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.
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.
URLSession if your system needs to send the mail: receipts, password resets, alerts. This is the real answer for transactional email.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.
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
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 MessageUIimport UIKitclass 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 = falseview.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 = selfcomposer.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.
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
Send the message
import Foundationenum EmailError: Error {case missingAPIKeycase 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: Addresslet subject: Stringlet 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.
Option two solves delivery. Here is what tends to arrive afterward:
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:
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: Tolet template: Stringlet 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.
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.
| MessageUI | Email API | Notification API | |
|---|---|---|---|
| Who sends the message | the user, from their account | your system | your system |
| Works without a human | no | yes | yes |
| Runs on a server | no, iOS only | yes | yes |
| Needs an API key | no | yes | yes |
| Safe to call from an iOS app | yes | no, key leaks | no, key leaks |
| Works in the Simulator | no | yes | yes |
| Delivery confirmation | none | webhooks you handle | built-in log |
| Templates outside your code | no | no | yes |
| Preferences and unsubscribes | n/a | you build it | built in |
| Other channels | none | none | push, SMS, in-app, chat |
| Best for | contact and share buttons | transactional email | email 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.
FAQ
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.MFMailComposeViewController.canSendMail() returns false and your guard exits silently. That happens when no mail account is configured on the device, and it is always the case in the iOS Simulator, which has no Mail app. Test on a real device with a signed-in mail account, and show the user an error rather than returning quietly when the check fails.message object.addAttachmentData(_:mimeType:fileName:) on the composer before presenting it. When sending through an email API, attachments go in the request body as base64-encoded strings alongside a filename and content type. Providers cap total message size, commonly around 20 to 30 MB after encoding, so large files are better sent as links.URLSession to call an email provider's REST API, the same as you would from any other server language. This is the normal approach in a Vapor or Hummingbird app, where your API key lives safely in the environment. SMTP from Swift is possible through community packages, but an HTTPS API is simpler, gives you delivery webhooks, and avoids managing connections and TLS.Keep exploring
One API, every channel
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.