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.
Install
Section titled “Install”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")]),]Allow mobile apps
Section titled “Allow mobile apps”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
Section titled “Params”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))Attachments and other options
Section titled “Attachments and other options”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 ] ))Delivery status
Section titled “Delivery status”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", …]Turnstile
Section titled “Turnstile”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 SwiftUIimport WebKitimport 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.
Your own site key
Section titled “Your own site key”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.
Retries and idempotency
Section titled “Retries and idempotency”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.
Errors
Section titled “Errors”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.
Security
Section titled “Security”- 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 withsk_with the errorprivate_key_in_browser. See Public and private keys. - With Allow mobile apps on, requests without an
Originheader 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.
Privacy
Section titled “Privacy”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.