Skip to content

fix(workflows): encode non-ASCII sender names for ses - #123796

Draft
Silthus wants to merge 4 commits into
PostHog:masterfrom
Silthus:fix/email-from-name-encoding
Draft

Silthus wants to merge 4 commits into
PostHog:masterfrom
Silthus:fix/email-from-name-encoding

Conversation

@Silthus

@Silthus Silthus commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Problem

  • Workflows email steps that send from a name with non-ASCII characters, such as "Café Müller", put raw UTF-8 into the SES From header.
  • SES requires a non-ASCII friendly name as an RFC 2047 encoded word (SES docs), so such a send can fail or show a garbled name.
  • A sender name override also lost its quotes, angle brackets and backslashes, because the service stripped them.
  • Found in review of the email domain wizard (Silthus/posthog#521), which works around it with a fallback name.

Impact

Pays into Grow Workflows through the email activation rate (measured here). In scope: email, Messaging. Expected move: small, up. Run cost: n/a.

Warning

🟡 Merge risk: medium · one-way door: an email sent with a wrong From name stays sent (merge-risk rubric)

  • Every SES send builds its From and To headers through formatMailbox. A bug there fails or garbles real sends for any team.
  • Limits: ASCII names keep the exact header they get today, and only non-ASCII names and overrides with "<>\ take a changed branch. Unit and SES-command tests cover each branch. A revert restores the old headers.

Changes

  • A non-ASCII sender or recipient name now reaches SES as UTF-8 Q-encoded words, for example =?UTF-8?Q?Caf=C3=A9_M=C3=BCller?= <a@example.com>. Mail clients show "Café Müller".
  • The encoder is nodemailer's encodeWord, already a dependency. It splits a long name between characters, keeps each word within 75 characters, and escapes "<>,\ inside the word.
  • An ASCII name keeps today's RFC 5322 quoted string, byte for byte. Encoding every name would change every send's header, and spam filters score a needlessly encoded From (SpamAssassin FROM_EXCESS_BASE64).
  • A sender name override now keeps quotes, angle brackets and backslashes inside the escaped name. Integration names and recipient names already reach SES in that shape. Control characters are still dropped, so a templated name cannot inject a header.
  • The local maildev path now hands nodemailer a { name, address } object. Nodemailer encodes it itself, and it would show a pre-encoded word as literal text.

How did you test this code?

  • Lane gate on 0d55d35: tsc --noEmit clean, eslint, oxlint and the semgrep security rules clean on the changed files, email.service.test.ts green against local Postgres and maildev.
  • CI: this PR comes from a fork, so its workflow runs wait for a maintainer to approve them.
  • Not run: a send through real SES. This devbox has no SES sandbox credentials, and the local SES emulator does not enforce the 7-bit rule.

Test rationale:

  • formatMailbox table, one new row: a non-ASCII name becomes an encoded word. It catches the bug itself. The expected bytes come from the UTF-8 encoding of the name, not from the code.
  • formatMailbox round trip for a long CJK name with 𠮷 and a long emoji name: each word decodes to UTF-8 on its own, as a mail client does, and the joined result must equal the name. It catches a split inside a character: a byte-slicing encoder fails the CJK row, and so did nodemailer's base64 mode.
  • SES from overrides table: the old "sanitized" row now expects quotes, angle brackets and a backslash kept and escaped. A new row sends a non-ASCII name with quotes. They catch the SES wiring and any return of the stripping.
  • Maildev send test, now parameterized with a non-ASCII recipient name. It catches double encoding on the nodemailer path, which an encoded string in to would cause.

Test seams: formatMailbox, and EmailService.executeSendEmail observed at the SES SendEmailCommand input and at the maildev inbox.

Owed proofs:

  • Live run: partial evidence, below. HEAD From values for eight names were each parsed by Python's RFC 5322/2047 header parser. All are 7-bit ASCII and decode to the exact input name with no parser defects.
  • Prod shape: gap. Measuring the share of stored sender names with non-ASCII characters needs read-only production access, which arrives on 2026-11-02.
  • Post-deploy watch: owed after the merge.
From header at HEAD 0d55d35, decoded by Python's email parser
'株式会社山田商事部𠮷田' x4      7 encoded words, longest 75 characters, decoded intact
'Café Müller'                    =?UTF-8?Q?Caf=C3=A9_M=C3=BCller?= <sender@example.com>, decoded intact
'Café "Müller"'                  =?UTF-8?Q?Caf=C3=A9_=22M=C3=BCller=22?= <sender@example.com>, decoded intact
'Ünïcødé (Sales); Team: a.b@c [x] =?y?= _z_'   2 encoded words, longest 73, decoded intact
'e + combining acute' x6         2 encoded words, longest 75, decoded intact
'Dry\Run "Co"'                   "Dry\\Run \"Co\"" <sender@example.com>, same as base, decoded intact
'Acme "Support" <help>, Inc\'    quoted string, decoded intact
'Plain Sender'                   "Plain Sender" <sender@example.com>, same as base
base, 'Café Müller'              "Café Müller" <sender@example.com>, not 7-bit ASCII

Release status

  • No feature flag controls this change

Docs update

None. No doc under docs/ covers how the email service formats sender names.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)

  • A side fix split from the email domain wizard work at Michael's request. It first ran as a draft on the fork, Silthus/posthog#589, now closed.
  • Skills invoked: impact, tdd, /writing-tests, /writing-code-comments, qa-swarm, pr-description, /writing-pr-descriptions, merge-risk.
  • Reviews: a GPT-6.1 Sol design pass and review round on the fork draft, then a QA swarm here with seven lenses on GPT-6.1 Sol and Opus 5.5.
  • No duplicate: a search of open PRs on PostHog/posthog found no other fix for display-name encoding.
  • Public artifact: every name and address in tests and evidence is invented.
Question Answer Who Where
Merge the one-way door without a feature flag? Yes. ASCII headers stay byte for byte, and only non-ASCII names, which base already breaks, change 👍 Accepted default pending approval
Keep or strip "<>\ in a sender name override? Keep them escaped, as the side-fix brief asked; strip only control characters 🤖 Agent pending approval
Encode every name, as the design pass suggested? No. Encode only non-ASCII names, so ASCII headers stay byte for byte and avoid FROM_EXCESS_BASE64 🤖 Agent pending approval
Which encoder? Nodemailer's Q encoder. It replaced a hand-written base64 encoder after the panel showed it covers the same cases; nodemailer's base64 mode breaks 𠮷 🤖 Agent pending approval
Owe a cross-version proof? No. The header is built and sent within one send, and nothing in the new format is persisted or read by another version 🤖 Agent pending approval

Involvement: one question answered (merge path), one design pass, a review round and two final reads on the fork draft, two QA swarm rounds here, live-run evidence captured.

🤖 Generated with Claude Code

Silthus and others added 3 commits October 27, 2026 05:39
SES requires a non-ASCII display name in From and To as an RFC 2047
encoded word, so "Café Müller" went out as raw UTF-8. formatMailbox now
encodes such names and keeps the quoted string for ASCII names. Sender
name overrides keep quotes, angle brackets and backslashes inside the
escaped name instead of stripping them. The maildev path hands
nodemailer a structured address, because nodemailer would quote an
encoded word as literal text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nodemailer's encodeWord splits a long name on UTF-16 units and keeps
surrogate pairs whole only in the emoji range, so a name with a
character such as U+20BB7 decoded with replacement characters. The
email service now builds the encoded words itself and splits only
between code points, at most 45 bytes per word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the explanatory comments with named constants for the RFC 2047
word layout and the printable-ASCII check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Oct 27, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

nodemailer's Q encoder splits long names on UTF-8 character boundaries
and escapes phrase specials itself, so the hand-written base64 framing
and code-point splitter go away. The round-trip test now decodes each
encoded word on its own, as a mail client does, so a character split
across two words fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus marked this pull request as ready for review October 27, 2026 06:12
@parameterai

parameterai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Risk: No findings

This change encodes non-ASCII display names as RFC 2047 Q-words in SES From/To headers and stops stripping "<>\\ from sender-name overrides, keeping them escaped inside a quoted string; the maildev path now passes a {name, address} object to nodemailer. I verified the header-injection guards hold on every path: control characters (including CR/LF) are stripped before any use, ASCII names are escaped as quoted strings, and the Q-encoder's output charset cannot contain header-framing characters, so I found no security issues.

Sentinel reviewed 0d55d35 · Review settings

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team October 27, 2026 06:13
@Silthus
Silthus marked this pull request as draft October 28, 2026 04:28

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant