Sending Email

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with leaf 4.5.2, queues 1.18.0, sendgrid-kit 3.1.0, mailgun 6.1.0 and Mikroservices/Smtp 3.1.2, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode in a scratch package, and the sign-up, job, rendering and header-injection tests were run with VaporTesting and the asyncTest queue driver of XCTQueues; the output shown is captured from those runs. No message was sent to a real provider: the three provider adapters compile against the real packages, but their network behaviour is taken from the packages' sources and documentation, not from a live account, and every credential on this page is a placeholder. The page extends the Bookshelf service of the earlier pages of this section. Vapor 5 is at 5.0.0-beta.4 and is not released yet; none of the mail packages has announced Vapor 5 support, so check each package’s Package.swift before moving. Swift itself is covered by the Swift Reference, and async functions, Sendable and actors by Async/Await and Tasks and Actors, Isolation and Sendable.

Vapor ships no mail client. Email is sent through a package, and the choice is between an HTTP API of a provider (SendGrid, Mailgun, Postmark, Amazon SES and similar) and plain SMTP. This page builds the part that does not depend on that choice: a small EmailSender abstraction, templated HTML and plain-text bodies, a queued job that does the sending, and tests that need no network. The three adapters then become a few lines each. For a managed sending service see Amazon SES; the same ideas in another framework are on the Django email page.

Why email leaves the request

Sending a message is a network call to a third party, and it can be slow, rate limited or down. A route handler that sends the email inline holds the HTTP response hostage to that call and turns a provider outage into a failed sign-up. The usual shape is:

  • the handler validates the input, writes its own data and returns quickly (202 Accepted for a request that triggers mail);

  • a job on a queue renders the message and sends it, with retries when the provider fails (the Queues & Background Jobs page covers AsyncJob, req.queue.dispatch, retries and workers);

  • the code that composes the message does not know which provider delivers it.

sequenceDiagram participant C as Client participant R as POST /v1/signups participant Q as Queue participant J as WelcomeEmailJob participant L as Leaf renderer participant S as EmailSender participant P as Provider C->>R: email and name R->>Q: dispatch WelcomeEmailPayload R-->>C: 202 Accepted Q->>J: dequeue J->>L: render welcome and welcome-text L-->>J: HTML and text bodies J->>S: send OutboundEmail S->>P: SendGrid, Mailgun or SMTP

The packages

Package Version What it is

sendgrid-kit (SendGridKit)

3.1.0

A framework-independent client for the SendGrid v3 mail-send API on AsyncHTTPClient: SendGridClient, SendGridEmail, dynamic templates, attachments. The sendgrid package (6.0.0) wraps it as req.sendgrid.client but reads SENDGRID_API_KEY and stops with a fatal error when it is missing, so this page uses sendgrid-kit directly.

Mailgun (Mailgun)

6.1.0

A Vapor service: app.mailgun.configuration, app.mailgun.client() and req.mailgunClient(), with MailgunMessage and MailgunTemplateMessage and US and EU regions. It needs tools version 6.2 and macOS 15 on Apple platforms, which is why the scratch package raises its platform.

Mikroservices/Smtp (Smtp)

3.1.2

A small SwiftNIO SMTP client with .credentials, .ssl and .startTls, HTML and plain-text bodies and attachments. Its last release is from September 2024 and its tools version is 5.2, so it compiles as Swift 5 code; read it before depending on it.

Other providers (Postmark, Amazon SES, Resend, Brevo) follow the same pattern: either a community package or a call to the provider’s HTTP API through req.client, which is the HTTP client of Vapor. If the provider has a REST API, a thirty-line adapter over app.client is often the lowest-maintenance option.

A Package.swift with every dependency pinned looks like this:

// swift-tools-version:6.3
import PackageDescription

let package = Package(
    name: "App",
    platforms: [.macOS(.v15)],
    dependencies: [
        .package(url: "https://github.com/vapor/vapor.git", exact: "4.122.2"),
        .package(url: "https://github.com/vapor/leaf.git", exact: "4.5.2"),
        .package(url: "https://github.com/vapor/queues.git", exact: "1.18.0"),
        .package(url: "https://github.com/vapor-community/sendgrid-kit.git", exact: "3.1.0"),
        .package(url: "https://github.com/vapor-community/mailgun.git", exact: "6.1.0"),
        .package(url: "https://github.com/Mikroservices/Smtp.git", exact: "3.1.2"),
    ],
    targets: [
        .executableTarget(name: "App", dependencies: [
            .product(name: "Vapor", package: "vapor"),
            .product(name: "Leaf", package: "leaf"),
            .product(name: "Queues", package: "queues"),
            .product(name: "SendGridKit", package: "sendgrid-kit"),
            .product(name: "Mailgun", package: "mailgun"),
            .product(name: "Smtp", package: "Smtp"),
        ], swiftSettings: swiftSettings),
        .testTarget(name: "AppTests", dependencies: [
            .target(name: "App"),
            .product(name: "VaporTesting", package: "vapor"),
            .product(name: "XCTQueues", package: "queues"),
        ], swiftSettings: swiftSettings),
    ]
)
var swiftSettings: [SwiftSetting] { [
    .enableUpcomingFeature("ExistentialAny"),
    .enableUpcomingFeature("InternalImportsByDefault"),
    .enableUpcomingFeature("MemberImportVisibility"),
    .enableUpcomingFeature("InferIsolatedConformances"),
    .enableUpcomingFeature("ImmutableWeakCaptures"),
] }

The message model

The model is a plain Sendable value, independent of any provider. Two decisions matter. First, every address and the subject are checked for control characters when the value is built, which is what stops header injection: an attacker-controlled name or address such as ada@example.com followed by a carriage return, a line feed and Bcc: x@example.net would otherwise add a hidden recipient in SMTP, and some providers accept the same trick in form fields. Second, the type carries both a text and an optional HTML body, because a message with only an HTML part scores worse with spam filters and is unreadable in text clients.

import Vapor

/// One mailbox: an address and an optional display name.
struct Mailbox: Sendable, Hashable {
    let address: String
    let name: String?

    /// Throws a 400 if the address is malformed or could inject headers.
    init(_ address: String, name: String? = nil) throws {
        guard Mailbox.isSafe(address), !Validator<String>.email.validate(address).isFailure else {
            throw Abort(.badRequest, reason: "Invalid email address")
        }
        guard name.map(Mailbox.isSafe) ?? true else {
            throw Abort(.badRequest, reason: "Invalid display name")
        }
        self.address = address
        self.name = name
    }

    /// RFC 5322 style `"Name" <address>` (or the bare address).
    var formatted: String {
        guard let name else { return address }
        let escaped = name.replacingOccurrences(of: "\\", with: "\\\\")
            .replacingOccurrences(of: "\"", with: "\\\"")
        return "\"\(escaped)\" <\(address)>"
    }

    /// No CR, LF or other control characters, which would allow header injection.
    static func isSafe(_ value: String) -> Bool {
        !value.unicodeScalars.contains { $0.properties.generalCategory == .control }
    }
}

/// A provider-neutral transactional message.
struct OutboundEmail: Sendable, Equatable {
    let from: Mailbox
    let to: [Mailbox]
    let replyTo: Mailbox?
    let subject: String
    let text: String
    let html: String?
    let tags: [String]

    init(
        from: Mailbox, to: [Mailbox], replyTo: Mailbox? = nil,
        subject: String, text: String, html: String? = nil, tags: [String] = []
    ) throws {
        guard !to.isEmpty else { throw Abort(.badRequest, reason: "An email needs a recipient") }
        guard Mailbox.isSafe(subject), !subject.isEmpty else {
            throw Abort(.badRequest, reason: "Invalid subject")
        }
        self.from = from
        self.to = to
        self.replyTo = replyTo
        self.subject = subject
        self.text = text
        self.html = html
        self.tags = tags
    }
}

The initializers throw Abort(.badRequest), so a controller that builds a Mailbox from user input turns a bad address into a 400 with no extra code. Validator<String>.email is the validator behind the .email case of Validatable described on the Validation page; it is a syntax check, not proof that the mailbox exists. Proof comes from the user clicking a link you sent.

The EmailSender abstraction

The protocol has one method. It is Sendable because it is shared by every request and every job, and it is stored on the Application through a StorageKey, the pattern of the Services, Storage and Dependency Injection page. Two implementations need no network: one logs the message for local development, and one is an actor that records messages for tests.

import Vapor

protocol EmailSender: Sendable {
    func send(_ email: OutboundEmail) async throws
}

/// Development sender: writes the message to the log instead of the network.
struct LogEmailSender: EmailSender {
    let logger: Logger

    func send(_ email: OutboundEmail) async throws {
        logger.info("email (not sent)", metadata: [
            "from": "\(email.from.formatted)",
            "to": "\(email.to.map(\.address))",
            "subject": "\(email.subject)",
        ])
    }
}

/// Test sender: records every message so a test can assert on it.
actor RecordingEmailSender: EmailSender {
    private(set) var sent: [OutboundEmail] = []

    func send(_ email: OutboundEmail) async throws {
        sent.append(email)
    }
}

extension Application {
    private struct EmailSenderKey: StorageKey {
        typealias Value = any EmailSender
    }

    var emailSender: any EmailSender {
        get {
            guard let sender = storage[EmailSenderKey.self] else {
                fatalError("No email sender configured; set app.emailSender in configure(_:)")
            }
            return sender
        }
        set { storage[EmailSenderKey.self] = newValue }
    }
}

extension Request {
    var emailSender: any EmailSender { application.emailSender }
}

The fatalError in the getter is deliberate: forgetting to configure a sender is a programming error found at the first request in development, not something to recover from. Because the storage value is an existential (any EmailSender), the Swift 6 ExistentialAny upcoming feature requires the any keyword, as above.

Provider adapters

Each adapter lives in its own file and imports only its package. That is more than style: SendGridKit and Smtp both export types called EmailAddress, and Smtp also exports Email, so a file importing both would not compile without module qualification. The names Mailbox and OutboundEmail of the model above avoid the same clash in the application.

SendGrid

SendGridClient needs an AsyncHTTPClient HTTPClient and the API key. Reusing Vapor’s app.http.client.shared avoids creating a second connection pool. SendGridEmail is generic over the dynamic-template data; [String: String] works when the message carries no template data.

import AsyncHTTPClient
import SendGridKit
import Vapor

struct SendGridEmailSender: EmailSender {
    let client: SendGridClient

    init(apiKey: String, httpClient: HTTPClient) {
        client = SendGridClient(httpClient: httpClient, apiKey: apiKey)
    }

    func send(_ email: OutboundEmail) async throws {
        var content: [EmailContent] = [EmailContent(type: "text/plain", value: email.text)]
        if let html = email.html {
            content.append(EmailContent(type: "text/html", value: html))
        }
        let message = SendGridEmail<[String: String]>(
            personalizations: [Personalization(to: email.to.map { SendGridKit.EmailAddress(email: $0.address, name: $0.name) })],
            from: SendGridKit.EmailAddress(email: email.from.address, name: email.from.name),
            replyTo: email.replyTo.map { SendGridKit.EmailAddress(email: $0.address, name: $0.name) },
            subject: email.subject,
            content: content,
            categories: email.tags.isEmpty ? nil : email.tags
        )
        try await client.send(email: message)
    }
}

A rejected request throws a SendGridError whose errors array holds the messages returned by SendGrid; the job below logs it and lets the queue retry. SendGrid also has regional endpoints: pass forEU: true to SendGridClient for an EU regional subuser.

Mailgun

The Mailgun package keeps its configuration on the application. MailgunDomain takes the sending domain and a region (.us or .eu), and MailgunConfiguration the private API key (including its key- prefix where the account has one). The adapter calls app.mailgun.client(), which is the default domain, and checks the status of the ClientResponse it returns.

import Mailgun
import Vapor

struct MailgunEmailSender: EmailSender {
    let app: Application

    func send(_ email: OutboundEmail) async throws {
        let message = MailgunMessage(
            from: email.from.formatted,
            to: email.to.map(\.formatted),
            replyTo: email.replyTo?.formatted,
            subject: email.subject,
            text: email.text,
            html: email.html,
            tags: email.tags.isEmpty ? nil : email.tags
        )
        let response = try await app.mailgun.client().send(message)
        guard response.status == .ok else {
            throw Abort(.badGateway, reason: "Mailgun answered \(response.status.code)")
        }
    }
}

The package can also read its configuration from Swift Configuration providers through MailgunConfiguration(config:), and it supports MailgunTemplateMessage for templates stored at Mailgun. Whether to keep templates in the provider or in the application is a product decision: provider templates let non-developers edit copy, application templates (Leaf, below) are versioned with the code and tested with it.

SMTP

SMTP is the lowest common denominator: every provider and every corporate relay speaks it, and it needs a host, a port, a user name and a password. Port 587 with STARTTLS (.startTls) is the submission default; port 465 with implicit TLS is .ssl; .none sends the password in clear text and must not be used outside a local test server. The adapter wraps the package’s async send.

import Smtp
import Vapor

struct SmtpEmailSender: EmailSender {
    let app: Application

    func send(_ email: OutboundEmail) async throws {
        let message = try Smtp.Email(
            from: Smtp.EmailAddress(address: email.from.address, name: email.from.name),
            to: email.to.map { Smtp.EmailAddress(address: $0.address, name: $0.name) },
            subject: email.subject,
            body: email.html ?? email.text,
            plain: email.html == nil ? nil : email.text,
            isBodyHtml: email.html != nil,
            replyTo: email.replyTo.map { Smtp.EmailAddress(address: $0.address, name: $0.name) }
        )
        try await app.smtp.send(message)
    }
}

Application.Smtp.configuration is a plain struct stored in app.storage without a lock, so set it once in configure(_:) before the server starts and never mutate it afterwards. The Email initializer throws when there is no recipient, and normalises line endings to CRLF as SMTP requires. Because the package predates Swift concurrency checking, a library with an actively maintained Sendable API or a call to your provider’s HTTP API through req.client is the safer long-term choice; the EmailSender protocol makes that swap local to one file.

Choosing the sender at startup

configure(_:) reads the environment and installs one sender. The secrets come from environment variables supplied by the deployment (a secret store, Kubernetes Secret, Fly.io secrets; see Deployment Targets), never from a file in the repository, and the values below are placeholders. The default branch installs the logging sender so a developer who sets nothing sends nothing.

import Leaf
import Mailgun
import Queues
import Smtp
import Vapor

func configure(_ app: Application) async throws {
    app.views.use(.leaf)
    app.queues.add(WelcomeEmailJob())
    try configureEmail(app)
    try routes(app)
}

func configureEmail(_ app: Application) throws {
    switch Environment.get("EMAIL_PROVIDER") ?? "log" {
    case "sendgrid":
        guard let key = Environment.get("SENDGRID_API_KEY") else {
            throw Abort(.internalServerError, reason: "SENDGRID_API_KEY is not set")
        }
        app.emailSender = SendGridEmailSender(apiKey: key, httpClient: app.http.client.shared)
    case "mailgun":
        guard let key = Environment.get("MAILGUN_API_KEY"),
              let domain = Environment.get("MAILGUN_DOMAIN") else {
            throw Abort(.internalServerError, reason: "MAILGUN_API_KEY and MAILGUN_DOMAIN are required")
        }
        app.mailgun.configuration = .init(apiKey: key, defaultDomain: MailgunDomain(domain, .eu))
        app.emailSender = MailgunEmailSender(app: app)
    case "smtp":
        guard let host = Environment.get("SMTP_HOST"),
              let user = Environment.get("SMTP_USERNAME"),
              let password = Environment.get("SMTP_PASSWORD") else {
            throw Abort(.internalServerError, reason: "SMTP_HOST, SMTP_USERNAME and SMTP_PASSWORD are required")
        }
        app.smtp.configuration = .init(
            hostname: host,
            port: Environment.get("SMTP_PORT").flatMap(Int.init) ?? 587,
            signInMethod: .credentials(username: user, password: password),
            secure: .startTls
        )
        app.emailSender = SmtpEmailSender(app: app)
    default:
        app.emailSender = LogEmailSender(logger: app.logger)
    }
}
# placeholders only: the real values come from your secret store
export EMAIL_PROVIDER=smtp
export SMTP_HOST=<smtp-host>
export SMTP_PORT=587
export SMTP_USERNAME=<smtp-username>
export SMTP_PASSWORD=<smtp-password>
export PUBLIC_BASE_URL=https://bookshelf.example
swift run App serve

Failing at startup when a variable is missing is intentional: Environment.get returns nil for an unset variable, and an email sender that is half configured would fail on the first sign-up instead of on deployment. The environment and the .env files are covered on the Configuration and Environment page, and the rule that secrets are not logged or committed on Security Hardening.

Templating emails with Leaf

An email body is a template like a web page, with the same escaping rules and a few extra constraints: HTML email is table-and-inline-style territory, links must be absolute, and the plain-text part is rendered from its own template so each is readable on its own. Put the templates under Resources/Views/Emails/ and render both with the same context. Leaf escapes #(name) as HTML, which is what the HTML part needs and what keeps a name such as Ada <b>L</b> from injecting markup; the plain-text part uses #unsafeHTML(name) so it shows the characters as typed.

<!doctype html>
<html>
  <body>
    <h1>Welcome, #(name)!</h1>
    <p>Your Bookshelf account is ready. <a href="#(loginURL)">Sign in</a> to start your first shelf.</p>
  </body>
</html>
Welcome, #unsafeHTML(name)!

Your Bookshelf account is ready. Sign in to start your first shelf:
#(loginURL)

The renderer is app.leaf.renderer, which works outside a request (in a job there is no Request). It returns a future of a ByteBuffer, so the helper awaits it with .get() and converts it to a String:

import Leaf
import Vapor

struct WelcomeContext: Encodable, Sendable {
    let name: String
    let loginURL: String
}

extension Application {
    /// Renders `Resources/Views/Emails/<name>.leaf` and `<name>-text.leaf` with the same context.
    func renderEmail(
        _ name: String, context: some Encodable & Sendable
    ) async throws -> (html: String, text: String) {
        let html = try await leaf.renderer.render(path: "Emails/\(name)", context: context).get()
        let text = try await leaf.renderer.render(path: "Emails/\(name)-text", context: context).get()
        return (String(buffer: html), String(buffer: text))
    }
}

The templating syntax, #extend layouts for a shared header and footer, and custom tags are on the Leaf Templates page. In the template the loginURL is built from PUBLIC_BASE_URL, not from the request, because a job has no request and a Host header taken from an incoming request would let an attacker poison the links in the emails you send (the same class of problem as password-reset poisoning).

Sending from a queued job

The job decodes its payload, renders the two bodies, builds the message and calls the sender configured on the application. The payload carries only what is needed (an address and a name), not a rendered message or a secret, because the payload is stored in Redis or another driver in clear text.

import Queues
import Vapor

struct WelcomeEmailPayload: Codable, Sendable {
    let address: String
    let name: String
}

struct WelcomeEmailJob: AsyncJob {
    typealias Payload = WelcomeEmailPayload

    func dequeue(_ context: QueueContext, _ payload: WelcomeEmailPayload) async throws {
        let app = context.application
        let base = Environment.get("PUBLIC_BASE_URL") ?? "http://localhost:8080"
        let rendered = try await app.renderEmail(
            "welcome", context: WelcomeContext(name: payload.name, loginURL: "\(base)/login"))
        let email = try OutboundEmail(
            from: Mailbox("no-reply@mail.bookshelf.example", name: "Bookshelf"),
            to: [Mailbox(payload.address, name: payload.name)],
            subject: "Welcome to Bookshelf",
            text: rendered.text,
            html: rendered.html,
            tags: ["welcome"]
        )
        try await app.emailSender.send(email)
    }

    func error(_ context: QueueContext, _ error: any Error, _ payload: WelcomeEmailPayload) async throws {
        context.logger.error("welcome email failed", metadata: ["error": "\(error)"])
    }
}
import Queues
import Vapor

struct SignUp: Content, Validatable {
    let email: String
    let name: String

    static func validations(_ validations: inout Validations) {
        validations.add("email", as: String.self, is: .email)
        validations.add("name", as: String.self, is: !.empty && .count(...80))
    }
}

func routes(_ app: Application) throws {
    app.post("v1", "signups") { req async throws -> HTTPStatus in
        try SignUp.validate(content: req)
        let signUp = try req.content.decode(SignUp.self)
        _ = try Mailbox(signUp.email, name: signUp.name) // reject header injection early
        try await req.queue.dispatch(
            WelcomeEmailJob.self, WelcomeEmailPayload(address: signUp.email, name: signUp.name))
        return .accepted
    }
}

A few points about the job:

  • It is registered with app.queues.add(WelcomeEmailJob()) in configure(_:); a worker process (swift run App queues) executes it, and the Queues & Background Jobs page covers drivers, retries (maxRetryCount) and scheduled jobs. A mail job that failed is retried by the queue, so the send must be safe to repeat: a duplicate "welcome" is an annoyance, a duplicate "your card was charged" is not, and for the second kind store a "sent" marker in the database in the same transaction that creates the thing the email is about.

  • Environment.get is read inside the job so the value is the one of the worker process, not of the process that enqueued the job.

  • error(_:_:_:) runs after the last retry; use it to log, to raise an alert or to write the failure to a table, but do not put the address in the log of a service with privacy obligations.

A password-reset email

A reset message is the same job with a different template and a link that carries a one-time token. The rules matter more than the code: generate the token with a cryptographically secure random generator ([UInt8].random(count: 32) or SymmetricKey(size: .bits256) from swift-crypto), store only its hash and an expiry, put the raw token in the link, make it single use, and answer the "forgot password" form identically whether the address exists or not so the form cannot be used to list your customers. The hashing and token APIs are on the Passwords and Crypto page and the login flow on Session Authentication and Web Login. The link itself is built from the configured PUBLIC_BASE_URL, as above.

Deliverability: SPF, DKIM and DMARC

Mail from your domain is trusted only if the receiving server can verify that you authorised the sender. Three DNS records do that: SPF lists the servers allowed to send for the domain, DKIM publishes the public key that verifies a signature the provider adds to each message, and DMARC states what to do with a message that fails both and where to send reports. Your provider gives you the exact SPF include: and the DKIM key to publish when you add a sending domain; start DMARC at p=none with an address for the reports, read them for a few weeks, and only then tighten it to quarantine or reject. Send from a subdomain such as mail.bookshelf.example so a reputation problem with transactional mail does not touch the domain people write to.

mail.bookshelf.example.          TXT  "v=spf1 include:<provider-spf-domain> -all"
<selector>._domainkey.mail.bookshelf.example.  TXT  "v=DKIM1; k=rsa; p=<public-key-from-the-provider>"
_dmarc.mail.bookshelf.example.   TXT  "v=DMARC1; p=none; rua=mailto:dmarc-reports@bookshelf.example"

All three records use placeholders; the names are the ones DNS expects. The SPF, DKIM and DMARC RFCs define them.

Testing with a fake sender

The general VaporTesting setup is on the Testing page. The RecordingEmailSender actor is the test double. A test configures the application, replaces the sender, switches the queues driver to the asyncTest driver of XCTQueues (an in-memory driver that records dispatched jobs), posts to the endpoint, takes the dispatched payload out of the test storage, runs the job and asserts on what was recorded. The tests need no Redis, no network and no provider account.

import Queues
import Testing
import VaporTesting
import XCTQueues
@testable import App

@Suite struct EmailTests {
    private func withTestApp(_ body: (Application, RecordingEmailSender) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        let recorder = RecordingEmailSender()
        do {
            try await configure(app)
            app.emailSender = recorder
            app.queues.use(.asyncTest)
            try await body(app, recorder)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    @Test func signUpQueuesAndJobSendsRenderedEmail() async throws {
        try await withTestApp { app, recorder in
            try await app.testing().test(
                .POST, "v1/signups",
                beforeRequest: { req in
                    try req.content.encode(["email": "ada@example.com", "name": "Ada <b>L</b>"])
                },
                afterResponse: { res async in #expect(res.status == .accepted) })

            #expect(app.queues.asyncTest.contains(WelcomeEmailJob.self))
            let payload = try #require(app.queues.asyncTest.first(WelcomeEmailJob.self))
            try await WelcomeEmailJob().dequeue(app.queues.queue.context, payload)

            let sent = await recorder.sent
            let message = try #require(sent.first)
            #expect(message.to.map(\.address) == ["ada@example.com"])
            #expect(message.subject == "Welcome to Bookshelf")
            #expect(message.html?.contains("Ada &lt;b&gt;L&lt;/b&gt;") == true)
            #expect(message.text.contains("Ada <b>L</b>"))
        }
    }

    @Test func headerInjectionIsRejected() async throws {
        try await withTestApp { app, recorder in
            #expect(throws: (any Error).self) { try Mailbox("a@example.com\r\nBcc: x@example.com") }
            #expect(throws: (any Error).self) { try Mailbox("a@example.com", name: "Ada\r\nBcc: x") }
            try await app.testing().test(
                .POST, "v1/signups",
                beforeRequest: { req in
                    try req.content.encode(["email": "not-an-email", "name": "Ada"])
                },
                afterResponse: { res async in #expect(res.status == .badRequest) })
            #expect(await recorder.sent.isEmpty)
        }
    }

    @Test func formattedMailboxEscapesQuotes() throws {
        #expect(try Mailbox("a@example.com", name: "Ada \"Countess\" L").formatted
            == "\"Ada \\\"Countess\\\" L\" <a@example.com>")
    }
}

Running swift test gives:

◇ Test run started.
◇ Suite EmailTests started.
◇ Test headerInjectionIsRejected() started.
◇ Test signUpQueuesAndJobSendsRenderedEmail() started.
◇ Test formattedMailboxEscapesQuotes() started.
✔ Test formattedMailboxEscapesQuotes() passed after 0.001 seconds.
✔ Test headerInjectionIsRejected() passed after 0.013 seconds.
✔ Test signUpQueuesAndJobSendsRenderedEmail() passed after 0.017 seconds.
✔ Suite EmailTests passed after 0.017 seconds.
✔ Test run with 3 tests in 1 suite passed after 0.018 seconds.

Two details of that test file are worth copying. The job is invoked directly with app.queues.queue.context, which makes the test deterministic instead of waiting for a worker; and the headers test covers both the model (Mailbox rejects a carriage return and a line feed) and the endpoint (a malformed address returns 400 and nothing is recorded). Before going live, send a real message to a mailbox of your own with the logging sender replaced by the real one, and use the provider’s sandbox or "test" mode where it has one; the delivery, spam-folder placement and DKIM alignment can only be checked against the real thing.

Common mistakes

Symptom Cause Fix

Mailgun not configured or SENDGRID_API_KEY fatal error at the first send

the package reads its configuration lazily and stops with fatalError

configure it in configure(_:) and fail startup when the variable is missing, as above

Duplicate or missing EmailAddress or Email type errors

two mail packages imported in one file

one adapter per file, own type names for the application model

An extra recipient appears in sent mail

a user-controlled name, address or subject contained a CR or LF

validate every header-bound value, as Mailbox and OutboundEmail do

Plain-text part shows HTML entities such as ampersand-lt

the text template used #(name), which escapes for HTML

use #unsafeHTML(name) in the text template only

Links in emails point to localhost or to an attacker’s host

the base URL came from the request or from a development default

take it from PUBLIC_BASE_URL and fail startup in production if it is unset

The sign-up request times out when the provider is slow

the handler sends inline

enqueue a job and answer 202

Mail lands in spam

missing or misaligned SPF, DKIM or DMARC, HTML-only body, a new sending domain

publish the records, send text and HTML parts, warm the domain up

Version notes and outdated material

  • The books consulted predate the packages used here. The Kodeco book sends its account and reset messages through a Vapor 3 SendGrid provider registered in the Services of configure; that registration style and the future-returning send no longer exist. The current equivalents are SendGridKit or app.mailgun, an async send, and Leaf rendering in a queued job; the Vapor 3 configure(_:_:_:) registration is replaced by the storage key above.

  • The Yeung and Patel books send no email.

  • sendgrid-kit is built on AsyncHTTPClient with an async send and has no dependency on Vapor; the sendgrid package (6.x) is a thin Vapor layer over it. Mailgun 6.x needs tools version 6.2 and reads its configuration through Swift Configuration; pin the version you test.

  • The Vapor community Smtp package of this page is not under the vapor-community organisation and has not been released since 2024; treat it as a reference implementation of an SMTP client and pin it exactly.

  • Vapor 5 (5.0.0-beta.4, not released) will need a version of each package that depends on it; the abstraction on this page, which depends on none of them in its public shape, is the part that survives.