iOS & Apple-Plattformen (Swift)
Mit dem Swift-Paket Frontmail sendet eine iOS-App (oder jede andere Apple-Plattform) E-Mails über
Ihre Vorlagen mit dem öffentlichen Schlüssel – ganz ohne eigenes Backend. Das Paket hat keine
Abhängigkeiten, nutzt async/await und kompiliert im Sprachmodus von Swift 6.
Voraussetzungen: Swift 5.9+ (Xcode 15+); iOS 15, macOS 12, tvOS 15, watchOS 8, visionOS 1.
Installation
Abschnitt betitelt „Installation“Xcode: File → Add Package Dependencies… →
https://github.com/frontmail-dev/frontmail-swift eingeben → die Bibliothek Frontmail zum
App-Target hinzufügen.
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")]),]Mobile Apps erlauben
Abschnitt betitelt „Mobile Apps erlauben“Native Apps senden keinen Origin- oder Referer-Header, daher werden Anfragen aus der App (egal ob
Sie erlaubte Websites eingetragen haben oder nicht) mit
403 origin_not_allowed abgelehnt, bis Sie im Dashboard
Sicherheit → Mobile Apps → Mobile Apps erlauben einschalten und auf Speichern klicken. Siehe
Sicherheitseinstellungen.
Anfragen von Websites werden weiterhin mit der Liste abgeglichen – der Schalter schwächt also den Schutz Ihrer Webformulare nicht. Er ist auch bei leerer Liste erforderlich.
Erstellen Sie den Client einmal (er ist unveränderlich und threadsicher) und rufen Sie send auf:
import Frontmail
let frontmail = try Frontmail(publicKey: "pk_4f2a…")
let result = try await frontmail.send( templateID: "tpl_contact", params: ["email": "jan@example.com", "message": "Guten Tag!", "qty": 2, "items": [["sku": "A-1"]]], serviceID: "svc_01J9…" // optional – nil verwendet den Standarddienst der Vorlage)if result.status == .held { // Erhalten – wird in Kürze zugestellt (die Organisation wartet auf Credits).}send liefert ein SendResult mit messageID, status (.queued oder .held) und statusToken.
Der Initializer wirft einen Fehler, wenn Sie einen privaten Schlüssel übergeben (siehe
Sicherheit).
Optionen des Initializers:
let frontmail = try Frontmail( publicKey: "pk_4f2a…", apiURL: URL(string: "https://api.frontmail.dev")!, // Standard retry: RetryPolicy(maxRetries: 3, baseDelay: 0.3, maxDelay: 10), // Standard; .disabled = keine Wiederholungen timeout: 15, // Sekunden pro Versuch (Standard) session: .shared // URLSession (Standard))Jede Anfrage trägt den Header X-Frontmail-Client: frontmail-swift/<version>.
Parameter
Abschnitt betitelt „Parameter“Parameter sind [String: FrontmailValue] – ein JSON-Wert, den Sie mit gewöhnlichen Literalen
schreiben (Strings, Zahlen, Booleans, nil, Arrays und Dictionaries). Für Werte aus Variablen
verwenden Sie die Cases: ["email": .string(email)].
Auch jeder Encodable-Wert, der zu einem JSON-Objekt kodiert wird, funktioniert. Property-Namen
werden unverändert gesendet (umbenennen können Sie sie mit CodingKeys), Datumswerte als
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))Anhänge und weitere Optionen
Abschnitt betitelt „Anhänge und weitere Optionen“SendOptions enthält den Turnstile-Token, Anhänge, einen expliziten Idempotenzschlüssel und die
Sprache der Vorlage:
try await frontmail.send( templateID: "tpl_order", params: ["order": "1042"], options: SendOptions( turnstileToken: token, attachments: [ .data(pdfData, filename: "rechnung.pdf", contentType: "application/pdf"), .upload(id: "upl_…"), // zuvor über POST /v1/uploads hochgeladen ] ))SendOptions(locale: …) versendet die E-Mail in einer Sprache der Vorlage
(ein BCP-47-Tag). Verwenden Sie Locale.preferredLanguages.first (z. B. de-AT) für die Sprache des
Geräts – Locale.current.identifier verwendet _ und wird mit invalid_body abgelehnt. Eine
unbekannte Sprache fällt auf dieselbe Basissprache zurück, danach auf die Standardsprache der
Vorlage; result.locale ist die verwendete Sprache:
let result = try await frontmail.send( templateID: "tpl_contact", params: ["name": "Jan"], options: SendOptions(locale: Locale.preferredLanguages.first))print(result.locale ?? "default") // "de"Zustellstatus
Abschnitt betitelt „Zustellstatus“Mit dem statusToken aus send kann die App den Status dieser Nachricht lesen. Das SDK sendet ihn
im Header X-Frontmail-Status-Token, nie in der URL:
let status = try await frontmail.status(messageID: result.messageID, statusToken: result.statusToken)print(status.status) // .queued, .sending, .sent, .delivered, .bounced, … oder .unknown("…")print(status.events.map(\.type)) // ["accepted", "sent", …]Turnstile
Abschnitt betitelt „Turnstile“Verlangt die Vorlage Turnstile, zeigen Sie das Cloudflare-Widget in einer
WKWebView an und übergeben den Token an send. Einen Site-Key brauchen Sie nicht:
Standardmäßig wird der gemeinsame mobile Schlüssel von Frontmail verwendet.
Turnstile.widget(client:) holt ihn von
GET /v1/public-config (im Speicher zwischengespeichert,
also nur einmal geladen), und das Widget läuft als Inline-HTML-Seite mit der URL
https://mobile.frontmail.dev – von dieser Adresse wird nichts geladen.
Das SDK liefert die Bausteine, die Web-View lebt in Ihrer App:
| API | Funktion |
|---|---|
Turnstile.widget(client:) |
Site-Key + Seiten-URL + der turnstileKey, mit dem Tokens gesendet werden |
Turnstile.html(siteKey:theme:size:action:language:) |
die Widget-Seite; sie sendet an den Script-Message-Handler frontmailTurnstile |
Turnstile.parseMessage(_:) |
validiert eine Nachricht → .token(String), .expired, .error(code:) |
Turnstile.navigationAction(url:baseURL:isTopFrame:) |
Navigationsschutz: .allow, .cancel, .openExternally(URL) |
Turnstile.isTrustedMessageOrigin(_:baseURL:) |
akzeptiert Nachrichten nur von der Widget-Seite |
Turnstile.resetScript |
JavaScript, das das Widget für einen neuen Token zurücksetzt |
Eine vollständige SwiftUI-View – kopieren Sie sie in Ihre App:
import SwiftUIimport WebKitimport Frontmail
struct TurnstileView: UIViewRepresentable { let widget: Turnstile.Widget var theme: Turnstile.Theme = .auto var size: Turnstile.Size = .normal /// Ändern Sie den Wert, um das Widget zurückzusetzen und einen neuen Token zu erhalten (Tokens sind nur einmal gültig). 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 }
// Nur die Inline-Seite (Hauptframe, ausgeliefert als baseURL) darf mit der App sprechen. 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) }
// Der Hauptframe bleibt auf dem Widget; Links darin öffnen sich im Systembrowser. 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": niemals eine zweite Web-View erzeugen. 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 } }}Verwendung in einem Formular: Token holen, senden, dann das Widget zurücksetzen:
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("E-Mail", text: $email) TextField("Nachricht", 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-Fehler \(code ?? "")" } } .frame(height: Turnstile.Size.flexible.recommendedHeight) } Button("Senden") { 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 } // Ein Token funktioniert nur einmal – unabhängig vom Ergebnis einen neuen anfordern. 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 ? "Erhalten – wird in Kürze zugestellt." : "Vielen Dank!" } catch let error as FrontmailError { info = error.message } catch { info = "\(error)" } }}Die Web-View ist auf das Widget beschränkt: Sie lädt nur die Inline-Seite (baseURL) und die
Challenge von Cloudflare, Links im Widget öffnen sich im Systembrowser, es lässt sich kein zweites
Fenster öffnen, und nur Nachrichten der Widget-Seite erreichen Ihren Code. Derselbe Coordinator
funktioniert auch in UIKit.
App-Anfragen haben keinen Origin-Header, daher prüft Frontmail ihre Tokens mit seinem gemeinsamen
mobilen Secret. Eigene Turnstile-Schlüssel brauchen Sie dafür nicht – mit aktiviertem
Mobile Apps erlauben können Sie das CAPTCHA für die Vorlagen der App einschalten, auch wenn Ihre
Organisation noch keine Schlüssel hat.
Eigener Site-Key
Abschnitt betitelt „Eigener Site-Key“Um stattdessen Ihr eigenes Turnstile-Widget zu verwenden, erstellen Sie das Widget selbst:
let widget = Turnstile.Widget.custom(siteKey: "YOUR_TURNSTILE_SITE_KEY", baseURL: URL(string: "https://example.com")!)Sein turnstileKey ist .org, daher sendet
SendOptions(turnstileToken: token, turnstileKey: widget.turnstileKey) den Wert
turnstile_key: "org", und Frontmail prüft den Token mit dem geheimen Schlüssel aus
Sicherheit → Bot-Schutz (Turnstile).
Ein Token ist etwa 5 Minuten gültig und kann einmal verwendet werden: Setzen Sie das Widget nach jedem Versand zurück, ob erfolgreich oder nicht.
Wiederholungen und Idempotenz
Abschnitt betitelt „Wiederholungen und Idempotenz“Netzwerkfehler, Timeouts, 5xx und 429 werden automatisch wiederholt (standardmäßig 3
Wiederholungen, exponentielles Backoff mit Full Jitter). Ein Retry-After-Header wird beachtet;
verlangt er mehr als 60 Sekunden, wird der Fehler sofort geworfen. Andere 4xx-Antworten werden nie
wiederholt. Alle Versuche einer Sendung verwenden denselben Idempotenzschlüssel (eine zufällige UUID,
sofern Sie kein idempotencyKey übergeben) – eine Wiederholung sendet die E-Mail also nie doppelt.
Wird der aufrufende Task abgebrochen, endet die Anfrage sofort mit dem Code aborted. Siehe
Wiederholungen und Idempotenz.
Alles wirft FrontmailError mit code, message, status (nil bei Fehlern auf Clientseite),
docsURL, details und retryAfter. Client-Codes sind network_error, timeout, aborted,
invalid_response und private_key_in_browser; die Prüfungen isAuth, isValidation,
isRateLimit, isInsufficientCredits, isNetwork und isCancelled fassen sie zusammen:
do { try await frontmail.send(templateID: "tpl_contact", params: params)} catch let error as FrontmailError where error.isRateLimit { show("Zu viele Nachrichten – versuchen Sie es in \(Int(error.retryAfter ?? 60)) s erneut.")} catch let error as FrontmailError where error.code == .originNotAllowed { assertionFailure("Schalten Sie im Dashboard Sicherheit → Mobile Apps → Mobile Apps erlauben ein.")} catch let error as FrontmailError { show(error.message)}Wie Sie mit den einzelnen Codes umgehen, steht unter Fehlerbehandlung.
Sicherheit
Abschnitt betitelt „Sicherheit“- Bauen Sie in eine App immer nur den öffentlichen Schlüssel (
pk_…) ein. Alles in einem App-Bundle lässt sich extrahieren, ein privater Schlüssel darin ist also so gut wie veröffentlicht. Das SDK lehnt Schlüssel mit dem Präfixsk_mit dem Fehlerprivate_key_in_browserab. Siehe Öffentliche und private Schlüssel. - Mit aktiviertem Mobile Apps erlauben werden Anfragen ohne
Origin-Header von überall angenommen – jedes Skript, das Ihren öffentlichen Schlüssel kennt, kann sie senden. Verlassen Sie sich auf das Rate Limit pro IP, Turnstile für jede Vorlage, die die App verwendet, und die Blockliste. - Eine Vorlage, die ihren Empfänger aus Parametern übernimmt (etwa eine Bestätigung an die Adresse, die der Nutzer eingegeben hat), ist gesperrt: Sie braucht Turnstile, einen festen Absender, kurze einfache Parameter und Links nur auf Ihre Domains. Siehe Erlaubte Links & Inhaltssperre.
Datenschutz
Abschnitt betitelt „Datenschutz“Das SDK trackt keine Nutzer, erhebt selbst keine Daten und verwendet keine Required-Reason-APIs –
das steht so auch in seiner PrivacyInfo.xcprivacy. Was Ihre Vorlagen senden (etwa eine
E-Mail-Adresse), sind Daten, die Ihre App erhebt: Geben Sie sie im Privacy Manifest Ihrer App und in
den Datenschutzangaben im App Store an.