SMTP Submission
Submit mail from an existing SMTP client through Mailyard outbound pipeline
SMTP Submission is a migration aid for teams that already send mail through an SMTP client or library and are not yet
ready to rewrite that integration against the HTTP API. A project issues SMTP username/password credentials from
Mailyard, the existing SMTP client points at Mailyard instead of its current outbound provider, and every message it
sends is parsed and fed into the same outbound pipeline used by POST /api/v1/emails/send — the same
domain-verification, suppression-list, rate-limit, and delivery handling, just reached over SMTP instead of HTTP. Teams
can cut over their SMTP client on day one and migrate call sites to the HTTP API gradually, at their own pace.
This is a separate, purpose-built listener from Inbound Email : Inbound accepts anonymous mail addressed to a verified domain, while submission requires SMTP AUTH and exists to accept mail from your applications, independent of whether Inbound is enabled.
Enabling submission
Submission is off by default. Enable it with configuration:
| Setting | Env | Default | Description |
|---|---|---|---|
submission.enabled | MAILYARD_SUBMISSION_ENABLED | false | Master switch for the submission listener. |
submission.addr | MAILYARD_SUBMISSION_ADDR | :587 | Bind address. Separate from inbound.addr - the two listeners never share a port or process state. 587 is the submission port from RFC 6409, and it is privileged: see Privileged ports
. |
submission.hostname | MAILYARD_SUBMISSION_HOSTNAME | mailyard | Hostname announced in the SMTP EHLO greeting. When it is a dotted name it is also the host the console and the credential API tell clients to connect to - see Connecting your SMTP client
. |
submission.max_message_size | MAILYARD_SUBMISSION_MAX_MESSAGE_SIZE | 26214400 (25 MiB) | Maximum raw message size in bytes. Larger messages are rejected with 552. |
submission.rate_per_minute | MAILYARD_SUBMISSION_RATE_PER_MINUTE | 60 | Per-IP maximum SMTP sessions per minute. 0 disables the limit. |
submission.tls.enabled | MAILYARD_SUBMISSION_TLS_ENABLED | true | Whether the listener offers STARTTLS. See below. |
The credential management routes (/api/v1/smtp-credentials) are always registered, so an operator can issue
credentials before switching the listener on. The console shows a banner while the listener is off.
Privileged ports
submission.addr defaults to :587 and inbound.addr to :25, because those are the ports clients and remote mail
servers actually try. Both are below 1024, so a process that is not root cannot bind them without help:
- Docker sets
net.ipv4.ip_unprivileged_port_start=0inside containers, so the published image binds them as uid 1000 with no extra configuration. - Kubernetes does not. Grant
NET_BIND_SERVICEin the container’ssecurityContext.capabilities, or allow the same sysctl. - systemd on bare metal:
AmbientCapabilities=CAP_NET_BIND_SERVICE. - Anywhere else, set
MAILYARD_SUBMISSION_ADDR=:2525andMAILYARD_INBOUND_ADDR=:2526and forward the low ports to them.
Mailyard binds both listeners before it finishes starting, so a port it cannot take is a startup error with the address in the message - not a warning buried in the log while the rest of the process comes up healthy.
STARTTLS
submission.tls.enabled decides whether STARTTLS is offered. It is on by default, and the configuration file says
nothing about which certificate is served:
submission:
enabled: true
addr: ":587"
tls:
enabled: true
The certificate is whatever is assigned to the submission listener under Administration -> Certificates in the console. Nothing assigned falls through to the ACME certificate if ACME is configured for this hostname, and then to a self-signed pair that is generated on first use and shared by every node. So the listener always has something to present, and replacing it is a change in the console rather than an edit and a restart.
Cleartext AUTH
With enabled: false the listener advertises no STARTTLS and accepts
AUTH PLAIN over a cleartext connection, so credentials and message content travel unencrypted. That is acceptable on a
private network, over a VPN, or behind a TLS-terminating TCP proxy - never bind it directly to the public internet with
STARTTLS off.
How It Works
Submission is a front door onto the same pipeline the HTTP API uses, not a parallel one. Once the message is parsed it
is an ordinary send request, and every rule that applies to POST /api/v1/emails/send applies here — because it is
literally the same function.
- Your SMTP client opens a connection to
submission.addrand authenticates withAUTH PLAIN, using either an SMTP submission username and password or - with any username - an API key holdingemails:writeas the password. - Mailyard looks up the credential, confirms it is not revoked and that the connecting IP is allowed, and ties the rest
of the session to the credential’s project.
MAIL FROM,RCPT TO, andDATAare all rejected until AUTH succeeds. - On
DATA, Mailyard reads the raw message (bounded bysubmission.max_message_size), parses subject, HTML/text bodies, and attachments from the MIME body, and builds a send request using the SMTP envelope addresses (MAIL FROM/RCPT TO) rather than the parsed header addresses — the same behavior a normal MTA would have. When theFromheader names the envelope sender, its display name is kept, soFrom: Ann <ann@example.com>arrives with the name. An address given twice atRCPT TO, in any case, is one recipient and one delivery. - That request is handed to the same
email.Service.Sendused by the HTTP API, scoped to the credential’s project and owning user. It goes through the identical checks: sender domain verification, suppression-list filtering, rate limits, plan quota, and attachment-size validation, and a normalEmailrecord is created and queued for delivery. - The SMTP response code reflects the outcome of that call — see Send Outcomes below.
Which headers travel
The headers your client wrote are forwarded as the message’s custom headers , with three exceptions:
- Structural headers the builder writes itself are dropped, not refused:
From,To,Cc,Date,Message-ID,Content-Type,MIME-Versionand the rest of the reserved set ,Senderand the read-receipt headers among them. The recipients come from the envelope, the To and Cc headers are kept as the client wrote them, display names included, andReply-ToandList-Unsubscribeare lifted into their own fields. - Mailyard’s control headers - anything under
X-Mailyard-- are instructions to the listener (X-Mailyard-Sandbox,X-Mailyard-Disable-Tracking,X-Mailyard-Sandbox-Retention) and never leave with the message. - Names on the project’s drop list (
submission_drop_headersin project settings ) are removed before forwarding. Mail clients and libraries addX-Mailer,X-PriorityorUser-Agenton their own, and the drop list is where a project says it does not want them delivered. It applies to submission only: an API caller who named a header meant it.
What survives is judged exactly as an API caller’s headers are, so a message carrying more than twenty custom headers,
a value with a control character in it, or one over 4096 characters, is refused with a 550 rather than trimmed. An
RFC 2047 encoded word is decoded before that check, so a line break smuggled inside one is refused too. Header values
are forwarded as the
decoded text of their first occurrence, and written out again as RFC 2047 encoded words where they are not ASCII. The project’s default headers are laid underneath, as for every other send.
A sandbox credential never reaches step 3
Capture happens before the MIME body is parsed, so a message submitted on a sandbox credential is stored raw and the send pipeline is not entered at all. The client still gets a success code, because from its point of view the message was accepted.
That ordering is the point: the decision lives on the credential, so a test suite cannot mail a real customer by getting a flag wrong in the message.
Issuing a Credential
SMTP credentials are project-scoped and always require a project - there is no personal/unscoped credential. Create one
from the dashboard under Developers -> SMTP Submission (/app/smtp-submission), or directly via the API. Creating
and revoking a credential require apikeys:write, deleting one apikeys:delete - an SMTP credential is a machine
credential like an API key and shares its permission. What the credential does needs a permission of its own as well:
one that sends real mail needs emails:write, a sandbox one needs sandbox:write.
POST /api/v1/smtp-credentials
curl -X POST http://localhost:3000/api/v1/smtp-credentials/ \
-H "Authorization: Bearer myk_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Legacy app submission", "allowed_ips": ["203.0.113.0/24"] }'
Response (201):
{
"smtp_credential": {
"id": "b4f9bd4a-f184-4e93-a40c-14e7f7181242",
"project_id": "81af718e-f0ae-4780-a0d7-9f05b34dabcc",
"name": "Legacy app submission",
"username": "smtp_9f3c2a1b7e4d5601",
"allowed_ips": [
"203.0.113.0/24"
],
"revoked": false,
"created_at": "2026-07-20T00:00:00Z"
},
"password": "8b1c...e02f",
"submission": {
"enabled": true,
"host": "mail.example.com",
"port": "587",
"starttls": true
}
}
submission.host is where a client connects: submission.hostname when it is a full name, otherwise the address the
listener binds, and a wildcard bind is reported as the host of server.public_url.
Warning
Save the password immediately. Like an API key, the plaintext password is only returned once, at creation time. Mailyard stores only a hash of it.
Unlike API keys, an SMTP credential has no expiry and no permission list - it is either usable or revoked. It does
support an IP allowlist (allowed_ips), same as API keys
: an empty list permits any
IP.
Listing Credentials
GET /api/v1/smtp-credentials
Returns the credentials of the current project under smtp_credentials, plus a submission object describing the
listener (enabled, host, port, starttls) so a client can render connection settings. Passwords are never
returned - only credential metadata (id, name, username, allowed_ips, revoked, created_at, last_used_at).
GET /api/v1/smtp-credentials/{id} answers one credential the same way.
Changing a Credential
PATCH /api/v1/smtp-credentials/{id}
name, allowed_ips and smtp_group, each changed only when sent: allowed_ips: [] lifts the restriction and
smtp_group: "none" puts the credential back on the project’s default group. The password is minted once and the
sandbox flag is what the credential is, so neither is editable.
Revoking a Credential
Instantly disables a credential without deleting it, mirroring API key revocation :
POST /api/v1/smtp-credentials/{id}/revoke
A revoked credential fails AUTH immediately on its next connection attempt; any already-open session is not forcibly
disconnected but subsequent commands on a new session will be rejected.
Deleting a Credential
DELETE /api/v1/smtp-credentials/{id}
Permanently removes the credential record.
Connecting Your SMTP Client
Point your existing SMTP client at the submission host and port, with the generated username/password and no encryption:
| Setting | Value |
|---|---|
| Host | The host the console and the credential API report, see below |
| Port | submission.addr port (default 587) |
| Encryption | STARTTLS when submission.tls is configured, otherwise none |
| Auth mechanism | PLAIN |
| Username / Password | From the credential creation response, or any username plus an API key holding emails:write |
The reported host is submission.hostname when that is a dotted name. The default mailyard is an EHLO name that
resolves nowhere, so with it the host of server.public_url is reported instead, then the host of submission.addr
when it is not a wildcard, then localhost. Set submission.hostname when clients reach the listener under a
different name than the console.
swaks --server localhost --port 587 \
--auth PLAIN --auth-user smtp_9f3c2a1b7e4d5601 --auth-password 8b1c...e02f \
--from sender@yourdomain.com --to recipient@example.com \
--header "Subject: Hello from SMTP submission" \
--body "This message was submitted through Mailyard's outbound pipeline."
AUTH PLAIN is the only mechanism submission advertises; clients that default to LOGIN or CRAM-MD5 should be
configured to use PLAIN explicitly. The listener accepts up to 100 recipients per message.
Send Outcomes
Because submission feeds the same email.Service.Send used by POST /api/v1/emails/send, an accepted message ends up
in the identical set of email statuses
(pending, queued,
processing, sent, failed, suppressed) — submission does not introduce any new ones. The SMTP response code the
client sees just reflects whether Mailyard accepted the message for that pipeline, not its eventual delivery outcome:
| SMTP response | Meaning |
|---|---|
250 | Accepted and handed to the outbound pipeline as an Email record. This includes the case where every recipient was suppressed — Mailyard still accepts the message, logs it with suppressed status, and does not attempt delivery, exactly as the HTTP API does. |
550 5.7.1 | Sender domain is not verified for this project. |
452 4.7.0 / 421 4.7.0 | Rate limit or plan quota exceeded; retry later. |
552 5.3.4 | Message exceeds submission.max_message_size, whether or not the client declared a SIZE. |
554 5.6.0 | Message could not be parsed (malformed MIME), or carries a line longer than the listener accepts. |
451 4.3.2 | The platform is in maintenance mode . Keep the message and retry later. |
451 4.3.0 | Temporary failure (e.g. a transient error while queuing). |
502 5.7.0 | A mail command was sent before a successful AUTH. |
535 5.7.8 | AUTH failed - unknown or revoked credential, wrong password, an API key without emails:write, or the connecting IP is not on the credential’s allowed_ips. |
Check the eventual delivery outcome of a message the same way you would for an API-sent email — via
GET /api/v1/emails/{id}/status using the id Mailyard assigned, or by browsing the project’s email log in the
dashboard.
Next Steps
- Domain Verification — required before a sender address can send mail.
- API Keys — the HTTP-API equivalent of a submission credential, for when you’re ready to migrate fully.
- Rate Limiting — how send limits are enforced across both the HTTP API and submission.
- Email Status — track a submitted message after it’s accepted.