Skip to content

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

  1. Email services → Add service: connect Gmail, Outlook, SMTP or a transactional provider, tick Make default, Verify & save.
  2. Templates → From gallery → Contact form → Use template. Required parameters: name, email, message; optional: phone, topic.
  3. Copy the template ID (tpl_…) from the Snippets tab.

2. Allow mobile apps

  • Security → Mobile apps → Allow mobile apps → Save. Apps send no Origin header, so until then the API answers origin_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 .held and 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.
All tutorials