Here is the entire mail layer of an app I shipped — one file, under forty lines, no third-party libraries:
import SwiftUI
import MessageUI
/// A SwiftUI wrapper around Apple's mail compose sheet.
/// Present it ONLY after MFMailComposeViewController.canSendMail() is true.
struct MailComposer: UIViewControllerRepresentable {
let recipient: String
let subject: String
let body: String
var onFinish: (MFMailComposeResult) -> Void = { _ in }
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> MFMailComposeViewController {
let vc = MFMailComposeViewController()
vc.mailComposeDelegate = context.coordinator
vc.setToRecipients([recipient])
vc.setSubject(subject)
vc.setMessageBody(body, isHTML: false)
return vc
}
func updateUIViewController(_ vc: MFMailComposeViewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
private let parent: MailComposer
init(_ parent: MailComposer) { self.parent = parent }
func mailComposeController(_ controller: MFMailComposeViewController,
didFinishWith result: MFMailComposeResult,
error: Error?) {
parent.onFinish(result) // tell the app what happened
parent.dismiss() // then close the sheet yourself
}
}
}
That is the whole thing, and it looks boring, which is the point. MessageUI has shipped with iOS since version 3. The surface has barely moved in fifteen years. And yet I burned the better part of two evenings on it the year I shipped this app, because the interesting part of MFMailComposeViewController is not the code you write. It is the four or five states that code has to survive after you present it.
In my app a finished note has two sinks. It becomes an email, and, when I have the setting on, it becomes a line appended to a markdown file in my Obsidian vault. This post is only about the first sink. The first sink is where MessageUI lives, and MessageUI is where every surprise was waiting.
What the wrapper is actually doing
Three things, and only three.
makeUIViewController builds the compose controller, stamps it with the recipient, subject, and body, and points its mailComposeDelegate at a coordinator. updateUIViewController does nothing, on purpose: once the sheet is on screen I do not want SwiftUI re-pushing values into it while the user is typing. makeCoordinator hands back the object that owns the one method that matters, mailComposeController(_:didFinishWith:error:).
The @Environment(\.dismiss) line is the quiet load-bearing piece. I will come back to why in a minute, because forgetting it is edge case number two and it is the one that makes your app look broken in a demo.
Everything above is textbook. The trouble starts one layer out, at the question of whether you are even allowed to show this sheet.
Why does canSendMail() return false on a phone with Gmail?
Because canSendMail() does not answer the question you think it answers.
You read it as "can this person send email." What it actually reports is closer to "is the system Mail composer able to hand a message to a configured account right now." Those are not the same sentence. If someone deleted Apple Mail, or never added an account to it, and lives entirely inside the Gmail or Outlook app, canSendMail() returns false. The person can obviously email. Your app just decided they cannot.
I gated my only send button on this call. The first bug report was from a friend who had never once opened Apple Mail on his phone. To him the app was inert: he tapped the button and nothing happened, because my else branch did nothing worth doing.
Here is the matrix I keep taped to the inside of my skull now:
| Device state | canSendMail() |
Can the person actually email? |
|---|---|---|
| Apple Mail present, account configured | true |
Yes |
| Apple Mail removed, Gmail app installed | false |
Yes, just not through MessageUI |
| No mail app, no account anywhere | false |
No |
| Simulator with no account | false |
No (test on a device) |
The fix is not to fight canSendMail(). It is honest and you should trust it. The fix is to have a real answer for the two middle rows: a mailto: fallback. Since iOS 14 the user can set a third-party app as the default mail handler, so a mailto: link opens their Gmail or Outlook, not a dead Apple Mail. That single fallback covers the person my if branch was quietly abandoning.
Presenting it without stranding anyone
Here is the call site that ties the guard, the sheet, and the fallback together:
struct ComposeButton: View {
let noteText: String
@State private var showingMail = false
var body: some View {
Button("Send note") {
if MFMailComposeViewController.canSendMail() {
showingMail = true // full composer path
} else {
openMailtoFallback() // default-mail-app path
}
}
.sheet(isPresented: $showingMail) {
MailComposer(recipient: "me@example.com",
subject: "Note",
body: noteText) { result in
if result == .sent { markCaptured() } // only .sent
}
}
}
private func openMailtoFallback() {
let encoded = noteText.addingPercentEncoding(
withAllowedCharacters: .urlQueryAllowed) ?? ""
if let url = URL(string: "mailto:me@example.com?subject=Note&body=\(encoded)") {
UIApplication.shared.open(url)
}
}
}
The whole design decision lives in that if. Note that I check canSendMail() inside the button action, not in onAppear and not stored in a @State at launch. That is deliberate, and it is the answer to a question I will get to in the FAQ: the value can change while your app is alive. Checking it at the last possible moment, right when the person taps, is the only version that stays correct.
Note also .urlQueryAllowed on the fallback. Skip that encoding and a note containing an ampersand, a space, or a # produces a mailto: string that either truncates or silently refuses to open. The composer handles all of that for you; the fallback makes you do it by hand, which is a fair summary of the whole trade between the two paths.
The sheet will not close itself
This is the demo-killer.
MFMailComposeViewController does not dismiss when the user taps Cancel or Send. It fires your delegate and then just sits there, fully presented, waiting for you. If you forget the dismiss call, the sheet freezes on screen after the user thinks they are done, and there is no obvious way out. It reads as a hang.
func mailComposeController(_ controller: MFMailComposeViewController,
didFinishWith result: MFMailComposeResult,
error: Error?) {
parent.onFinish(result)
parent.dismiss() // <- the whole reason the coordinator exists
}
Every tutorial screenshot hides this, because a screenshot is the happy path caught once, before anyone taps Cancel. I only found it because I tested Send, saw it work, shipped, and then watched a user tap Cancel in a screen recording and get stuck. The @Environment(\.dismiss) I flagged earlier is what makes that one line possible from inside the coordinator. Wire it in makeUIViewController, call it in the delegate, and the sheet behaves.
What does .saved actually mean?
MFMailComposeResult has four cases, and I had mentally collapsed them into two: it worked, or it did not. That is wrong, and the wrong-ness has a specific cost.
| Result | What the user did | Did a message leave the device? | What I show now |
|---|---|---|---|
.sent |
Tapped Send | Yes, Mail queued it | "Sent" |
.saved |
Cancel, then Save Draft | No | Nothing, or "Saved a draft" |
.cancelled |
Cancel, then Delete Draft | No | Nothing |
.failed |
Send attempt failed | No | Offer retry, read error
|
The trap is .saved. When the user taps Cancel, iOS asks whether to save a draft. If they say yes, you get .saved. If your success check is written as "anything that is not .cancelled counts as sent," you will proudly show a green "Sent" checkmark for a message that is sitting in a drafts folder, unsent, possibly forever. I did exactly that for about a week. The note the person thought they had captured and sent was, from their side, just gone.
Branch on the specific case. Treat only .sent as sent. Treat .saved as its own quiet thing, because it is a real outcome a real person chose.
The Simulator quietly can't send
You cannot fully test this flow in the Simulator, and the Simulator will not tell you that in words.
A fresh simulator has no mail account, so canSendMail() returns false and you never see the sheet at all. If you wire around the guard to force the UI up during development, the compose window appears, you tap Send, the sheet dismisses, and nothing is delivered, because there is no account behind it. The failure is silent. There is no error dialog, no red console line. It just does not happen.
The only reliable way I found to exercise all four MFMailComposeResult branches is a physical device with a real account, tapping Send, Cancel-and-save, and Cancel-and-delete by hand and watching what my delegate does. Budget the ten minutes on hardware. I did not, at first, and I paid for it with the .saved bug above, which the Simulator structurally could not have shown me.
error is not the signal; result is
The delegate hands you an Error?, and it is a decoy.
In practice that error is nil almost every time, including on outcomes you would loosely call failures. A cancel is not an error. A saved draft is not an error. .failed is where an error can appear, and even there it is often thin. If you write your logic as if error == nil { showSuccess() }, you will show success for cancels and saved drafts alike, because their error is nil too.
The signal is result. Read the enum, switch on all four cases, and reach for error only inside .failed, and only to decide what to tell the user or whether a retry is worth attempting. I inverted this at first because every other iOS callback trains you to check the error object first. This one is the exception.
The fallback has its own edges
Once you accept that canSendMail() will be false for a chunk of real users, the mailto: fallback stops being a nicety and becomes half the feature. It has limits worth knowing before you lean on it.
A mailto: URL carries the body as a percent-encoded query string. Long notes bump into practical length ceilings, the encoding has to be exact or the link silently fails to open, and there are no attachments — mailto: cannot carry a file. So the fallback is not a drop-in twin of the composer. It is a narrower path for the plain-text case, which, for a note-to-email app, happens to be almost every case.
And when the composer itself returns .failed, the message has to land somewhere that will try again later rather than vanish. That queue is a separate piece of machinery with its own retry and ordering rules; I took it apart in an earlier write-up on the offline-first outbox. The mail sheet is only the front door. What happens after .failed is where reliability actually lives.
What I'd change after shipping it
Two things, now that the dust settled.
First, I would stop treating canSendMail() as a feature flag and treat mailto: as the default door, with the full composer as the enhancement layered on top when it is available. I built it the other way around, composer-first, and spent the fallback as an afterthought. The afterthought turned out to serve the users I most wanted to keep.
Second, I would log the four MFMailComposeResult cases from day one. I have no idea how often real people hit .saved versus .sent, and I wish I did, because it would tell me whether "Save Draft" is a genuine intent I should support better or an accident I should design away. I added that logging late. Instrument the boring enum early; it is the only honest record of what people actually do with your send button.
FAQ
Does canSendMail() change while my app is running?
It can. A person can add or remove a mail account in Settings while your app is backgrounded. Call canSendMail() right before you present the sheet, not once at launch and never again.
Does this wrapper work on iPad?
Yes. MFMailComposeViewController presents as a form sheet on iPad without extra work. The delegate, the four results, and the dismiss requirement are all identical; nothing about the edge cases above is iPhone-only.
Should I just skip the composer and use a mailto: link everywhere?
Only if you never need attachments, rich control over the message, or the in-app compose UI people already trust. mailto: is universal and reaches third-party default mail apps, but it is plain-text, length-limited, and attachment-free. I use the composer when canSendMail() is true and mailto: as the fallback, so each covers the other's blind spot.
If you ship anything that sends mail: do you gate on canSendMail(), or do you always offer a mailto: fallback? I want to hear the split, and the one bug that made you pick.
I build Simple Memo alone — an iOS app that turns the note you just typed into an email in about 0.3 seconds. I post here when the boring parts of shipping teach me something.
Top comments (0)