Skip to content

iOS & Apple platforms (Swift)

The Frontmail Swift package lets an iOS app (or any Apple platform) send emails through your templates with the public key – no backend of your own needed. It has no dependencies, uses async/await and compiles in Swift 6 language mode.

Requirements: Swift 5.9+ (Xcode 15+); iOS 15, macOS 12, tvOS 15, watchOS 8, visionOS 1.

Xcode: File → Add Package Dependencies… → enter https://github.com/frontmail-dev/frontmail-swift → add the Frontmail library to your app target.

Package.swift:

dependencies: [
.package(url: "https://github.com/frontmail-dev/frontmail-swift", from: "0.1.0"),
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "Frontmail", package: "frontmail-swift")]),
]

Native apps don’t send an Origin or Referer header, so requests from the app are rejected (whether or not you have allowed websites set) with 403 origin_not_allowed until you turn on Security → Mobile apps → Allow mobile apps in the dashboard and click Save. See Security settings.

Requests from websites are still checked against the list, so the switch doesn’t weaken the protection of your web forms. The switch is required even with an empty list.

Create the client once (it’s immutable and thread-safe) and call send:

import Frontmail
let frontmail = try Frontmail(publicKey: "pk_4f2a…")
let result = try await frontmail.send(
templateID: "tpl_contact",
params: ["email": "jan@example.com", "message": "Hello!", "qty": 2, "items": [["sku": "A-1"]]],
serviceID: "svc_01J9…" // optional – nil uses the template's default service
)
if result.status == .held {
// Received – it will be delivered shortly (the organization is waiting for credits).
}

send returns SendResult with messageID, status (.queued or .held) and statusToken. The initializer throws when you pass a private key (see Security).

Options of the initializer:

let frontmail = try Frontmail(
publicKey: "pk_4f2a…",
apiURL: URL(string: "https://api.frontmail.dev")!, // default
retry: RetryPolicy(maxRetries: 3, baseDelay: 0.3, maxDelay: 10), // default; .disabled = no retries
timeout: 15, // seconds per attempt (default)
session: .shared // URLSession (default)
)

Every request carries the header X-Frontmail-Client: frontmail-swift/<version>.

Params are [String: FrontmailValue] – a JSON value you write with ordinary literals (strings, numbers, booleans, nil, arrays and dictionaries). For values from variables use the cases: ["email": .string(email)].

Any Encodable value that encodes to a JSON object works too. Property names are sent as they are (use CodingKeys to rename them) and dates as ISO-8601 strings:

struct Contact: Encodable {
let email: String
let message: String
}
try await frontmail.send(templateID: "tpl_contact", params: Contact(email: email, message: message))

SendOptions carries the Turnstile token, attachments and an explicit idempotency key:

try await frontmail.send(
templateID: "tpl_order",
params: ["order": "1042"],
options: SendOptions(
turnstileToken: token,
attachments: [
.data(pdfData, filename: "invoice.pdf", contentType: "application/pdf"),
.upload(id: "upl_…"), // uploaded earlier via POST /v1/uploads
]
)
)

The statusToken from send lets the app read the status of that message. The SDK sends it in the X-Frontmail-Status-Token header, never in the URL:

let status = try await frontmail.status(messageID: result.messageID, statusToken: result.statusToken)
print(status.status) // .queued, .sending, .sent, .delivered, .bounced, … or .unknown("…")
print(status.events.map(\.type)) // ["accepted", "sent", …]

If the template requires Turnstile, show the Cloudflare widget in a WKWebView and pass the token to send. You don’t need a site key: by default Frontmail’s shared mobile key is used. Turnstile.widget(client:) fetches it from GET /v1/public-config (cached in memory, so it’s loaded once), and the widget runs as an inline HTML page with the URL https://mobile.frontmail.dev – nothing is loaded from that address.

The SDK gives you the pieces, the web view lives in your app:

API What it does
Turnstile.widget(client:) site key + page URL + the turnstileKey to send tokens with
Turnstile.html(siteKey:theme:size:action:language:) the widget page; it posts to the script message handler frontmailTurnstile
Turnstile.parseMessage(_:) validates a message → .token(String), .expired, .error(code:)
Turnstile.navigationAction(url:baseURL:isTopFrame:) navigation guard: .allow, .cancel, .openExternally(URL)
Turnstile.isTrustedMessageOrigin(_:baseURL:) accepts messages only from the widget page
Turnstile.resetScript JavaScript that resets the widget for a new token

A complete SwiftUI view – copy it into your app:

import SwiftUI
import WebKit
import Frontmail
struct TurnstileView: UIViewRepresentable {
let widget: Turnstile.Widget
var theme: Turnstile.Theme = .auto
var size: Turnstile.Size = .normal
/// Change the value to reset the widget and get a new token (tokens are single use).
var resetID = 0
let onMessage: (Turnstile.Message) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.userContentController.add(context.coordinator, name: Turnstile.messageHandlerName)
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = false
context.coordinator.resetID = resetID
webView.loadHTMLString(Turnstile.html(siteKey: widget.siteKey, theme: theme, size: size), baseURL: widget.baseURL)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.parent = self
if context.coordinator.resetID != resetID {
context.coordinator.resetID = resetID
webView.evaluateJavaScript(Turnstile.resetScript)
}
}
static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
webView.configuration.userContentController.removeScriptMessageHandler(forName: Turnstile.messageHandlerName)
}
@MainActor
final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate {
var parent: TurnstileView
var resetID = 0
init(_ parent: TurnstileView) { self.parent = parent }
// Only the inline page (main frame, served as baseURL) may talk to the app.
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.frameInfo.isMainFrame,
Turnstile.isTrustedMessageOrigin(message.frameInfo.request.url, baseURL: parent.widget.baseURL),
let parsed = Turnstile.parseMessage(message.body) else { return }
parent.onMessage(parsed)
}
// The top frame stays on the widget; links inside it open in the system browser.
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void
) {
switch Turnstile.navigationAction(
url: navigationAction.request.url,
baseURL: parent.widget.baseURL,
isTopFrame: navigationAction.targetFrame?.isMainFrame
) {
case .allow:
decisionHandler(.allow)
case .cancel:
decisionHandler(.cancel)
case let .openExternally(url):
decisionHandler(.cancel)
UIApplication.shared.open(url)
}
}
// window.open / target="_blank": never create a second web view.
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
if case let .openExternally(url) = Turnstile.navigationAction(
url: navigationAction.request.url, baseURL: parent.widget.baseURL, isTopFrame: true
) {
UIApplication.shared.open(url)
}
return nil
}
}
}

Use it in a form: get a token, send, then reset the widget:

struct ContactView: View {
let frontmail: Frontmail
@State private var widget: Turnstile.Widget?
@State private var token: String?
@State private var resetID = 0
@State private var email = ""
@State private var message = ""
@State private var info = ""
var body: some View {
Form {
TextField("Email", text: $email)
TextField("Message", text: $message)
if let widget {
TurnstileView(widget: widget, size: .flexible, resetID: resetID) { event in
switch event {
case let .token(t): token = t
case .expired: token = nil
case let .error(code): info = "Turnstile error \(code ?? "")"
}
}
.frame(height: Turnstile.Size.flexible.recommendedHeight)
}
Button("Send") { Task { await submit() } }.disabled(token == nil)
Text(info)
}
.task {
do { widget = try await Turnstile.widget(client: frontmail) } catch { info = "\(error)" }
}
}
private func submit() async {
guard let widget, let token else { return }
// A token works once – ask for a new one whatever the outcome.
defer { self.token = nil; resetID += 1 }
do {
let result = try await frontmail.send(
templateID: "tpl_contact",
params: ["email": .string(email), "message": .string(message)],
options: SendOptions(turnstileToken: token, turnstileKey: widget.turnstileKey)
)
info = result.status == .held ? "Received – it will be delivered shortly." : "Thank you!"
} catch let error as FrontmailError {
info = error.message
} catch {
info = "\(error)"
}
}
}

The web view is locked to the widget: it only loads the inline page (baseURL) and Cloudflare’s challenge, links inside the widget open in the system browser, no second window can be opened and only messages from the widget page reach your code. The same Coordinator works in UIKit.

App requests have no Origin header, so Frontmail verifies their tokens with its shared mobile secret. You don’t need your own Turnstile keys for this – with Allow mobile apps on, you can turn the CAPTCHA on for the app’s templates even if your organization has no keys yet.

To use your own Turnstile widget instead, create the widget yourself:

let widget = Turnstile.Widget.custom(siteKey: "YOUR_TURNSTILE_SITE_KEY", baseURL: URL(string: "https://example.com")!)

Its turnstileKey is .org, so SendOptions(turnstileToken: token, turnstileKey: widget.turnstileKey) sends turnstile_key: "org" and Frontmail verifies the token with the secret key from Security → Bot protection (Turnstile).

A token is valid for about 5 minutes and can be used once: reset the widget after every send, successful or not.

Network errors, timeouts, 5xx and 429 are retried automatically (3 retries by default, exponential backoff with full jitter). A Retry-After header is honored; if it asks for more than 60 seconds, the error is thrown right away. Other 4xx responses are never retried. All attempts of one send reuse the same idempotency key (a random UUID unless you pass idempotencyKey), so a retry never sends the email twice. Cancelling the calling Task stops the request immediately with the code aborted. See Retries and idempotency.

Everything throws FrontmailError with code, message, status (nil for client-side errors), docsURL, details and retryAfter. Client codes are network_error, timeout, aborted, invalid_response and private_key_in_browser; the checks isAuth, isValidation, isRateLimit, isInsufficientCredits, isNetwork and isCancelled group them:

do {
try await frontmail.send(templateID: "tpl_contact", params: params)
} catch let error as FrontmailError where error.isRateLimit {
show("Too many messages – try again in \(Int(error.retryAfter ?? 60)) s.")
} catch let error as FrontmailError where error.code == .originNotAllowed {
assertionFailure("Turn on Security → Mobile apps → Allow mobile apps in the dashboard.")
} catch let error as FrontmailError {
show(error.message)
}

How to handle each code is in Error handling.

  • Only ever put the public key (pk_…) into an app. Anything in an app bundle can be extracted, so a private key there is as good as published. The SDK refuses keys starting with sk_ with the error private_key_in_browser. See Public and private keys.
  • With Allow mobile apps on, requests without an Origin header are accepted from anywhere – any script that knows your public key can send them. Rely on the per-IP rate limit, Turnstile on every template the app uses and the block list.
  • A template that takes its recipient from params (for example a confirmation sent to the address the user typed) is locked: it needs Turnstile, a fixed sender, short plain params and links only to your domains. See Link allowlist & content lock.

The SDK doesn’t track users, collects no data of its own and uses no required-reason APIs – its PrivacyInfo.xcprivacy says so. What your templates send (such as an email address) is data your app collects: declare it in your app’s privacy manifest and in the App Store privacy details.