How to build a contact form in iOS with Swift and SwiftUI
A SwiftUI contact form that emails you every message through your own mailbox – no backend and no SMTP password in the app. Uses the Frontmail Swift package.
Updated:
You’ll add a contact form to an iOS app with SwiftUI and the Frontmail Swift package. The app holds only a public key; Frontmail sends the email through your mailbox, so there’s no SMTP password in the app and no server to maintain.
You need: a free Frontmail account, an Xcode project with SwiftUI and about fifteen minutes.
1. Connect your mailbox and create the template
- Email services → Add service: connect Gmail, Outlook, SMTP or a transactional provider, tick Make default, Verify & save.
- Templates → From gallery → Contact form → Use template. Required parameters:
name,email,message; optional:phone,topic. - Copy the template ID (
tpl_…) from the Snippets tab.
2. Allow mobile apps
- Security → Mobile apps → Allow mobile apps → Save. Apps send no
Originheader, so until then the API answersorigin_not_allowed. - Template → Settings → Protection → Require CAPTCHA (Turnstile). Apps use Frontmail’s shared Turnstile widget; you don’t need Cloudflare keys.
3. Add the package
In Xcode choose File → Add Package Dependencies…, enter
https://github.com/frontmail-dev/frontmail-swift and add the Frontmail library to your app
target. Then copy the TurnstileView from the Turnstile section of the iOS SDK documentation into
your project – a small UIViewRepresentable around WKWebView that you own.
4. Build the form
import SwiftUI
import Frontmail
let frontmail = try! Frontmail(publicKey: "pk_…") // create once, share
struct ContactMessage: Encodable {
let name: String
let email: String
let message: String
}
struct ContactView: View {
@State private var name = ""
@State private var email = ""
@State private var message = ""
@State private var widget: Turnstile.Widget?
@State private var token: String?
@State private var resetID = 0
@State private var info = ""
var body: some View {
Form {
TextField("Name", text: $name)
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
TextField("Message", text: $message, axis: .vertical)
.lineLimit(4...8)
if let widget {
TurnstileView(widget: widget, size: .flexible, resetID: resetID) { event in
if case let .token(t) = event { token = t } else { token = nil }
}
.frame(height: Turnstile.Size.flexible.recommendedHeight)
}
Button("Send") { Task { await send() } }
.disabled(token == nil)
Text(info)
}
.task { widget = try? await Turnstile.widget(client: frontmail) }
}
private func send() async {
guard let widget, let token else { return }
defer { self.token = nil; resetID += 1 } // a token works only once
do {
_ = try await frontmail.send(
templateID: "tpl_…",
params: ContactMessage(name: name, email: email, message: message),
options: SendOptions(turnstileToken: token, turnstileKey: widget.turnstileKey)
)
info = "Thank you! We’ll get back to you soon."
} catch let error as FrontmailError {
info = error.message
} catch {
info = error.localizedDescription
}
}
}
Any Encodable struct works as the parameters, so the compiler checks the field names for you.
Network errors and 5xx responses are retried with one idempotency key – no duplicate emails on a
flaky connection.
5. Test it
Run the app in the simulator or on a device, fill in the form and tap Send. The email lands in your inbox and appears in History in the dashboard.
origin_not_allowed– Allow mobile apps is off or not saved.captcha_required– no Turnstile token yet; Send stays disabled until the check finishes.invalid_template_params– a required field is empty or the email is invalid.
When your credits run out, the message is accepted with the status
.heldand sent automatically after you top up – nothing gets lost.
Next steps
- Confirm the message to the user with the Auto-reply template from the gallery.
- Send
SendOptions(locale: Locale.preferredLanguages.first)to use a language variant of the template in the user’s language.


