From 996fa7eea6594af59e35eeddd1394f3678509358 Mon Sep 17 00:00:00 2001 From: Joe Polastre Date: Tue, 14 Jul 2026 00:25:01 -0700 Subject: [PATCH] fix: Generate Message-ID client-side using the sender's domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulwark currently sends Email/set create without a messageId property, leaving Message-ID generation to the JMAP server. Servers typically fall back to their OS hostname for this (Stalwart, via mail-builder's `gethostname()`), which produces IDs like: ``` <175234...abc@ip-10-0-12-97.ec2.internal> ``` This is bad for every deployment, in three escalating ways: 1. Information disclosure: the Message-ID travels in every outgoing message and permanently into archives, quoting, and In-Reply-To / References of replies. An internal hostname (container name, private DNS, k8s pod name) is infrastructure detail no recipient should see. 2. Deliverability: spam filters score Message-IDs whose domain part is not a plausible FQDN or is unrelated to the sender (SpamAssassin MSGID_FROM_MTA_HEADER and friends). Internal names like *.ec2.internal or bare container ids read as botnet-ish. 3. Correctness of intent: RFC 5322 §3.6.4 recommends the originator generate the Message-ID, using a domain it controls, so the id is meaningful and plausibly unique under that domain's authority. The sender's own domain is exactly that; the mail server's transient runtime hostname is exactly not. Generate the id in `sendEmail()` as `.@`, taken from the From address (falling back to the login username). The timestamp prefix keeps ids roughly sortable and adds entropy across UUID reuse concerns; crypto.randomUUID() is available in every runtime Bulwark supports (browsers and Node 19+). Per RFC 8621 §4.1.2.3 the JMAP messageId property carries bare msg-ids (no angle brackets), so none are added. Clients that never set messageId also can't thread their own sent mail reliably until the server echoes the message back; setting it at create time makes the id known and stable from the start. No behavior change for servers that honored client-provided ids all along; servers that previously synthesized an id now simply don't need to. --- lib/jmap/client.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 2b6d2f50..89ad0aff 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -432,6 +432,17 @@ function stripMessageIdBrackets(id: string): string { return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim(); } +// Generate a Message-ID for outgoing mail (bare msg-id, no angle brackets, per +// RFC 8621 §4.1.2.3). Without one the server synthesizes it from its OS +// hostname, which leaks internal names (e.g. @ip-10-0-12-97.ec2.internal) into +// headers — an anti-spam signal and an information disclosure. Use the sender's +// domain instead, matching what receivers expect a Message-ID to look like. +function generateMessageId(fromEmail: string): string { + const at = fromEmail.lastIndexOf('@'); + const domain = at > 0 ? fromEmail.slice(at + 1) : 'localhost'; + return `${Date.now().toString(36)}.${crypto.randomUUID()}@${domain}`; +} + /** * Build a CalendarEvent/query filter restricting results to the given * calendars. Stalwart implements the singular `inCalendar` condition (one @@ -2467,6 +2478,7 @@ export class JMAPClient implements IJMAPClient { cc: cc?.length ? cc.map(parseRecipientString) : undefined, bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined, subject, + messageId: [generateMessageId(fromEmail || this.username)], inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined, references: normalizedReferences?.length ? normalizedReferences : undefined, keywords: { "$seen": true, "$draft": true },