API Reference
API Reference
GitHub ↗
Security

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:

SettingEnvDefaultDescription
submission.enabledMAILYARD_SUBMISSION_ENABLEDfalseMaster switch for the submission listener.
submission.addrMAILYARD_SUBMISSION_ADDR:587Bind 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.hostnameMAILYARD_SUBMISSION_HOSTNAMEmailyardHostname 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_sizeMAILYARD_SUBMISSION_MAX_MESSAGE_SIZE26214400 (25 MiB)Maximum raw message size in bytes. Larger messages are rejected with 552.
submission.rate_per_minuteMAILYARD_SUBMISSION_RATE_PER_MINUTE60Per-IP maximum SMTP sessions per minute. 0 disables the limit.
submission.tls.enabledMAILYARD_SUBMISSION_TLS_ENABLEDtrueWhether 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=0 inside containers, so the published image binds them as uid 1000 with no extra configuration.
  • Kubernetes does not. Grant NET_BIND_SERVICE in the container’s securityContext.capabilities, or allow the same sysctl.
  • systemd on bare metal: AmbientCapabilities=CAP_NET_BIND_SERVICE.
  • Anywhere else, set MAILYARD_SUBMISSION_ADDR=:2525 and MAILYARD_INBOUND_ADDR=:2526 and 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.

  1. Your SMTP client opens a connection to submission.addr and authenticates with AUTH PLAIN, using either an SMTP submission username and password or - with any username - an API key holding emails:write as the password.
  2. 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, and DATA are all rejected until AUTH succeeds.
  3. On DATA, Mailyard reads the raw message (bounded by submission.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 the From header names the envelope sender, its display name is kept, so From: Ann <ann@example.com> arrives with the name. An address given twice at RCPT TO, in any case, is one recipient and one delivery.
  4. That request is handed to the same email.Service.Send used 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 normal Email record is created and queued for delivery.
  5. 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-Version and the rest of the reserved set , Sender and 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, and Reply-To and List-Unsubscribe are 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_headers in project settings ) are removed before forwarding. Mail clients and libraries add X-Mailer, X-Priority or User-Agent on 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:

SettingValue
HostThe host the console and the credential API report, see below
Portsubmission.addr port (default 587)
EncryptionSTARTTLS when submission.tls is configured, otherwise none
Auth mechanismPLAIN
Username / PasswordFrom 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 responseMeaning
250Accepted 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.1Sender domain is not verified for this project.
452 4.7.0 / 421 4.7.0Rate limit or plan quota exceeded; retry later.
552 5.3.4Message exceeds submission.max_message_size, whether or not the client declared a SIZE.
554 5.6.0Message could not be parsed (malformed MIME), or carries a line longer than the listener accepts.
451 4.3.2The platform is in maintenance mode . Keep the message and retry later.
451 4.3.0Temporary failure (e.g. a transient error while queuing).
502 5.7.0A mail command was sent before a successful AUTH.
535 5.7.8AUTH 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.