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
7 changes: 7 additions & 0 deletions .changeset/messaging-email-headers.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 42 additions & 1 deletion src/schemas/messaging/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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': '<https://example.com/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();
});
});
13 changes: 13 additions & 0 deletions src/schemas/messaging/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof EmailMessageSchema>;
Expand Down Expand Up @@ -59,6 +62,16 @@ export const SendMagicLinkEmailInputSchema = z.object({

export type SendMagicLinkEmailInput = z.infer<typeof SendMagicLinkEmailInputSchema>;

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<typeof SendEnrollmentInviteEmailInputSchema>;

/**
* 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
Expand Down
Loading