Files
plunk/apps/api/src/services/SESService.ts
T
Matthias Endler 11c428d4ff fix(ses): emit List-Unsubscribe inside the header section, not the body
The raw MIME template in sendRawEmail produced a blank line between Content-Type and List-Unsubscribe whenever no custom headers were passed, because the ternary for custom headers expanded to an empty string surrounded by newlines.

Per RFC 5322 §2.1 a blank line terminates the header section, so List-Unsubscribe ended up as the first line of the body. Lenient clients (Gmail, Outlook) recover; strict clients (Thunderbird) do not, breaking one-click unsubscribe and degrading Gmail/Yahoo bulk-sender deliverability signals.

Collect optional headers (custom + List-Unsubscribe) into an array, filter empties, and append them to the Content-Type line with a single newline separator. No codepath can now produce a blank line inside the header block.
2026-04-30 15:20:06 +02:00

335 lines
9.8 KiB
TypeScript

import {SES} from '@aws-sdk/client-ses';
import signale from 'signale';
import {
AWS_SES_ACCESS_KEY_ID,
AWS_SES_REGION,
AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI,
SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED,
} from '../app/constants.js';
/**
* AWS SES Client
*/
export const ses = new SES({
apiVersion: '2010-12-01',
region: AWS_SES_REGION,
credentials: {
accessKeyId: AWS_SES_ACCESS_KEY_ID,
secretAccessKey: AWS_SES_SECRET_ACCESS_KEY,
},
});
interface SendRawEmailParams {
from: {
name: string;
email: string;
};
to: string[] | {name?: string; email: string}[];
content: {
subject: string;
html: string;
};
reply?: string;
headers?: Record<string, string> | null;
attachments?:
| {
filename: string;
content: string; // Base64 encoded
contentType: string;
contentId?: string;
disposition?: 'attachment' | 'inline';
}[]
| null;
tracking?: boolean;
}
/**
* Break long lines to comply with email RFC standards
*/
function breakLongLines(input: string, maxLineLength: number, isBase64 = false): string {
if (isBase64) {
// For base64 content, break at exact intervals without looking for spaces
const result = [];
for (let i = 0; i < input.length; i += maxLineLength) {
result.push(input.substring(i, i + maxLineLength));
}
return result.join('\n');
} else {
// For text content, break at spaces when possible
const lines = input.split('\n');
const result = [];
for (let line of lines) {
while (line.length > maxLineLength) {
let pos = maxLineLength;
while (pos > 0 && line[pos] !== ' ') {
pos--;
}
if (pos === 0) {
pos = maxLineLength;
}
result.push(line.substring(0, pos));
line = line.substring(pos).trim();
}
result.push(line);
}
return result.join('\n');
}
}
/**
* Send a raw email via AWS SES with full MIME formatting
*/
export async function sendRawEmail({
from,
to,
content,
reply,
headers,
attachments,
tracking = true,
}: SendRawEmailParams): Promise<{messageId: string}> {
// Check if the body contains an unsubscribe link
const regex = /unsubscribe\/([a-f\d-]+)"/;
const containsUnsubscribeLink = regex.exec(content.html);
let unsubscribeHeader = '';
if (containsUnsubscribeLink?.[1]) {
const unsubscribeId = containsUnsubscribeLink[1];
unsubscribeHeader = `List-Unsubscribe: <${DASHBOARD_URI}/unsubscribe/${unsubscribeId}>`;
}
// Generate unique boundaries for multipart messages
const altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
const mixedBoundary = attachments?.some(a => (a.disposition ?? 'attachment') === 'attachment')
? `----=_MixedPart_${Math.random().toString(36).substring(2)}`
: null;
const relatedBoundary = attachments?.some(a => a.disposition === 'inline')
? `----=_RelatedPart_${Math.random().toString(36).substring(2)}`
: null;
// Format To header with names if provided
const toHeader = to
.map(recipient => {
if (typeof recipient === 'string') {
return recipient;
} else {
return recipient.name ? `${recipient.name} <${recipient.email}>` : recipient.email;
}
})
.join(', ');
// Extract just email addresses for Destinations (SES requirement)
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
// Determine root content type
let rootContentType = `multipart/alternative; boundary="${altBoundary}"`;
if (mixedBoundary) {
rootContentType = `multipart/mixed; boundary="${mixedBoundary}"`;
} else if (relatedBoundary) {
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
}
// Build the additional headers (custom headers + List-Unsubscribe), filtering
// out empties so we never emit a blank line inside the header section.
// Per RFC 5322 §2.1, a blank line terminates the header section, so any blank
// line here would push subsequent headers (notably List-Unsubscribe) into the body.
const extraHeaderLines = [
...(headers ? Object.entries(headers).map(([key, value]) => `${key}: ${value}`) : []),
...(unsubscribeHeader ? [unsubscribeHeader] : []),
];
const extraHeaders = extraHeaderLines.length > 0 ? `\n${extraHeaderLines.join('\n')}` : '';
// Build raw MIME message
let rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
Content-Type: ${rootContentType}${extraHeaders}
`;
// building the body
if (mixedBoundary) {
rawMessage += `--${mixedBoundary}\n`;
if (relatedBoundary) {
rawMessage += `Content-Type: multipart/related; boundary="${relatedBoundary}"\n\n`;
rawMessage += `--${relatedBoundary}\n`;
}
} else if (relatedBoundary) {
rawMessage += `--${relatedBoundary}\n`;
}
// If we are nested, we need to specify that this next part is the alternative container
if (mixedBoundary || relatedBoundary) {
rawMessage += `Content-Type: multipart/alternative; boundary="${altBoundary}"\n\n`;
}
// The alternative part content (always contains HTML)
rawMessage += `--${altBoundary}
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
${breakLongLines(content.html, 500)}
--${altBoundary}--
`;
// Add inline attachments to the related container
if (relatedBoundary) {
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
for (const attachment of inlineAttachments) {
rawMessage += `\n--${relatedBoundary}
Content-Type: ${attachment.contentType}
Content-Transfer-Encoding: base64
Content-ID: <${attachment.contentId || attachment.filename}>
Content-Disposition: inline; filename="${attachment.filename}"
${breakLongLines(attachment.content, 76, true)}`;
}
rawMessage += `\n--${relatedBoundary}--`;
}
// Add regular attachments to the mixed container
if (mixedBoundary) {
const regularAttachments = attachments?.filter(a => (a.disposition ?? 'attachment') === 'attachment') ?? [];
for (const attachment of regularAttachments) {
rawMessage += `\n--${mixedBoundary}
Content-Type: ${attachment.contentType}
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="${attachment.filename}"
${breakLongLines(attachment.content, 76, true)}`;
}
rawMessage += `\n--${mixedBoundary}--`;
}
// Determine which configuration set to use
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
const configurationSetName =
TRACKING_TOGGLE_ENABLED && !tracking ? SES_CONFIGURATION_SET_NO_TRACKING : SES_CONFIGURATION_SET;
// Send via SES
const response = await ses.sendRawEmail({
Destinations: destinations,
ConfigurationSetName: configurationSetName,
RawMessage: {
Data: new TextEncoder().encode(rawMessage),
},
Source: `${from.name} <${from.email}>`,
});
if (!response.MessageId) {
throw new Error('Could not send email');
}
return {messageId: response.MessageId};
}
/**
* Get verification attributes for multiple domain identities
*/
export const getIdentities = async (domains: string[]): Promise<{domain: string; status: string}[]> => {
const res = await ses.getIdentityVerificationAttributes({
Identities: domains,
});
const parsedResult = Object.entries(res.VerificationAttributes ?? {});
return parsedResult.map(obj => {
return {domain: obj[0], status: obj[1].VerificationStatus ?? 'NotStarted'};
});
};
/**
* Verify a domain and get DKIM tokens for DNS configuration
*/
export const verifyDomain = async (domain: string): Promise<string[]> => {
// Verify DKIM for the domain
const DKIM = await ses.verifyDomainDkim({Domain: domain});
// Set custom MAIL FROM domain (plunk.yourdomain.com)
await ses.setIdentityMailFromDomain({
Identity: domain,
MailFromDomain: `plunk.${domain}`,
});
return DKIM.DkimTokens ?? [];
};
/**
* Get DKIM verification attributes for a domain
*/
export const getDomainVerificationAttributes = async (domain: string) => {
const attributes = await ses.getIdentityDkimAttributes({
Identities: [domain],
});
const parsedAttributes = Object.entries(attributes.DkimAttributes ?? {});
if (parsedAttributes.length === 0) {
return {
domain,
tokens: [],
status: 'NotStarted',
};
}
const firstAttribute = parsedAttributes[0];
if (!firstAttribute) {
return {
domain,
tokens: [],
status: 'NotStarted',
};
}
return {
domain: firstAttribute[0],
tokens: firstAttribute[1].DkimTokens ?? [],
status: firstAttribute[1].DkimVerificationStatus ?? 'NotStarted',
};
};
/**
* Disable bounce/complaint forwarding for a verified domain
*/
export const disableFeedbackForwarding = async (domain: string): Promise<void> => {
await ses.setIdentityFeedbackForwardingEnabled({
Identity: domain,
ForwardingEnabled: false,
});
};
/**
* Delete a verified domain identity from AWS SES
*/
export const deleteIdentity = async (domain: string): Promise<void> => {
await ses.deleteIdentity({Identity: domain});
};
/**
* Get AWS SES account sending quota and rate limit
* @returns MaxSendRate (emails per second) or null if the call fails
*/
export const getSendingQuota = async (): Promise<{
maxSendRate: number;
max24HourSend: number;
sentLast24Hours: number;
} | null> => {
try {
const quota = await ses.getSendQuota({});
return {
maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided
max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit
sentLast24Hours: quota.SentLast24Hours ?? 0,
};
} catch (error) {
signale.error('[SES] Failed to fetch sending quota:', error);
return null;
}
};