From 962bc55bc8388b7cfe458ad0d2597dae8e152d4a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 2 Nov 2026 23:19:01 +0800 Subject: [PATCH] feat(email): expose sendEmail threading as a public option Add `threading: { inReplyTo?, references? }` to SendEmailOptions so a reply sent later (for example from a queue job) continues the thread. The ids are cleaned with the same rules replyEmail uses: a bad id is left out instead of failing the send, References keeps the newest ids within 8 KiB and falls back to In-Reply-To. Only non-string values return MIME_ERROR. `headers` still drops In-Reply-To and References. The shared checks move to threading.ts. The line-length check now follows foldHeader's quote rules, so a stray `"` in an id no longer makes the send fail with MIME_ERROR. Fixes #665 Co-Authored-By: Claude Opus 5.5 --- docs/guide/email/receiving.md | 2 +- docs/guide/email/sending.md | 54 ++++-- packages/void/src/runtime/email/inbound.ts | 114 +----------- packages/void/src/runtime/email/mime.ts | 74 ++++---- packages/void/src/runtime/email/threading.ts | 169 +++++++++++++++++ packages/void/src/runtime/email/types.ts | 21 ++- packages/void/test/unit/email-mime.test.ts | 180 +++++++++++++++---- packages/void/test/unit/email-send.test.ts | 39 ++++ 8 files changed, 461 insertions(+), 192 deletions(-) create mode 100644 packages/void/src/runtime/email/threading.ts diff --git a/docs/guide/email/receiving.md b/docs/guide/email/receiving.md index d237bdb0e..c48c1ecec 100644 --- a/docs/guide/email/receiving.md +++ b/docs/guide/email/receiving.md @@ -78,7 +78,7 @@ export default defineEmail(async (message, env, ctx, info) => { }); ``` -`replyEmail()` replies to the original sender with a threaded subject. You can override `subject`. +`replyEmail()` replies to the original sender with a threaded subject. You can override `subject`. To reply after the handler returns, use [`sendEmail` with `threading`](./sending.md#reply-later). On a Void platform, the default sender is your project's shared address, or the address that received mail on your registered domain. Use that default for shared mail; `message.to` has the project prefix removed. Sender overrides must belong to your project. diff --git a/docs/guide/email/sending.md b/docs/guide/email/sending.md index 64a828b19..a3de23f2b 100644 --- a/docs/guide/email/sending.md +++ b/docs/guide/email/sending.md @@ -67,18 +67,19 @@ For mail to arbitrary users, [register your own domain](./domains.md#your-own-do ## Options {#options} -| Option | Type | Notes | -| ---------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `from` | `string \| { email, name? }` | Optional. Pinned to the project sender — see below. | -| `to` | `Address \| Address[]` | Required. One or more recipients. | -| `subject` | `string` | Required. UTF-8 supported (encoded as RFC 2047). | -| `text` | `string` | At least one of `text` / `html` is required. | -| `html` | `string` | Sent as `multipart/alternative` if both are provided. | -| `replyTo` | `Address` | Optional `Reply-To` header. | -| `cc`, `bcc` | `Address \| Address[]` | Optional. Each recipient is sent its own message envelope. | -| `headers` | `Record` | Custom headers; reserved headers (From, Date, etc.) are ignored. | -| `attachments` | `Attachment[]` | See [Attachments](#attachments). | -| `idempotencyKey` | `string` | Optional on the Void Platform: 1–128 printable, non-space ASCII characters. Reuse for retries of the same send. | +| Option | Type | Notes | +| ---------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `from` | `string \| { email, name? }` | Optional. Pinned to the project sender — see below. | +| `to` | `Address \| Address[]` | Required. One or more recipients. | +| `subject` | `string` | Required. UTF-8 supported (encoded as RFC 2047). | +| `text` | `string` | At least one of `text` / `html` is required. | +| `html` | `string` | Sent as `multipart/alternative` if both are provided. | +| `replyTo` | `Address` | Optional `Reply-To` header. | +| `cc`, `bcc` | `Address \| Address[]` | Optional. Each recipient is sent its own message envelope. | +| `headers` | `Record` | Custom headers; reserved headers (From, Date, etc.) are ignored. | +| `threading` | `{ inReplyTo?, references? }` | Optional. Threads the email under an earlier message. See [Reply later](#reply-later). | +| `attachments` | `Attachment[]` | See [Attachments](#attachments). | +| `idempotencyKey` | `string` | Optional on the Void Platform: 1–128 printable, non-space ASCII characters. Reuse for retries of the same send. | You can send to at most 50 recipients across `to`, `cc`, and `bcc`. Use standard email addresses with ASCII local parts, and punycode for internationalized domains. @@ -88,6 +89,35 @@ On the platform the sender is pinned to your project. `from` must be your projec On your own Cloudflare account, `from` defaults to `email.from` from `void.config.ts` and must be on a domain your account can send from; Cloudflare rejects any other sender and `sendEmail` reports it as `INVALID_FROM`. +## Reply later {#reply-later} + +`replyEmail()` works only while the inbound handler runs. To reply later, for example from a queue job, save the thread ids in the handler and pass them to `sendEmail` as `threading`: + +```ts +// email/support.ts +export default defineEmail(async (message) => { + await tickets.save({ + from: message.from, + subject: message.headers.get('Subject') ?? '', + messageId: message.headers.get('Message-ID') ?? '', + references: message.headers.get('References') ?? '', + }); +}); + +// Later, in a queue job +await sendEmail({ + to: ticket.from, + subject: `Re: ${ticket.subject}`, + text: 'We fixed it.', + threading: { + inReplyTo: ticket.messageId, + references: `${ticket.references} ${ticket.messageId}`, + }, +}); +``` + +`inReplyTo` is the parent message's `Message-ID`. `references` lists the thread's ids, oldest first, ending with the parent; it defaults to `inReplyTo`. Mail clients use them to show the reply in the same conversation. An id that cannot be sent, such as one with a control character, is left out. If `references` cannot be sent, it falls back to `inReplyTo`. The email still sends. `headers` cannot set `In-Reply-To` or `References`. + ## Attachments {#attachments} ```ts diff --git a/packages/void/src/runtime/email/inbound.ts b/packages/void/src/runtime/email/inbound.ts index f57c4c0cc..7ba01e25b 100644 --- a/packages/void/src/runtime/email/inbound.ts +++ b/packages/void/src/runtime/email/inbound.ts @@ -17,12 +17,12 @@ import { withTimeout } from '../email-protocol.ts'; import { getRawRuntimeEnv } from '../env-raw.ts'; import { buildMime, - indexOfControl, MAX_ADDRESS_LENGTH, MimeError, normalizeEmail, validateAddressParts, } from './mime.ts'; +import { fitsHeaderLine, replyReferences, threadingId } from './threading.ts'; import type { Address, Attachment } from './types.ts'; // SDK limits give handlers early errors. The gateway independently validates @@ -649,79 +649,6 @@ export function createMessageProxy( return { message, getActions: () => [...actions] }; } -/** - * Whether `Name: value` fits RFC 5322's 998-octet line. Folding happens only - * at SP or HTAB, so the widest unbroken run decides. - */ -function fitsHeaderLine(name: string, value: string | undefined): value is string { - if (!value) { - return false; - } - // One pass without allocation: tokenizing a hostile 10 MiB header could - // exhaust the isolate. Other `\s` characters do not end a run, because - // `foldHeader` cannot fold at them. - const budget = 998 - (name.length + 2); - let run = 0; - for (let i = 0; i < value.length; i++) { - const c = value.charCodeAt(i); - if (c === 0x20 || c === 0x09) { - run = 0; - continue; - } - // UTF-8 octets: a surrogate pair is 4, a lone surrogate (U+FFFD) is 3. - if (c < 0x80) { - run += 1; - } else if (c < 0x800) { - run += 2; - } else if ( - c >= 0xd800 && - c <= 0xdbff && - i + 1 < value.length && - (value.charCodeAt(i + 1) & 0xfc00) === 0xdc00 - ) { - run += 4; - i++; - } else { - run += 3; - } - if (run > budget) { - return false; - } - } - return true; -} - -/** - * `chain` without the ids that hold a control character, joined by single - * spaces. Called only on a chain already cut to `MAX_REPLY_REFERENCES_CHARS`. - */ -function dropControlIds(chain: string): string { - if (indexOfControl(chain) === -1) { - return chain; - } - const kept: Array = []; - let start = -1; - let tainted = false; - for (let i = 0; i <= chain.length; i++) { - const c = i < chain.length ? chain.charCodeAt(i) : 0x20; - if (c === 0x20 || c === 0x09) { - if (start >= 0 && !tainted) { - kept.push(chain.slice(start, i)); - } - start = -1; - tainted = false; - } else { - if (start < 0) { - start = i; - } - if (c < 0x20 || c === 0x7f) { - tainted = true; - } - } - } - return kept.join(' '); -} - export interface ReplyEmailOptions { /** * On the managed platform (a `__VOID_PROJECT_SLUG` is injected) defaults @@ -797,40 +724,6 @@ function prefixReplySubject(original: string): string { return prefixed; } -/** - * Longest `References` value a reply carries, in UTF-16 code units. 8 KiB - * keeps any real thread whole; past it the newest ids are kept, since mail - * clients thread on any id they recognise and the parent is `In-Reply-To`. - */ -const MAX_REPLY_REFERENCES_CHARS = 8192; - -/** - * The reply's `References`, or `''`: the inbound chain plus the parent's id, - * trimmed to its newest whole ids, or the parent's id alone when an id - * cannot fit a line. - */ -function replyReferences(originalReferences: string, messageId: string): string { - const parent = messageId ? ` ${messageId}` : ''; - let chain = originalReferences; - const budget = MAX_REPLY_REFERENCES_CHARS - parent.length; - if (chain.length > budget) { - // Cut at the next SP or HTAB so no id is split. - let cut = chain.length - budget; - while (cut < chain.length) { - const c = chain.charCodeAt(cut); - if (c === 0x20 || c === 0x09) { - break; - } - cut++; - } - chain = chain.slice(cut); - } - // After the cut, so the walk is bounded; `parent` was already checked. - chain = dropControlIds(chain); - chain = `${chain}${parent}`.trim(); - return fitsHeaderLine('References', chain) ? chain : messageId; -} - /** * Minimal RFC 2047 decoder for the reply subject: B and Q encoded-words, * unknown charsets read as UTF-8, malformed input returned unchanged. @@ -946,10 +839,7 @@ export async function buildReplyMessage( // ids are remote-controlled: drop one that cannot fit a line or holds a // control character rather than fail the reply. const threading: { inReplyTo?: string; references?: string } = {}; - const messageId = - fitsHeaderLine('In-Reply-To', originalMessageId) && indexOfControl(originalMessageId) === -1 - ? originalMessageId - : ''; + const messageId = threadingId('In-Reply-To', originalMessageId); if (messageId) { threading.inReplyTo = messageId; } diff --git a/packages/void/src/runtime/email/mime.ts b/packages/void/src/runtime/email/mime.ts index e239b9239..ec924df74 100644 --- a/packages/void/src/runtime/email/mime.ts +++ b/packages/void/src/runtime/email/mime.ts @@ -8,7 +8,14 @@ import { validate as isValidEmailSyntax } from 'email-validator'; import { bytesToBase64 } from '../base64.ts'; -import type { Address, Attachment, NormalizedAttachment, NormalizedEmail } from './types.ts'; +import { indexOfControl, threadingId, threadingReferences, trimFws } from './threading.ts'; +import type { + Address, + Attachment, + NormalizedAttachment, + NormalizedEmail, + SendEmailOptions, +} from './types.ts'; /** Encoded outbound MIME ceiling, separate from the 10 MiB inbound limit. */ export const MAX_MESSAGE_BYTES: number = 5 * 1024 * 1024; @@ -49,20 +56,6 @@ function rejectCRLF(field: string, value: string): void { } } -/** - * Index of the first C0 control other than HTAB, or DEL; -1 when there is - * none. HTAB is folding whitespace (RFC 5322 §3.2.2). - */ -export function indexOfControl(value: string): number { - for (let i = 0; i < value.length; i++) { - const c = value.charCodeAt(i); - if ((c < 0x20 && c !== 0x09) || c === 0x7f) { - return i; - } - } - return -1; -} - /** * Refuse a control in a value rendered verbatim into a header (an * attachment's filename, media type and Content-ID). Names the code point, @@ -524,7 +517,7 @@ export async function normalizeEmail(opts: { bcc?: Address | Array
; headers?: Record; attachments?: Array; - threading?: { inReplyTo?: string; references?: string }; + threading?: SendEmailOptions['threading']; }): Promise { if (!opts.text && !opts.html) { throw new MimeError('MIME_ERROR', "email: at least one of 'text' or 'html' is required."); @@ -626,13 +619,7 @@ export async function normalizeEmail(opts: { } attachments.push(normalized); } - // Threading comes from the trusted reply path, but CR/LF are still refused. - if (opts.threading?.inReplyTo) { - rejectCRLF('threading.inReplyTo', opts.threading.inReplyTo); - } - if (opts.threading?.references) { - rejectCRLF('threading.references', opts.threading.references); - } + const threading = normalizeThreading(opts.threading); return { from, @@ -645,10 +632,39 @@ export async function normalizeEmail(opts: { html: opts.html, headers: opts.headers ? { ...opts.headers } : {}, attachments, - threading: opts.threading, + threading, }; } +/** + * `threading` from `sendEmail`, `createEmail` or `replyEmail`. A value that is + * not a string is a caller error. Thread ids often come from remote mail, so a + * bad id never fails the email: an `inReplyTo` with a control character or + * too long for one line is left out, and a `references` chain that cannot be + * sent falls back to `inReplyTo`. + */ +function normalizeThreading(threading: unknown): NormalizedEmail['threading'] { + if (threading === undefined) { + return undefined; + } + if (typeof threading !== 'object' || threading === null || Array.isArray(threading)) { + throw new MimeError('MIME_ERROR', 'threading: must be an object.'); + } + const { inReplyTo = '', references = '' } = threading as Record; + if (typeof inReplyTo !== 'string') { + throw new MimeError('MIME_ERROR', 'threading.inReplyTo: must be a string.'); + } + if (typeof references !== 'string') { + throw new MimeError('MIME_ERROR', 'threading.references: must be a string.'); + } + const parent = threadingId('In-Reply-To', trimFws(inReplyTo)); + const chain = threadingReferences(references) || parent; + if (!parent && !chain) { + return undefined; + } + return { ...(parent ? { inReplyTo: parent } : {}), ...(chain ? { references: chain } : {}) }; +} + /** * RFC 2047 §2: a 75-character encoded-word less `=?UTF-8?B?` and `?=` leaves * 63 characters of base64, so 45 source bytes. @@ -1129,9 +1145,8 @@ const RESERVED_HEADERS = new Set([ 'reply-to', 'date', 'message-id', - // Threading headers are owned by replyEmail() — accepting them via - // the user-supplied `headers` map would let a caller spoof reply - // chains. replyEmail builds them itself from the original message. + // Threading has one channel, `threading`, which checks each id. replyEmail + // builds it from the original message; sendEmail takes it from the caller. 'in-reply-to', 'references', 'mime-version', @@ -1319,9 +1334,8 @@ export function buildMime(email: NormalizedEmail): { raw: Uint8Array; messageId: } topHeaders.push(['Subject', encodeWord(email.subject)]); topHeaders.push(['Message-ID', messageId]); - // Threading headers from the trusted reply path. These are reserved - // against user-supplied `headers` (RESERVED_HEADERS) so the only way - // they land in the wire MIME is via `email.threading`. + // Reserved against user-supplied `headers` (RESERVED_HEADERS), so these + // land in the wire MIME only through the checked `email.threading`. if (email.threading?.inReplyTo) { topHeaders.push(['In-Reply-To', email.threading.inReplyTo]); } diff --git a/packages/void/src/runtime/email/threading.ts b/packages/void/src/runtime/email/threading.ts new file mode 100644 index 000000000..d94924eb4 --- /dev/null +++ b/packages/void/src/runtime/email/threading.ts @@ -0,0 +1,169 @@ +/** + * Header-value checks shared by the MIME builder and the reply path, and the + * best-effort cleaning of `In-Reply-To` / `References` ids. Thread ids usually + * come from remote mail, so an id that cannot be sent is left out instead of + * failing the message. No imports, so both `mime.ts` and `inbound.ts` can use it. + */ + +/** + * Index of the first C0 control other than HTAB, or DEL; -1 when there is + * none. HTAB is folding whitespace (RFC 5322 §3.2.2). + */ +export function indexOfControl(value: string): number { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if ((c < 0x20 && c !== 0x09) || c === 0x7f) { + return i; + } + } + return -1; +} + +/** + * `value` without leading or trailing SP and HTAB, the only whitespace a + * `Headers` value is trimmed of. `String#trim` would also strip U+00A0. + */ +export function trimFws(value: string): string { + let start = 0; + let end = value.length; + while (start < end && (value.charCodeAt(start) === 0x20 || value.charCodeAt(start) === 0x09)) { + start++; + } + while ( + end > start && + (value.charCodeAt(end - 1) === 0x20 || value.charCodeAt(end - 1) === 0x09) + ) { + end--; + } + return value.slice(start, end); +} + +/** + * Whether `Name: value` fits RFC 5322's 998-octet line once folded. It + * follows `foldHeader`: a fold point is SP or HTAB outside a quoted string, + * so the widest run between fold points decides. + */ +export function fitsHeaderLine(name: string, value: string | undefined): value is string { + if (!value) { + return false; + } + // One pass without allocation: tokenizing a hostile 10 MiB header could + // exhaust the isolate. Other `\s` characters do not end a run, because + // `foldHeader` cannot fold at them. + const budget = 998 - (name.length + 2); + let run = 0; + let inQuotes = false; + let escape = false; + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (escape) { + escape = false; + } else if (c === 0x5c && inQuotes) { + escape = true; + } else if (c === 0x22) { + inQuotes = !inQuotes; + } else if ((c === 0x20 || c === 0x09) && !inQuotes) { + run = 0; + continue; + } + // UTF-8 octets: a surrogate pair is 4, a lone surrogate (U+FFFD) is 3. + if (c < 0x80) { + run += 1; + } else if (c < 0x800) { + run += 2; + } else if ( + c >= 0xd800 && + c <= 0xdbff && + i + 1 < value.length && + (value.charCodeAt(i + 1) & 0xfc00) === 0xdc00 + ) { + run += 4; + i++; + } else { + run += 3; + } + if (run > budget) { + return false; + } + } + return true; +} + +/** + * Longest `References` value, in UTF-16 code units. 8 KiB keeps any real + * thread whole; past it the newest ids are kept, since mail clients thread on + * any id they recognise and the parent is `In-Reply-To`. + */ +const MAX_REFERENCES_CHARS = 8192; + +/** + * The newest whole ids of `chain` that fit `budget` code units, without the + * ids that hold a control character. + */ +function newestIds(chain: string, budget: number): string { + if (chain.length > budget) { + // Cut at the next SP or HTAB so no id is split. + let cut = chain.length - budget; + while (cut < chain.length) { + const c = chain.charCodeAt(cut); + if (c === 0x20 || c === 0x09) { + break; + } + cut++; + } + chain = chain.slice(cut); + } + // After the cut, so the walk is bounded. + return dropControlIds(chain); +} + +/** `chain` without the ids that hold a control character, joined by single spaces. */ +function dropControlIds(chain: string): string { + if (indexOfControl(chain) === -1) { + return chain; + } + const kept: Array = []; + let start = -1; + let tainted = false; + for (let i = 0; i <= chain.length; i++) { + const c = i < chain.length ? chain.charCodeAt(i) : 0x20; + if (c === 0x20 || c === 0x09) { + if (start >= 0 && !tainted) { + kept.push(chain.slice(start, i)); + } + start = -1; + tainted = false; + } else { + if (start < 0) { + start = i; + } + if (c < 0x20 || c === 0x7f) { + tainted = true; + } + } + } + return kept.join(' '); +} + +/** `id` when it fits one `name:` line and holds no control character, else `''`. */ +export function threadingId(name: string, id: string): string { + return fitsHeaderLine(name, id) && indexOfControl(id) === -1 ? id : ''; +} + +/** `References` cut to its newest whole ids, or `''` when an id cannot fit a line. */ +export function threadingReferences(chain: string): string { + const kept = newestIds(chain, MAX_REFERENCES_CHARS).trim(); + return fitsHeaderLine('References', kept) ? kept : ''; +} + +/** + * A reply's `References`, or `''`: the inbound chain plus the parent's id, + * trimmed to its newest whole ids, or the parent's id alone when an id + * cannot fit a line. `messageId` was already checked with `threadingId`. + */ +export function replyReferences(originalReferences: string, messageId: string): string { + const parent = messageId ? ` ${messageId}` : ''; + const chain = + `${newestIds(originalReferences, MAX_REFERENCES_CHARS - parent.length)}${parent}`.trim(); + return fitsHeaderLine('References', chain) ? chain : messageId; +} diff --git a/packages/void/src/runtime/email/types.ts b/packages/void/src/runtime/email/types.ts index bbe4628a6..e9a762b9a 100644 --- a/packages/void/src/runtime/email/types.ts +++ b/packages/void/src/runtime/email/types.ts @@ -31,8 +31,21 @@ export interface SendEmailOptions { replyTo?: Address; cc?: Address | Array
; bcc?: Address | Array
; + /** Custom headers. Reserved names such as `From`, `Date` and the threading headers are ignored. */ headers?: Record; attachments?: Array; + /** + * Threads this email under an earlier message, such as a reply sent from a + * queue job after the inbound handler returned. `inReplyTo` is the parent's + * `Message-ID`. `references` lists the thread's ids, oldest first, ending + * with the parent; it defaults to `inReplyTo`. An id that cannot be sent, + * such as one with a control character, is left out. If `references` + * cannot be sent, it falls back to `inReplyTo`. The email still sends. + */ + threading?: { + inReplyTo?: string; + references?: string; + }; /** * Deduplicates a platform send for 30 days. Reuse the same key and message * when checking an uncertain attempt; a changed message is a conflict. @@ -134,11 +147,9 @@ export interface NormalizedEmail { headers: Record; attachments: Array; /** - * Internal-only threading headers, set by `replyEmail`/`buildReplyMessage`. - * The MIME builder emits these as proper `In-Reply-To` / `References` - * top-level headers. User-supplied `headers` with these names are - * always dropped (they're in `RESERVED_HEADERS`); this field is the - * supported channel for the trusted reply path. + * `In-Reply-To` / `References` after the ids were checked, from the + * `threading` option or `replyEmail`. User-supplied `headers` with these + * names are always dropped (they're in `RESERVED_HEADERS`). */ threading?: { inReplyTo?: string; diff --git a/packages/void/test/unit/email-mime.test.ts b/packages/void/test/unit/email-mime.test.ts index 8d416febb..8aeb2663c 100644 --- a/packages/void/test/unit/email-mime.test.ts +++ b/packages/void/test/unit/email-mime.test.ts @@ -33,6 +33,19 @@ async function build(opts: Parameters[0]) { return { raw, messageId, text: decode(raw) }; } +type Threading = Parameters[0]['threading']; + +/** + * `normalizeEmail` caps threading ids, so size checks that need a larger + * value hand them to `buildMime` directly: it is the authority on the size. + */ +async function normalizeWithThreading( + opts: Parameters[0], + threading: Threading, +): Promise[0]> { + return { ...(await normalizeEmail(opts)), threading }; +} + function b64ToText(b64: string): string { const binary = atob(b64); const bytes = new Uint8Array(binary.length); @@ -1690,7 +1703,7 @@ describe('buildMime', () => { ).rejects.toThrow(/from/); }); - it('drops user-supplied In-Reply-To and References (reserved for replyEmail)', async () => { + it('drops user-supplied In-Reply-To and References (only `threading` sets them)', async () => { const { text } = await build({ from: 'a@x.dev', to: 'b@y.dev', @@ -1705,6 +1718,119 @@ describe('buildMime', () => { expect(text).not.toContain('spoof-references'); }); + describe('threading option', () => { + const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 'Re: s', text: 't' }; + + it('renders In-Reply-To and References after Message-ID', async () => { + const { text } = await build({ + ...base, + threading: { inReplyTo: '', references: ' ' }, + }); + expect(text).toMatch( + /Message-ID: <[^>]+>\r\nIn-Reply-To: \r\nReferences: \r\n/, + ); + }); + + it('defaults References to In-Reply-To', async () => { + const email = await normalizeEmail({ ...base, threading: { inReplyTo: ' ' } }); + expect(email.threading).toEqual({ + inReplyTo: '', + references: '', + }); + }); + + it('still sends when a stray quote stops the folder from breaking the line', async () => { + // Inside a quoted string `foldHeader` cannot fold at a space, so the + // whole rest of the value is one run. + const quotedChain = `<"a@x> ${' '.repeat(200)}`; + for (const threading of [ + { inReplyTo: '', references: quotedChain }, + { inReplyTo: `"${' a'.repeat(600)}` }, + ]) { + const email = await normalizeEmail({ ...base, threading }); + const text = decode(buildMime(email).raw); + expect(text).not.toContain('<"a@x>'); + expect(text).not.toContain('In-Reply-To: "'); + } + const fallback = await normalizeEmail({ + ...base, + threading: { inReplyTo: '', references: quotedChain }, + }); + expect(fallback.threading).toEqual({ inReplyTo: '', references: '' }); + }); + + it('trims only spaces and tabs from inReplyTo', async () => { + const email = await normalizeEmail({ + ...base, + threading: { inReplyTo: '\t \u00a0 ' }, + }); + expect(email.threading?.inReplyTo).toBe('\u00a0'); + }); + + it('leaves out an id that holds a control character or cannot fit a line, and still sends', async () => { + for (const inReplyTo of [ + '', + '', + `<${'x'.repeat(1000)}@x>`, + ]) { + const email = await normalizeEmail({ ...base, threading: { inReplyTo } }); + expect(email.threading).toBeUndefined(); + expect(decode(buildMime(email).raw)).not.toContain('In-Reply-To'); + } + const email = await normalizeEmail({ + ...base, + threading: { + inReplyTo: '', + references: ' ', + }, + }); + expect(email.threading).toEqual({ + inReplyTo: '', + references: ' ', + }); + // A chain with an id too long for one line falls back to the parent. + const long = await normalizeEmail({ + ...base, + threading: { inReplyTo: '', references: `<${'x'.repeat(1000)}@x>` }, + }); + expect(long.threading).toEqual({ inReplyTo: '', references: '' }); + }); + + it('keeps the newest whole ids of a References chain over 8 KiB', async () => { + const ids = Array.from( + { length: 1000 }, + (_, i) => ``, + ); + const email = await normalizeEmail({ ...base, threading: { references: ids.join(' ') } }); + const kept = email.threading!.references!; + expect(kept.length).toBeLessThanOrEqual(8192); + expect(kept.endsWith('')).toBe(true); + expect(ids.join(' ').endsWith(kept)).toBe(true); + expect(kept.startsWith(' { + for (const threading of [ + '', + null, + { inReplyTo: 5 }, + { references: [''] }, + ]) { + await expect( + normalizeEmail({ ...base, threading } as unknown as Parameters[0]), + ).rejects.toMatchObject({ code: 'MIME_ERROR' }); + } + }); + + it('treats empty values as no threading', async () => { + const email = await normalizeEmail({ + ...base, + threading: { inReplyTo: '', references: ' ' }, + }); + expect(email.threading).toBeUndefined(); + }); + }); + it('folds long whitespace-separated headers (e.g. References) below 78 columns', async () => { // 30 message IDs without commas: the fold must split at whitespace, not // only at address-list separators. @@ -1960,8 +2086,13 @@ describe('buildMime header size projection', () => { `message is at least \\d+ bytes once encoded, which exceeds Cloudflare limit of ${MAX_MESSAGE_BYTES} bytes`, ); - async function expectProjected(opts: Parameters[0]) { - const email = await normalizeEmail(opts); + async function expectProjected( + opts: Parameters[0], + threading?: Threading, + ) { + const email = threading + ? await normalizeWithThreading(opts, threading) + : await normalizeEmail(opts); let error: unknown; try { buildMime(email); @@ -2044,20 +2175,9 @@ describe('buildMime header size projection', () => { // remote sender controls their size. it('counts the threading headers', async () => { // 900k twelve-character ids: 10.8 MB of non-WSP before a single space. - await expectProjected({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 'hi', - threading: { references: ' '.repeat(900_000).trimEnd() }, - }); - await expectProjected({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 'hi', - threading: { inReplyTo: `<${'x'.repeat(11 * 1024 * 1024)}@x>` }, - }); + const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 'hi' }; + await expectProjected(base, { references: ' '.repeat(900_000).trimEnd() }); + await expectProjected(base, { inReplyTo: `<${'x'.repeat(11 * 1024 * 1024)}@x>` }); }); // `normalizeAttachment` bounds the Content-ID, so the charge is pinned on a @@ -2123,26 +2243,22 @@ describe('buildMime header size projection', () => { { length: Math.floor((2.5 * 1024 * 1024) / (id.length + 1)) }, () => id, ); - const { raw, text } = await build({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 'hi', - threading: { references: chain.join(' ') }, - }); + const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 'hi' }; + const raw = buildMime(await normalizeWithThreading(base, { references: chain.join(' ') })).raw; + const text = decode(raw); expect(raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); expect(unfoldHeader(text, 'References')).toBe(chain.join(' ')); // A long chain is re-joined from its ids by single spaces, so 11 MiB of // spaces between two ids renders as one; a bound that charged them // would reject a message that fits. - const spaced = await build({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 'hi', - threading: { references: `${' '.repeat(11 * 1024 * 1024)}` }, - }); - expect(spaced.text).toContain('References: \r\n'); + const spaced = decode( + buildMime( + await normalizeWithThreading(base, { + references: `${' '.repeat(11 * 1024 * 1024)}`, + }), + ).raw, + ); + expect(spaced).toContain('References: \r\n'); }); it('never rejects a header-heavy message that fits, and leaves the final check as the authority', async () => { diff --git a/packages/void/test/unit/email-send.test.ts b/packages/void/test/unit/email-send.test.ts index 15769d1c6..d911d4504 100644 --- a/packages/void/test/unit/email-send.test.ts +++ b/packages/void/test/unit/email-send.test.ts @@ -111,6 +111,25 @@ describe('sendEmail (test harness)', () => { } }); + it('threads a later reply with the threading option', async () => { + const harness = createEmailTestHarness(); + try { + const result = await sendEmail({ + from: 'support@acme.dev', + to: 'user@example.com', + subject: 'Re: Help', + text: 'Fixed.', + threading: { inReplyTo: '' }, + }); + expect(result.ok).toBe(true); + const raw = new TextDecoder().decode(harness.messages[0].raw); + expect(raw).toContain('\r\nIn-Reply-To: \r\n'); + expect(raw).toContain('\r\nReferences: \r\n'); + } finally { + harness.dispose(); + } + }); + it('does not populate the dev buffer when only the harness sink is active', async () => { const harness = createEmailTestHarness(); try { @@ -278,6 +297,26 @@ describe('sendEmail (error mapping)', () => { } }); + it('returns MIME_ERROR for threading that is not a string', async () => { + const harness = createEmailTestHarness(); + try { + const result = await sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + threading: { inReplyTo: 5 }, + } as unknown as Parameters[0]); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('MIME_ERROR'); + } + expect(harness.messages).toHaveLength(0); + } finally { + harness.dispose(); + } + }); + it('returns MIME_ERROR when neither text nor html is set', async () => { const harness = createEmailTestHarness(); try {