Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | 1x 1x 1x 1x 1x 1x 1x 10x 10x 9x 9x 3x 3x 6x 1x 1x 1x 4x 4x 1x 2x 2x 2x 2x 2x 2x | /**
* Email Utilities
* Uses AWS SES to send transactional emails
*
* ## URL Handling
* Email links (e.g., invitation URLs) are derived from the request context,
* not from environment variables. This ensures correct URLs across all environments:
* - Local sandboxes (localhost:3001)
* - Feature branch deployments (jcn-4.gaininsight.co.uk)
* - Persistent environments (dev/test/prod.juncan.gaininsight.global)
*
* API routes should extract the base URL from the request and pass it to email functions:
* ```typescript
* const baseUrl = `https://${request.headers.get('host')}`;
* await sendInvitationEmail({ ...params, baseUrl });
* ```
*
* @see /docs/architecture/email-urls.md for detailed documentation
* @see JCN-27 Host header validation
*/
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
import outputs from "../../amplify_outputs.json";
const sesClient = new SESClient({
region: outputs.auth.aws_region,
});
const FROM_EMAIL = "andy@juncan.ai";
/**
* Allowed hosts for email URLs (JCN-27)
* Validates against this list to prevent phishing via host header injection
*/
const ALLOWED_HOSTS = [
"localhost:3000",
"localhost:3001",
"dev.juncan.gaininsight.global",
"test.juncan.gaininsight.global",
"juncan.gaininsight.global",
];
const DEFAULT_PRODUCTION_URL = "https://juncan.gaininsight.global";
/**
* Validate and sanitize a base URL from request host header
* Falls back to production URL if host is not in allowlist
*/
export function validateBaseUrl(baseUrl: string): string {
try {
const url = new URL(baseUrl);
const host = url.host;
if (!ALLOWED_HOSTS.includes(host)) {
console.warn(`Untrusted host header: ${host}, using default production URL`);
return DEFAULT_PRODUCTION_URL;
}
return baseUrl;
} catch {
console.warn(`Invalid base URL: ${baseUrl}, using default production URL`);
return DEFAULT_PRODUCTION_URL;
}
}
interface SendEmailParams {
to: string;
subject: string;
html: string;
text?: string;
}
export async function sendEmail({ to, subject, html, text }: SendEmailParams): Promise<void> {
const command = new SendEmailCommand({
Destination: {
ToAddresses: [to],
},
Message: {
Body: {
Html: {
Charset: "UTF-8",
Data: html,
},
...(text && {
Text: {
Charset: "UTF-8",
Data: text,
},
}),
},
Subject: {
Charset: "UTF-8",
Data: subject,
},
},
Source: FROM_EMAIL,
});
await sesClient.send(command);
}
/**
* Send an invitation email to join an organisation
*
* @param params.baseUrl - The base URL derived from the request (e.g., "https://jcn-4.gaininsight.co.uk")
* This ensures the invitation link works in any environment.
*/
export async function sendInvitationEmail(params: {
to: string;
organisationName: string;
inviterName: string;
role: string;
invitationToken: string;
baseUrl: string;
}): Promise<void> {
const { to, organisationName, inviterName, role, invitationToken, baseUrl } = params;
const validatedBaseUrl = validateBaseUrl(baseUrl);
const inviteUrl = `${validatedBaseUrl}/invite/${invitationToken}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>You've been invited to join ${organisationName}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">You've been invited!</h1>
<p style="color: #666; font-size: 16px;">
${inviterName} has invited you to join <strong>${organisationName}</strong> on Juncan as a <strong>${role}</strong>.
</p>
<p style="margin: 30px 0;">
<a href="${inviteUrl}"
style="background-color: #0070f3; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Accept Invitation
</a>
</p>
<p style="color: #999; font-size: 14px;">
This invitation will expire in 7 days.
</p>
<p style="color: #999; font-size: 14px;">
If you didn't expect this invitation, you can safely ignore this email.
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px;">
Juncan - Pre-owned marketplace platform
</p>
</body>
</html>
`;
const text = `
You've been invited to join ${organisationName}!
${inviterName} has invited you to join ${organisationName} on Juncan as a ${role}.
Accept the invitation here: ${inviteUrl}
This invitation will expire in 7 days.
If you didn't expect this invitation, you can safely ignore this email.
`.trim();
await sendEmail({
to,
subject: `You've been invited to join ${organisationName} on Juncan`,
html,
text,
});
}
|