From 18ba713c6762003a76b96069eda8319b0b785bc7 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 31 Oct 2026 08:42:48 -0400 Subject: [PATCH] feat(messaging): add email headers and the enrollment invite input --- .changeset/messaging-email-headers.md | 7 +++++ src/schemas/messaging/schema.test.ts | 43 ++++++++++++++++++++++++++- src/schemas/messaging/schema.ts | 13 ++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 .changeset/messaging-email-headers.md diff --git a/.changeset/messaging-email-headers.md b/.changeset/messaging-email-headers.md new file mode 100644 index 0000000..02d7520 --- /dev/null +++ b/.changeset/messaging-email-headers.md @@ -0,0 +1,7 @@ +--- +'@seamless-auth/types': minor +--- + +`EmailMessageSchema` accepts an optional `headers` record of string names to string values, such as `List-Unsubscribe` and `List-Unsubscribe-Post` for one-click unsubscribe. The inferred `EmailMessage` type gains the optional `headers` field. `@seamless-auth/messaging` already sends these through SES v2 and validates the names and values there. + +New `SendEnrollmentInviteEmailInputSchema` and `SendEnrollmentInviteEmailInput`: the input for the email that asks a user to sign in and add a passkey. `signInUrl` is the application's normal sign-in page and carries no credential. diff --git a/src/schemas/messaging/schema.test.ts b/src/schemas/messaging/schema.test.ts index 38ccaed..dd0e2eb 100644 --- a/src/schemas/messaging/schema.test.ts +++ b/src/schemas/messaging/schema.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { AuthDeliverySchema, DeliveryResultSchema } from './schema.js'; +import { + AuthDeliverySchema, + DeliveryResultSchema, + EmailMessageSchema, + SendEnrollmentInviteEmailInputSchema, +} from './schema.js'; describe('AuthDeliverySchema', () => { it('parses an email OTP delivery', () => { @@ -101,3 +106,39 @@ describe('DeliveryResultSchema', () => { ).toThrow(); }); }); + +describe('EmailMessageSchema', () => { + const message = { to: 'user@example.com', subject: 'Hello', text: 'Hi' }; + + it('parses a message without headers', () => { + expect(EmailMessageSchema.parse(message).headers).toBeUndefined(); + }); + + it('keeps custom headers', () => { + const headers = { + 'List-Unsubscribe': '', + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }; + + expect(EmailMessageSchema.parse({ ...message, headers }).headers).toEqual(headers); + }); + + it('rejects a header value that is not a string', () => { + expect(() => EmailMessageSchema.parse({ ...message, headers: { 'X-Count': 1 } })).toThrow(); + }); +}); + +describe('SendEnrollmentInviteEmailInputSchema', () => { + it('parses an invite with only the required fields', () => { + expect(() => + SendEnrollmentInviteEmailInputSchema.parse({ + to: 'user@example.com', + signInUrl: 'https://example.com/sign-in', + }), + ).not.toThrow(); + }); + + it('rejects an invite without a sign-in URL', () => { + expect(() => SendEnrollmentInviteEmailInputSchema.parse({ to: 'user@example.com' })).toThrow(); + }); +}); diff --git a/src/schemas/messaging/schema.ts b/src/schemas/messaging/schema.ts index 5a9b619..2795545 100644 --- a/src/schemas/messaging/schema.ts +++ b/src/schemas/messaging/schema.ts @@ -20,6 +20,9 @@ export const EmailMessageSchema = z.object({ subject: z.string(), text: z.string(), html: z.string().optional(), + // Passed through as given, such as `List-Unsubscribe`. A transport that cannot send custom + // headers ignores them, and the messaging transports refuse names that would inject more. + headers: z.record(z.string(), z.string()).optional(), }); export type EmailMessage = z.infer; @@ -59,6 +62,16 @@ export const SendMagicLinkEmailInputSchema = z.object({ export type SendMagicLinkEmailInput = z.infer; +export const SendEnrollmentInviteEmailInputSchema = z.object({ + to: z.string(), + // The sign-in page. It carries no credential, so it signs nobody in on its own. + signInUrl: z.string(), + from: z.string().optional(), + subject: z.string().optional(), +}); + +export type SendEnrollmentInviteEmailInput = z.infer; + /** * What the auth API tells a self-hosted server to deliver. The API never sends * the message itself when the adopter owns delivery, so this is the contract