Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/email-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@seamless-auth/messaging": minor
"@seamless-auth/messaging-aws": minor
---

Email messages can carry custom headers, such as `List-Unsubscribe` and `List-Unsubscribe-Post` for one-click unsubscribe. Header names and values are validated so a caller cannot inject further headers, and the headers the transport sets from the message itself (From, To, Subject) are refused.

The AWS email transport now sends through SES v2 (`@aws-sdk/client-sesv2`), since the v1 SendEmail call cannot carry custom headers. The IAM action is still `ses:SendEmail`. A `sesClient` passed in config must now be an SES v2 client, or anything that accepts the v2 `SendEmailCommand`.
610 changes: 206 additions & 404 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/aws/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@aws-sdk/client-ses": "^3.917.0",
"@aws-sdk/client-sesv2": "^3.1034.0",
"@aws-sdk/client-sns": "^3.917.0",
"@seamless-auth/messaging": "^0.2.0"
},
Expand Down
50 changes: 30 additions & 20 deletions packages/aws/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import {
} from "@aws-sdk/client-sns";
import {
SendEmailCommand,
SESClient,
SESv2Client,
type SendEmailCommandOutput,
type SendEmailCommandInput,
} from "@aws-sdk/client-ses";
} from "@aws-sdk/client-sesv2";
import type {
DeliveryResult,
EmailMessage,
Expand Down Expand Up @@ -56,35 +56,45 @@ class AwsSesEmailTransport implements EmailTransport {
assertNonEmptyString(config.fromEmail, "config.fromEmail");
assertLikelyEmail(config.fromEmail, "config.fromEmail");
this.config = config;
this.client = config.sesClient ?? new SESClient({ region: config.region });
this.client = config.sesClient ?? new SESv2Client({ region: config.region });
}

async send(message: EmailMessage): Promise<DeliveryResult> {
assertEmailMessage(message);

const headers = Object.entries(message.headers ?? {}).map(([Name, Value]) => ({
Name,
Value,
}));

// SES v2, because the v1 SendEmail call has no way to carry custom headers.
// Simple content is still authorised by ses:SendEmail, as before.
const input: SendEmailCommandInput = {
Source: message.from ?? this.config.fromEmail,
FromEmailAddress: message.from ?? this.config.fromEmail,
Destination: {
ToAddresses: [message.to],
},
Message: {
Subject: {
Charset: "UTF-8",
Data: message.subject,
},
Body: {
...(message.html
? {
Html: {
Charset: "UTF-8",
Data: message.html,
},
}
: {}),
Text: {
Content: {
Simple: {
Subject: {
Charset: "UTF-8",
Data: message.text,
Data: message.subject,
},
Body: {
...(message.html
? {
Html: {
Charset: "UTF-8",
Data: message.html,
},
}
: {}),
Text: {
Charset: "UTF-8",
Data: message.text,
},
},
...(headers.length > 0 ? { Headers: headers } : {}),
},
},
};
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export interface EmailMessage {
subject: string;
text: string;
html?: string;
/**
* Extra headers to send with the message, such as `List-Unsubscribe`. Names
* and values are passed through as given; a transport that cannot send
* custom headers ignores them.
*/
headers?: Record<string, string>;
}

export interface SmsMessage {
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,45 @@ export function assertEmailMessage(message: EmailMessage): void {
if (message.html !== undefined) {
assertNonEmptyString(message.html, "message.html");
}

if (message.headers !== undefined) {
assertEmailHeaders(message.headers);
}
}

// RFC 5322 field names are printable ASCII without a colon. A line break in
// either part would let a caller inject further headers, or a body.
const HEADER_NAME = /^[!-9;-~]+$/;
const LINE_BREAK = /[\r\n]/;

// Set by the transport from the message's own fields, so a header naming one
// would be a second, conflicting copy.
const RESERVED_HEADERS = new Set(["from", "to", "cc", "bcc", "subject", "content-type"]);

export function assertEmailHeaders(headers: Record<string, string>): void {
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
throw new MessagingError("INVALID_INPUT", `"message.headers" must be an object.`);
}

for (const [name, value] of Object.entries(headers)) {
if (!HEADER_NAME.test(name)) {
throw new MessagingError("INVALID_INPUT", `"${name}" is not a valid header name.`);
}

if (RESERVED_HEADERS.has(name.toLowerCase())) {
throw new MessagingError(
"INVALID_INPUT",
`"${name}" is set from the message itself and cannot be passed as a header.`,
);
}

if (typeof value !== "string" || value.trim().length === 0 || LINE_BREAK.test(value)) {
throw new MessagingError(
"INVALID_INPUT",
`The "${name}" header must be a non-empty string on a single line.`,
);
}
}
}

export function assertSmsMessage(message: SmsMessage): void {
Expand Down
75 changes: 67 additions & 8 deletions tests/providers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,63 @@ test("AWS SES provider sends normalized email payloads", async () => {
assert.equal(result.provider, "aws-ses");
assert.equal(result.channel, "email");
assert.equal(result.messageId, "ses-123");
assert.equal(capturedCommand.input.Source, "noreply@example.com");
assert.equal(capturedCommand.input.FromEmailAddress, "noreply@example.com");
assert.deepEqual(capturedCommand.input.Destination.ToAddresses, ["user@example.com"]);
assert.equal(capturedCommand.input.Message.Subject.Data, "Verify your email");
assert.equal(capturedCommand.input.Message.Body.Text.Data, "Code: ABCDEF");
assert.equal(capturedCommand.input.Content.Simple.Subject.Data, "Verify your email");
assert.equal(capturedCommand.input.Content.Simple.Body.Text.Data, "Code: ABCDEF");
assert.equal(capturedCommand.input.Content.Simple.Headers, undefined);
});

test("AWS SES provider passes custom headers through", async () => {
let capturedCommand;

const provider = createAwsEmailTransport({
region: "us-east-1",
fromEmail: "noreply@example.com",
sesClient: {
async send(command) {
capturedCommand = command;
return { MessageId: "ses-456" };
},
},
});

await provider.send({
to: "user@example.com",
subject: "What's new",
text: "A note.",
headers: {
"List-Unsubscribe": "<https://api.example.com/unsubscribe/abc>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
});

assert.deepEqual(capturedCommand.input.Content.Simple.Headers, [
{ Name: "List-Unsubscribe", Value: "<https://api.example.com/unsubscribe/abc>" },
{ Name: "List-Unsubscribe-Post", Value: "List-Unsubscribe=One-Click" },
]);
});

test("AWS SES provider refuses headers that would inject or collide", async () => {
const provider = createAwsEmailTransport({
region: "us-east-1",
fromEmail: "noreply@example.com",
sesClient: {
async send() {
throw new Error("should not be sent");
},
},
});
const base = { to: "user@example.com", subject: "Hi", text: "Hello" };

for (const headers of [
{ "List-Unsubscribe": "<https://a.example>\r\nBcc: x@example.com" },
{ "Bad Name": "value" },
{ Subject: "Another subject" },
{ "X-Empty": " " },
]) {
await assert.rejects(provider.send({ ...base, headers }), { code: "INVALID_INPUT" });
}
});

test("AWS SNS provider sends SMS with sender ID when configured", async () => {
Expand Down Expand Up @@ -149,7 +202,7 @@ test("Mixed provider composition works for SES email plus Twilio SMS", async ()
assert.equal(smsResult.provider, "twilio");
assert.equal(deliveries[0].type, "email");
assert.equal(deliveries[1].type, "sms");
assert.match(deliveries[0].input.Message.Subject.Data, /sign-in link/i);
assert.match(deliveries[0].input.Content.Simple.Subject.Data, /sign-in link/i);
assert.match(deliveries[1].input.body, /123456/);
});

Expand Down Expand Up @@ -287,9 +340,12 @@ test("Enrollment invites link to the sign-in page", async () => {
});

assert.equal(result.provider, "aws-ses");
assert.match(delivered[0].Message.Subject.Data, /add a passkey/i);
assert.match(delivered[0].Message.Body.Text.Data, /https:\/\/app\.example\.com\/login/);
assert.match(delivered[0].Message.Body.Html.Data, /href="https:\/\/app\.example\.com\/login"/);
assert.match(delivered[0].Content.Simple.Subject.Data, /add a passkey/i);
assert.match(delivered[0].Content.Simple.Body.Text.Data, /https:\/\/app\.example\.com\/login/);
assert.match(
delivered[0].Content.Simple.Body.Html.Data,
/href="https:\/\/app\.example\.com\/login"/,
);
});

test("Enrollment invites honour a custom handler and an override", async () => {
Expand Down Expand Up @@ -323,7 +379,10 @@ test("Enrollment invites honour a custom handler and an override", async () => {
to: "user@example.com",
signInUrl: "https://app.example.com/login",
});
assert.equal(delivered[0].Message.Subject.Data, "[Seamless Demo] passkeys for user@example.com");
assert.equal(
delivered[0].Content.Simple.Subject.Data,
"[Seamless Demo] passkeys for user@example.com",
);
});

test("Enrollment invites reject a sign-in URL that is not a URL", async () => {
Expand Down
Loading