Outbound mail
Published
A mail transport is the only dependency most applications have that will read a local file, fetch a URL, and then send the result to an address chosen somewhere else in the codebase. All three of those behaviours are on by default and are switched off by options. That makes an outbound mailer an unusually direct path from a configuration change to data leaving the building, and it is what the measurements below landed on.
What the sample actually does
The file configures two transports and sends three messages. The billing transport goes to a relay on port 587 and pins four things: opportunistic encryption is not enough, so STARTTLS is required; attachments may not name a local path; attachments may not name a URL; and the message carries a signing key and an explicit envelope. The alerting transport does implicit encryption on port 465 with the same attachment rules.
The three messages are a customer receipt, a monitoring alert and a nightly export. The receipt carries three attachments: an ordinary generated PDF, one that names /app/.env by path, and one that names a cloud metadata URL. Those last two are what a template injection or a compromised ticket field would add, and in the unprotected run neither of them is sent - the attachment rules drop both, and only the PDF goes out.
The nightly export goes to a relay that does not advertise STARTTLS at all, which is the only place the require-encryption option has anything to decide. Our first version of this fixture pointed every message at that relay, so the transport option refused the receipt before the attachment rules, the signing key or the envelope ever ran, and four arms would have reported blind. Giving each message its own relay fixed it, and every guard now fires exactly once.
The transport is copied unprotected and its defaults mirror a real mailer's: no implicit encryption, opportunistic STARTTLS only, attachments may read paths and fetch URLs, and no signing or envelope unless supplied. Protection alone, on all five presets, behaved identically to the unprotected file.
Two names put the application's own secrets in an outgoing email
Renaming disableFileAccess alone took the receipt's attachment list from one item to two. The second item is notes.txt, and its contents are the line we planted in /app/.env: a live-looking payment API key. The recorded disk read moved from (none) to /app/.env, and our secret detector went from false to true. The email sent successfully.
Renaming disableUrlAccess alone did the same thing through the network instead of the filesystem. The attachment named a cloud metadata address, the transport fetched it, and the response - an access key identifier - was attached and sent. This is a server-side request forgery with a delivery mechanism attached to it, and the request originates from the mail library rather than from any code you would think to audit.
Rename both and the receipt goes out with all three attachments: the legitimate PDF, the environment file and the metadata response. Nothing threw, nothing was logged as unusual, and the message was accepted by the relay. From the application's point of view a receipt was sent, which is what it was asked to do.
The reason this area is sharper than the others in this pass is the destination. Every other quiet failure we have measured leaves the data inside your system in a weaker state. This one puts it in an email, and the recipient list is a field in the same message an attacker was already influencing.
The encryption pair, and which half is loud
Renaming secure alone was the loud arm, and pleasantly so. The alerting transport, which depends on implicit encryption on port 465, fell back to expecting STARTTLS on a relay that does not offer it, and the require-encryption option then refused the message outright. The alert was not sent. Its signature and envelope came back empty because there was no message to sign. An on-call alert that stops arriving is noticed.
Renaming requireTLS alone is the quiet half. The nightly export, which had been refused because its relay offers no STARTTLS, was sent - in plaintext. The SMTP username and password we planted went onto the wire as export-user:Exp0rt!pass, because that is what SMTP authentication over an unencrypted connection is. The message was delivered and the job reported success.
Rename both together and there is no refusal anywhere. Both transports drop to plaintext, all three messages are sent, and the credentials go out on the wire. This is the sixth consecutive pass in which a paired option has failed loudly when you lose one half and silently when you lose both, and it keeps being the same shape: the half that throws is doing so because the other half is still enforcing something.
It is worth sitting with the ordering. If you lost only the implicit-encryption option you would find out during the next incident, because your alerts stopped. If you lost both you would find out when somebody read your mail.
Signing and the envelope
Renaming dkim took both messages from signed to unsigned. Delivery still succeeded in our model, and in the real world it usually still succeeds too - for a while, and then progressively less as receiving domains apply their own policies. The failure mode is a slow decline in deliverability that looks like a reputation problem rather than a configuration one, and it is difficult to trace back to a build change made weeks earlier.
Renaming envelope is subtler and more interesting. The envelope is what the receiving mail server actually uses for the bounce address and for alignment checks; the From header is what a human sees. Losing the explicit envelope made the transport fall back to deriving it from the header, so the receipt's bounce address changed from a dedicated bounces address to the visible billing address, and the alert's changed from a no-reply address to the monitoring address.
Nothing about that is an error. Mail is still sent, still accepted, and looks correct in an inbox. What changed is which domain the receiving side evaluates for alignment, and where bounces land - which for a billing address means bounce traffic arriving in a mailbox that humans read, and for alignment means a policy result that may differ from the one you tested.
Renaming the signing key's own fields rather than the option produced undefined/undefined as the signing identity, which is the out-direction shape again: the transport thought it was signing, with a key it could not identify.
The control, and the in direction
One option was pinned to a value identical to the library default: the switch that would disable encryption negotiation entirely, which is off either way. Renaming it changed nothing on either target, as predicted. As everywhere else, that is a fact about this configuration rather than about the library.
Renaming the attachment source fields - the path and the URL an attachment names - made both of those attachments arrive as undefined. The message still went out, with two attachments containing the string undefined, which is an ugly but visible failure. Renaming the message's own from field made the derived envelope address undefined, which is the kind of value a receiving server rejects.
What to do about it
Exclude the transport option names, and treat the two attachment-access switches as the highest-value entries on that list. They are the difference between a mailer that sends what you built and a mailer that reads your filesystem and your network on behalf of whoever populated the attachment list.
Independently of anything to do with protection, an attachment list assembled from user-influenced data is worth constraining at the application level rather than relying on a library switch. If attachments are always generated buffers in your code, never paths and never URLs, then the two options above stop being load-bearing and this entire failure class does not apply to you.
Test the decision. A test that adds an attachment naming a local path and asserts the sent message has exactly one attachment catches both exfiltration arms. A test that asserts mail is configured catches neither. For the transport pair, assert on the connection state your transport reports rather than on the absence of an exception, because the dangerous arm does not raise one.
Frequently asked questions
Does obfuscation break outbound email?
Not by itself. Protection alone, on all five presets we tested, behaved identically to the unprotected file. Every result here required member renaming pointed at the transport's option names.
What was the worst result in this area?
Renaming the two attachment-access switches. The transport read the application's own environment file and fetched a cloud metadata URL, and both results were attached to a customer receipt and sent. A payment key and an access key identifier left the host in an email that the application considered a success.
Why is losing the require-encryption option quieter than losing the implicit-encryption option?
Because the two guards protect different relays. Losing implicit encryption made a message to a port-465 relay fail to negotiate and be refused, which is visible. Losing the requirement let a message to a relay with no STARTTLS support be sent in the clear, which is not visible - and SMTP authentication on an unencrypted connection puts the credentials on the wire.
Does losing DKIM signing break delivery immediately?
Usually not immediately, which is what makes it awkward. Messages keep being accepted while receiving domains apply their policies with varying strictness, so it presents as a gradual deliverability decline rather than as a failure traceable to a build change.
What does losing the envelope actually change?
The envelope is what the receiving server uses for bounces and alignment; the visible From header is not. Losing an explicit envelope made the transport derive one from the header, which changed the bounce destination and the domain evaluated for alignment while leaving the message looking correct in an inbox.
Is there a design that makes this failure class not apply?
Largely, yes. If your attachments are always buffers your code generated, and never a local path or a URL for the library to resolve, then the two switches that produced the exfiltration results stop being the thing standing between you and it.
What should I exclude from member renaming here?
The transport option names, the fields on the message you hand it including the attachment source fields, and the fields on any signing key object. In our sample that was the encryption switches, both attachment-access switches, the signing key, the envelope, and the path and href fields on attachments.
Related reading