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
2 changes: 1 addition & 1 deletion docs/guide/email/receiving.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
54 changes: 42 additions & 12 deletions docs/guide/email/sending.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | 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<string, string>` | 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.

Expand All @@ -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
Expand Down
114 changes: 2 additions & 112 deletions packages/void/src/runtime/email/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string> = [];
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
74 changes: 44 additions & 30 deletions packages/void/src/runtime/email/mime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -524,7 +517,7 @@ export async function normalizeEmail(opts: {
bcc?: Address | Array<Address>;
headers?: Record<string, string>;
attachments?: Array<Attachment>;
threading?: { inReplyTo?: string; references?: string };
threading?: SendEmailOptions['threading'];
}): Promise<NormalizedEmail> {
if (!opts.text && !opts.html) {
throw new MimeError('MIME_ERROR', "email: at least one of 'text' or 'html' is required.");
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, unknown>;
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.
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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]);
}
Expand Down
Loading
Loading