net.smtp: correct From/To/Cc/Bcc headers and address normalization by nunsez · Pull Request #28262 · vlang/v · GitHub
Skip to content

net.smtp: correct From/To/Cc/Bcc headers and address normalization - #28262

Open
nunsez wants to merge 5 commits into
vlang:masterfrom
nunsez:fix/net-smtp-headers
Open

net.smtp: correct From/To/Cc/Bcc headers and address normalization#28262
nunsez wants to merge 5 commits into
vlang:masterfrom
nunsez:fix/net-smtp-headers

Conversation

@nunsez

@nunsez nunsez commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

In Mail.message_data the From, To, Cc and Bcc headers were built from fixed raw strings:

From: ${cfg.from}
To: <${cfg.to.split(';').join('>; <')}>
Cc: <${cfg.cc.split(';').join('>; <')}>
Bcc: <${cfg.bcc.split(';').join('>; <')}>

Because of this:

  • empty Cc/Bcc produced an empty header (Cc: <>\r\n), invalid for some providers and polluting the message;
  • only the ';' separator and bare addresses were supported, so the Ivan Petrov <ivan@example.com> form produced broken output (nested angle brackets) in To/Cc/Bcc;
  • the generated headers used ';' as the separator, but RFC 5322 requires ',' for an address-list;
  • display names were not quoted and special chars (" and \) not escaped;
  • non-ASCII names (e.g. Cyrillic) were emitted raw. RFC 5322 requires ASCII-only headers, so such names must be an RFC 2047 encoded-word, but From/To/Cc/Bcc did not encode them (only Subject already did).

What was done

  • From, To, Cc and Bcc are now normalized through format_addr (single address) / format_addr_list (list):
    • empty Cc/Bcc no longer emit a header line;
    • input is ';'-separated (matching the module convention), output always uses ', ' per RFC 5322 3.6.3;
    • bare address -> <addr@example.com>;
    • with display name -> "Name" <addr@example.com>, with " and \ escaped;
    • non-ASCII name -> RFC 2047 encoded-word (?=utf-8?B?...?=).
  • Extracted the shared mailbox parser split_mailbox, returning a (display_name, addr_spec) pair. The first <...> outside of quotes is treated as the separator, so a quoted local-part like "a<b"@example.com is not broken. envelope_addr is now based on it (behavior preserved).
  • Errors in send now include the reason: error('Sending mailfrom failed: ' + err) and likewise for mailto, data, body. Before, the reason was lost.

Note on formatting noise

I ran ./vnew fmt -w on the touched files after implementing the logic, so the diff also contains some alignment changes (struct field spacing etc.) that just come from the formatter, not from the fix itself.

@nunsez

nunsez commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@medvednikov

Copy link
Copy Markdown
Member

I’d request changes on PR #28262. The overall direction—separating SMTP envelope addresses from RFC 5322 header formatting—is good, but I found two correctness regressions in the new mailbox parser.

  • P2 — split_mailbox truncates valid quoted local-parts containing >. After finding the opening <, the implementation uses index_after('>') for the closing delimiter. That search is not quote-aware. For example, User <"a>b"@example.com> is valid, but this parser will treat the > inside "a>b" as the end of the angle address and extract "a as the addr-spec. RFC 5321 permits > inside a quoted local-part. ([RFC Editor]1)
    Fix: scan for the closing > while tracking quoted-string/escape state, and add a regression test such as User <"a>b"@example.com>.

  • P2 — quoted display names are not unescaped before being re-escaped. strip_quotes() removes only the outer quotes, while format_addr() later escapes every backslash again. A valid input such as "A\(B" <x@example.com> semantically represents display name A(B, but this code serializes the retained backslash and changes the display name. RFC 5322 permits a quoted-pair to escape any visible character or whitespace, not just " and \. The trimmed[len-2] != '\\' closing-quote check also fails for even-length runs of backslashes before the closing quote. ([RFC Editor]2)
    Fix: parse quoted strings left-to-right, decode quoted-pairs to their underlying characters, then escape them once when serializing. Tests should cover a generic quoted-pair and a display name ending in a literal backslash.

  • Adjacent Bcc issue worth addressing in this PR: Client.send() sends envelope recipients only from config.to, while message_data() emits populated Cc: and Bcc: headers. So Cc/Bcc recipients do not receive the message through send(), and a configured Bcc address can instead be exposed in the DATA sent to To recipients. This behavior predates some of the parser work, but the new Bcc-header test effectively blesses it, which conflicts with the PR’s stated goal of correcting Bcc handling. RFC 5322 describes Bcc handling specifically so blind-recipient identities aren't exposed to other recipients. ([RFC Editor]2)
    I’d make envelope recipients To + Cc + Bcc, while omitting populated Bcc addresses from the normal message copy (or using separate recipient-specific copies).

The tests otherwise cover the common cases well—plain addresses, display names, Unicode names, semicolon-separated recipients, and envelope normalization. The targeted vlib modules CI is green on head df331b59…; the PR as a whole is not fully green yet, with other workflows still failing.

I haven’t posted anything to GitHub. If you want, I can turn the first two findings into concise inline review comments and submit a Request changes review.

I can also keep an eye on the PR for fixes or new CI results.

@nunsez

nunsez commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the thorough review and for taking the time to point out the weak spots.
I'll continue working on this PR to get it in shape.

@JalonSolov

Copy link
Copy Markdown
Collaborator

I would also suggest merging main into this PR, to (hopefully) cut down the CI errors.

@nunsez
nunsez force-pushed the fix/net-smtp-headers branch from df331b5 to d0f4fcb Compare September 3, 2026 05:57
@nunsez

nunsez commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Fixed split_mailbox to properly handle quoted local-parts containing > and unescape quoted display names before re-escaping.
Envelope recipients now include To + Cc + Bcc, while Bcc addresses are kept out of DATA headers.
Also added CRLF injection hardening in address fields.

@medvednikov

Copy link
Copy Markdown
Member

Re-reviewed the updated head 78acc116.

Your three previous blockers are fixed correctly:

  • Quote-aware closing > parsing now handles addresses like User <"a>b"@example.com>, with regression tests.
  • Quoted display names are decoded before re-escaping; the tests now cover A\(B and a trailing literal backslash.
  • To + Cc + Bcc are all sent as SMTP envelope recipients, while Bcc is omitted from DATA, with an end-to-end SMTP test covering all three classes.

Also, correction to something I mentioned while reviewing: cfg.subject.is_ascii() is fine. In V, is_ascii() specifically checks printable through ~, so it preserves the old Subject behavior for CR/LF/control characters.

I found one remaining P2 in the CRLF hardening, though:

split_mailbox() only calls strip_crlf() after it successfully finds an angle-address. For the very common bare-address path, it returns trimmed directly:

open_at := index_unquoted(trimmed, `<`, 0) or {
    return none, trimmed
}

That means a bare value such as:

victim@example.com
X-Evil: yes

can still put a newline into format_addr(), and malformed bare/envelope input can still place CRLF into RCPT TO. The newly added tests only exercise CRLF inside <...> or the display-name path.

The simplest fix is to sanitize before branching:

trimmed := strip_crlf(s.trim_space())

and then remove/reduce the later duplicate stripping. I'd add one bare-address CRLF regression test for both format_addr() and envelope_addr().

There is also still a CI blocker: the current head has vlib modules CI green, but both Linux GCC and Clang fail the “All code is formatted” step. The PR branch is also behind current master, which is now e9bb2f0d….

So my current verdict is request changes for the bare-address CRLF path + get formatting CI green. After those, I’d be comfortable approving.

@nunsez
nunsez force-pushed the fix/net-smtp-headers branch from 78acc11 to 3630fb9 Compare September 6, 2026 02:59
@nunsez

nunsez commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

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.

3 participants