3 ways to send email with Swift

Send email from Swift with MessageUI, an email API, or a notification API. Working code for each, and why MessageUI cannot actually send one on its own.

Updated Sep 11, 2026

Last updated: September 2026. Package versions verified on 2026-09-11.

"Send an email with Swift" turns out to be two different tasks, and picking the wrong one costs you an afternoon.

  • Letting the user send an email from their own account. Apple's MessageUI framework opens a pre-filled composer, and the user taps send. Your app never sends anything.
  • Sending an email from your own system. No Apple framework does this. You call an email provider over HTTPS, either from server-side Swift or from a backend your app talks to.

Most people searching for this want the second one and find tutorials for the first. This guide covers both, plus the notification-API layer you end up wanting once email is one channel among several.

Which option should you pick?

  • MessageUI if you want a "contact us" or "share this" button and it is fine for the message to come from the user's own address. No account, no keys, no backend.
  • An email API over URLSession if your system needs to send the mail: receipts, password resets, alerts. This is the real answer for transactional email.
  • A notification API if email is one channel among several, or once templates, preferences, and delivery logs start mattering.

One rule that spans all three: do not put an email provider's API key in an iOS app. Anything in the bundle can be extracted, and a leaked key lets someone send mail as you. Options two and three belong in server-side Swift or behind your own backend.

1. Open the mail composer with MessageUI

MessageUI presents Apple's standard mail composer inside your app. You pre-fill recipients, subject, body, and attachments, and the user reviews and sends from their configured mail account.

Be clear about what this is. The message comes from the user, not your app. It does not send if they cancel. It cannot run on a server, in the background, or without a human tapping send. If you need a receipt to go out when an order ships, this is the wrong tool.

What you need first

  • An iOS app target. MessageUI ships on iOS and Mac Catalyst, and it is a proprietary Apple framework rather than an open-source library. There is no server-side equivalent.
  • A real device with a mail account signed in. canSendMail() always returns false in the Simulator, which has no Mail app, and that is the single most common reason this code appears to do nothing.

Send the message

import MessageUI
import UIKit
class ViewController: UIViewController {
private let mailButton = UIButton(type: .system)
override func viewDidLoad() {
super.viewDidLoad()
setUpButton()
}
private func setUpButton() {
mailButton.setTitle(NSLocalizedString("Email us", comment: ""), for: .normal)
mailButton.addTarget(self, action: #selector(showMailComposer), for: .touchUpInside)
mailButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mailButton)
NSLayoutConstraint.activate([
mailButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
mailButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
@objc private func showMailComposer() {
guard MFMailComposeViewController.canSendMail() else {
// No mail account configured, or you are running in the Simulator.
// Tell the user rather than failing silently.
let alert = UIAlertController(
title: NSLocalizedString("No mail account", comment: ""),
message: NSLocalizedString("Add a mail account in Settings to send email.", comment: ""),
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default))
present(alert, animated: true)
return
}
let composer = MFMailComposeViewController()
composer.mailComposeDelegate = self
composer.setToRecipients(["support@example.com"])
composer.setSubject("Help with my order")
composer.setMessageBody("<p>Hello,</p>", isHTML: true)
present(composer, animated: true)
}
}
extension ViewController: MFMailComposeViewControllerDelegate {
func mailComposeController(
_ controller: MFMailComposeViewController,
didFinishWith result: MFMailComposeResult,
error: Error?
) {
controller.dismiss(animated: true)
if let error {
// Surface it. Swallowing this is how bugs hide.
print("Mail composer failed: \(error.localizedDescription)")
return
}
switch result {
case .sent:
// The only success case. Log it, or update your UI here.
print("User sent the message")
case .saved:
print("User saved it as a draft")
case .cancelled:
print("User cancelled")
case .failed:
print("Compose failed")
@unknown default:
break
}
}
}

Two things worth noting. The guard on canSendMail() needs a user-visible branch, because returning silently is indistinguishable from a broken button. And NSLayoutConstraint.activate groups the constraints rather than setting isActive line by line, which is the current idiom.

For attachments, call composer.addAttachmentData(data, mimeType: "application/pdf", fileName: "receipt.pdf") before presenting.

Where it gets thin: the user is the sender, so you get no delivery confirmation, no templates, and no ability to send when nobody is holding the phone.

2. Send with an email API

This is what "send an email from my app" usually means: your system sends, from your domain, without a human involved. Every major provider exposes an HTTPS API, so URLSession is all you need. No SDK required.

The example below uses SendGrid, whose v3/mail/send endpoint has been stable for years. Resend, Postmark, Mailgun, and Amazon SES all work the same way with different URLs and body shapes.

What you need first

  • An account with an email provider, and an API key.
  • A verified sending domain or sender address. Skipping this is why a first send returns 202 and still lands in spam or gets rejected.
  • Somewhere to run this that is not an iOS app bundle: a Vapor or Hummingbird service, a Swift Lambda, or your own backend.

Send the message

import Foundation
enum EmailError: Error {
case missingAPIKey
case providerRejected(String)
}
struct SendGridEmail: Encodable {
struct Personalization: Encodable {
struct Address: Encodable { let email: String }
let to: [Address]
}
struct Address: Encodable { let email: String; let name: String? }
struct Content: Encodable { let type: String; let value: String }
let personalizations: [Personalization]
let from: Address
let subject: String
let content: [Content]
}
func sendEmail(to recipient: String) async throws {
guard let apiKey = ProcessInfo.processInfo.environment["SENDGRID_API_KEY"] else {
throw EmailError.missingAPIKey
}
let payload = SendGridEmail(
personalizations: [.init(to: [.init(email: recipient)])],
from: .init(email: "orders@example.com", name: "Acme"),
subject: "Your order shipped",
content: [.init(type: "text/plain", value: "Track it anytime in the app.")]
)
var request = URLRequest(url: URL(string: "https://api.sendgrid.com/v3/mail/send")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(payload)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
let body = String(decoding: data, as: UTF8.self)
throw EmailError.providerRejected(body)
}
}

The status-code check is the line people leave out. URLSession does not throw on a 401 or a 403, so without it a rejected send looks exactly like a successful one.

Using Encodable structs rather than assembling a dictionary means the compiler catches a misspelled field, which matters because a malformed body comes back as a generic 400.

A note on testing this: MailSlurp belongs here, not as a way to send. It provides disposable inboxes so an automated test can assert that your code sent the right subject to the right address. That is a real and useful job, and it is a commercial service rather than an open-source tool.

Where it gets thin: one provider, message copy in your source, and everything above delivery (templates, per-user preferences, retries, a second channel) is yours to build.

3. Send with a notification API

Option two solves delivery. Here is what tends to arrive afterward:

  • Copy lives in a compiled binary, so a wording change is a deploy.
  • Someone unsubscribes and you now own a suppression list.
  • The same event should reach one user by email and another by push.
  • Your provider has a bad hour and there is no second path.
  • Twenty events fire in a minute and one user gets twenty emails.
  • Someone asks how many notifications failed last week and you cannot answer.

A notification API such as Courier sits above your email provider and handles those. You keep SendGrid or SES delivering, and your Swift code stops knowing which one it is.

There is no Courier SDK for sending from Swift, so this is a plain HTTPS call. Worth stating clearly because there is a Courier iOS SDK, and it does something else: it is a client library for receiving in-app notifications, managing push tokens, and rendering preferences, and it requires iOS 15 or newer. Do not reach for it to send email.

What the layer gives you:

  • Automatic failover between providers when one starts erroring.
  • Journeys for multi-step sequences, with digest and throttling nodes.
  • One send endpoint for email, push, SMS, in-app, Slack, and Microsoft Teams.
  • Per-user, per-channel preferences enforced before a send.
  • Templates edited outside your codebase, with localization per user.
  • One log covering every channel.

Set it up

Create a free account: the Developer plan includes 10,000 messages a month, and past that it's $0.005 per message, the same rate on every channel. Then connect your email provider under Channels using the credentials it already gave you. Same SendGrid or SES account as section 2; you are not changing who delivers.

Send the message

import Foundation
// Reuses the EmailError enum declared above.
struct CourierSend: Encodable {
struct Message: Encodable {
struct To: Encodable { let user_id: String }
let to: To
let template: String
let data: [String: String]
}
let message: Message
}
func sendNotification(userID: String, trackingURL: String) async throws {
guard let apiKey = ProcessInfo.processInfo.environment["COURIER_API_KEY"] else {
throw EmailError.missingAPIKey
}
let payload = CourierSend(
message: .init(
to: .init(user_id: userID),
template: "ORDER_SHIPPED",
data: ["name": "Erika", "tracking_url": trackingURL]
)
)
var request = URLRequest(url: URL(string: "https://api.courier.com/send")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(payload)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw EmailError.providerRejected(String(decoding: data, as: UTF8.self))
}
let result = try JSONDecoder().decode([String: String].self, from: data)
print(result["requestId"] ?? "")
}

Nothing in that body names email. Routing and the user's stored preferences decide the channel, so moving a notification to push, or sending both, is configuration rather than a rebuild.

If you would rather send to an address than a stored user, replace to with { "email": "erika@example.com" } and pass inline content instead of a template.

Where it gets thin: one more service in the path, and no typed client to catch payload mistakes at compile time beyond the structs you write yourself.

Building this with an AI agent

If you are writing this in Cursor, Claude Code, or Codex, connect the agent to Courier directly instead of pasting snippets. It then works from the current SDK shapes rather than the 2022-era API that most training data still contains, which is the difference between Swift that compiles and Swift that does not.

The MCP server gives the agent typed tool calls. In Claude Code:

claude mcp add --transport http courier https://mcp.courier.com --header api_key:YOUR_API_KEY

Courier Skills adds the practices that are easy to get wrong, such as never batching a one-time passcode:

npx skills add trycourier/courier-skills

Both sit alongside the Courier CLI for scripting and CI. courier.com/agents collects the setup for Cursor, Claude Code, Codex, and VS Code in one place, and Build with AI has the machine-readable docs index an agent can fetch on its own.

Then ask in plain language: "email a receipt when a payment succeeds, and respect the user's preferences." The agent picks the right primitive, and knows which channels need a recorded opt-in.

This matters for the same reason this guide version-stamps every snippet. An agent working from stale training data will confidently write a send call that no longer exists.

Comparing the three options

MessageUIEmail APINotification API
Who sends the messagethe user, from their accountyour systemyour system
Works without a humannoyesyes
Runs on a serverno, iOS onlyyesyes
Needs an API keynoyesyes
Safe to call from an iOS appyesno, key leaksno, key leaks
Works in the Simulatornoyesyes
Delivery confirmationnonewebhooks you handlebuilt-in log
Templates outside your codenonoyes
Preferences and unsubscribesn/ayou build itbuilt in
Other channelsnonenonepush, SMS, in-app, chat
Best forcontact and share buttonstransactional emailemail plus other channels

If you want a contact button, use MessageUI and stop reading. If your system needs to send mail, MessageUI cannot do it at all, and the choice is between calling one provider yourself and putting a layer in front so that copy, channels, and providers stop being code changes.

Next steps


FAQ

Frequently asked questions

Yes, but not with MessageUI. MFMailComposeViewController only presents the system composer for the user to send from their own account. To send without user involvement you make an HTTPS request to an email provider from URLSession, which works in a server-side Swift app or from your own backend. Apple provides no framework that delivers mail directly.

One API, every channel

Ship notifications without the boilerplate

Courier gives you one API for email, SMS, push, and chat, with templates, routing, retries, and delivery logs built in.

Last updated Sep 11, 2026. Code samples are illustrative; provider APIs and pricing change over time, so check each provider’s docs before relying on them.