SMTP Submission Gateway Client Guide

Overview

In addition to the HTTP POST /api/v1/messages/send endpoint, me.squibble.email now offers native SMTP submission via port 2525 (plaintext with PROXY v2 protocol termination by Traefik). This guide covers connection details, authentication, supported MIME types, and the complete SMTP status-code contract.

Important: The public SMTP endpoint at 9465 is not live yet. The Traefik entrypoint is provisioned in me.squibble.monorepo MR !103 and answers once that merges and Traefik is redeployed. This guide documents the interface and status codes; you cannot connect until the Traefik entrypoint is deployed.

Connection Details

Host and Port

  • Host: api.email.squibble.ch (or internal overlay: smtp_submission:2525)
  • Port: 9465 (external; not live yet)
  • Security: implicit TLS, like submissions port 465 — not STARTTLS. Wrap the socket in TLS from the first byte. Traefik terminates it and forwards plaintext over a private Docker Swarm overlay network. The gateway does not advertise or accept STARTTLS, so a client that tries to negotiate it will fail.

Authentication

SMTP AUTH (PLAIN or LOGIN mechanism) requires:

  • Username: The email address of the mailbox you are authorised to send from (e.g., alice@example.com).
  • Password: An active agent API token with the messages:send scope. Tokens are issued interactively via the CLI (python cli.py tokens:issue --name "my-app" --smtp-enabled) and are rotatable without downtime. Select messages:send when prompted.

Example (Python):

import smtplib

# SMTP_SSL, not SMTP + starttls(): the port is implicit TLS.
server = smtplib.SMTP_SSL("api.email.squibble.ch", 9465)
server.login("alice@example.com", "your-agent-token-here")

Idempotency and Duplicate Risk

SMTP has no built-in idempotency mechanism. If you send a message and receive a 250 response, but the network drops before you see the full response, a retry may result in a duplicate message in the outbound queue.

Mitigation (v1): Send messages sparingly and design your retry logic to avoid tight loops. Gaps of seconds or minutes are sufficient to let the submitted message clear the gateway.

Future work (pre-GA): A client-side deduplication header will be implemented (see PLAN.md).

Supported MIME Types

Plain and HTML

  • text/plain — simple ASCII or UTF-8 text.
  • text/html — HTML bodies with embedded CSS.
  • multipart/alternative — both text and HTML variants (e.g., plain text fallback with HTML rendering).

The gateway does not automatically inject tracking pixels or rewrite links when using SMTP submission (unlike the HTTP API). If you need tracking, use the HTTP endpoint.

Attachments

Regular MIME attachments are supported as separate parts:

Content-Type: application/octet-stream
Content-Disposition: attachment; filename="report.pdf"
Content-Transfer-Encoding: base64

[base64-encoded binary data]

Not supported:

  • Inline images (Content-Disposition: inline with Content-ID). Use attachments instead and reference them in HTML via <a href>cid:id</a> is not supported; provide full URLs or data URIs.
  • Arbitrary custom headers (X-* fields). Only standard RFC 5322 headers are accepted.

Message Size and Recipient Limits

  • Maximum raw SMTP DATA: 36 MiB, including MIME headers, boundaries, and transfer encoding. Exceeding this returns 552 5.3.4.
  • Maximum decoded text body: 1 MiB. The plain-text and HTML bodies are checked independently after transfer decoding; exceeding either returns 554 5.6.0.
  • Maximum decoded attachment: 10 MiB per file, 25 MiB total, and 20 files. Base64 wire expansion still counts toward the 36 MiB raw DATA ceiling.
  • Maximum recipients: 100 combined (To, Cc, Bcc).
  • Subject: optional; at most 998 Unicode characters after MIME header decoding. This implementation counts characters, not encoded octets. Normal RFC header folding is parsed before validation.

SMTP Status Codes

The gateway returns the following codes. A 4xx response is transient and may be retried with backoff; a 5xx response is permanent for the supplied command or credentials. A 250 response means the message is durably committed to the outbound queue.

Success

CodeMeaningNotes
250 2.0.0OK — message acceptedReturned only after durable commit to the outbound queue. If the connection drops after this, the message is safely queued.

Transient Failures (4xx)

These indicate a temporary issue; the client may retry after a delay.

CodeMeaningNotes
(connection closed, no reply)PROXY handshake failedThe gateway sits behind a PROXY-v2 terminator and closes any connection that does not complete the handshake — before the greeting, so there is no status code to read. If you see this connecting directly rather than through the published endpoint, that is why. Also covers the per-IP connection throttle.
421 4.7.0Too many authentication failuresThree failed AUTH attempts on one connection. Reconnect; check the credentials before retrying.
421 4.4.2Idle timeoutNo command for 300 seconds. Reconnect.
454 4.7.0Temporary authentication failureServer-side authentication infrastructure is unavailable or misconfigured. Retry with backoff; changing valid credentials will not resolve it.
452 4.5.3Too many recipientsMore than 100 RCPT TO commands in one transaction. Split the send. The over-cap recipient is not added to the envelope.
452 4.2.2Send quota exceededThe token has hit its configured send limit for the window. Try again later.
451 4.3.0Transient internal errorDatabase or downstream failure while enqueuing. Retry after a short delay — the message was not accepted.

Permanent Failures (5xx)

These indicate a permanent problem; do not retry the same message. Fix the error and resubmit.

CodeMeaningNotes
530 5.7.0MAIL before AUTHYou sent a MAIL FROM command before authenticating. Authenticate first with SMTP AUTH (PLAIN or LOGIN).
535 5.7.8Authentication failedLogin (mailbox address) or password (token) is invalid, revoked, expired, lacks messages:send, is not SMTP-enabled, or belongs to another mailbox. All credential and authorization failures return this code — no information disclosure.
550 5.7.1Sender mismatchThe MAIL FROM address does not match the authenticated mailbox. Sender identity is always bound to the authenticating mailbox. The null sender (MAIL FROM:<>) is rejected the same way — submission is not a bounce path.
550 5.7.1Recipient not allowedA recipient’s domain is outside the token’s allowlist.
550 5.7.1Suppressed recipientAt least one recipient is on the mailbox’s suppression list (hard bounce, unsubscribe, or complaint). Remove suppressed addresses and resubmit, or use cli suppressions:remove to override.
552 5.3.4Message too largeThe raw message exceeds 36 MiB (which allows for roughly 25 MiB of decoded attachments plus base64 expansion). Reduce size or split.
554 5.5.1No valid recipientsDATA was sent with no accepted RCPT TO.
554 5.6.0MIME errorParse failure, nesting deeper than 10, more than 50 parts, an inline Content-ID part, a custom X-* header, or a body/attachment over the size caps. The reason is included in the reply text.

Success

CodeMeaning
250 2.1.0 / 250 2.1.5MAIL FROM / RCPT TO accepted.
250 2.0.0Message accepted for delivery. Returned only after the message is durably committed to the outbound queue — a 250 means the send is ours to complete.

How Sender Identity Works

The gateway enforces a strict sender-binding invariant (ADR 0001 §4):

  • Your SMTP login mailbox (e.g., alice@example.com) is the only address that can appear in From, Sender, Reply-To, and the envelope Return-Path.
  • The envelope sender is checked. A MAIL FROM that is not your authenticated mailbox is rejected with 550 5.7.1.
  • Message headers are not. Any From, Sender, Reply-To or Return-Path you put in the message body is discarded, not rejected: the gateway extracts only subject, bodies and attachments, and the outbound message is rebuilt from your mailbox identity. Setting them is harmless and has no effect. (Honouring a client Reply-To is deferred — issue #29.)
  • Return-Path is automatically rewritten to a VERP (Variable Envelope Return Path) address for bounce tracking. DSNs are collected and processed by the gateway; bounces are available via the API.

Working Example (Python)

#!/usr/bin/env python3
"""Send an email via SMTP submission gateway."""

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

# Configuration
SMTP_HOST = "api.email.squibble.ch"
SMTP_PORT = 9465  # Not live yet — see the note at the top of this guide
MAILBOX = "alice@example.com"
TOKEN = "your-agent-token-here"  # Token with messages:send scope

def send_email():
    # Create message
    msg = MIMEMultipart("alternative")
    msg["Subject"] = "Hello from the SMTP Gateway"
    msg["From"] = "alice@example.com"
    msg["To"] = "bob@example.com"

    # Text and HTML variants
    text = "Hello Bob,\n\nThis is a test email."
    html = "<html><body><p>Hello Bob,</p><p>This is a test email.</p></body></html>"

    msg.attach(MIMEText(text, "plain"))
    msg.attach(MIMEText(html, "html"))

    # Connect and send
    try:
        # SMTP_SSL: the port is implicit TLS, terminated by Traefik.
        # Until the Traefik entrypoint is deployed, this connection fails.
        with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) as server:
            server.login(MAILBOX, TOKEN)
            server.sendmail(
                "alice@example.com",
                ["bob@example.com"],
                msg.as_string()
            )
            print("Message sent successfully!")
    except smtplib.SMTPAuthenticationError:
        print("Authentication failed. Check mailbox and token.")
    except smtplib.SMTPException as e:
        print(f"SMTP error: {e}")

if __name__ == "__main__":
    send_email()

Troubleshooting

”535 5.7.8 Authentication failed”

  • Check mailbox address: Ensure you are using the full email address (e.g., alice@example.com), not a local part.
  • Check token: Verify the token is active (cli tokens:list) and has the messages:send scope.
  • Token expiry: If using time-limited tokens, ensure they haven’t expired.
  • SMTP permission: Ensure the token is SMTP-enabled and belongs to the mailbox used as the username.

”454 4.7.0 Temporary authentication failure”

  • The credentials were not classified as invalid. Retry with backoff.
  • If the response persists, contact the operator: database access or server-side credential decryption is unavailable.

”550 5.1.1 Sender mismatch”

  • The MAIL FROM address in your SMTP envelope does not match the authenticated mailbox. The gateway rejects any attempt to send from a different address.

”550 5.1.3 Suppressed recipient”

  • A recipient address is on the suppression list (due to a hard bounce, unsubscribe, or complaint). Remove the address from the message and retry, or use cli suppressions:remove <mailbox> <email> to override.

”552 5.3.4 Message too large”

  • The complete raw SMTP DATA exceeds 36 MiB. Reduce attachment sizes or split the message.

”554 5.6.0 MIME error”

  • The message has parsing errors or unsupported MIME elements:
    • Inline images (Content-ID parts)
    • Custom headers (X-* fields)
    • Reply-To override attempt
    • Deeply nested multipart structures
    • A decoded text or HTML body over 1 MiB
    • An attachment over 10 MiB, over 25 MiB decoded attachments in total, or more than 20 attachments

See Also

  • ADR 0011: docs/adr/0011-smtp-submission-gateway.md — architecture, library choice, security boundaries.
  • HTTP Send API: docs/features/02_outbound_api_and_html_parsing.md — alternative HTTP endpoint with automatic tracking.
  • Bounce Processing: docs/features/05_imap_bounce_processing.md — automatic DSN collection and suppression.
  • Token Rotation: docs/runbooks/key-rotation.md — renewing credentials.

Sourced from docs/features/09_smtp_submission_guide in the repo. Edits go through the same review as code.