Zum Inhalt springen

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.

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")]),
]

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 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))

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"

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", …]

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 SwiftUI
import WebKit
import 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.

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.

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.

  • 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äfix sk_ mit dem Fehler private_key_in_browser ab. 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.

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.