iOS e piattaforme Apple (Swift)
Il pacchetto Swift Frontmail permette a un’app iOS (o a qualsiasi piattaforma Apple) di inviare
email tramite i tuoi modelli con la chiave pubblica – senza bisogno di un tuo backend. Non ha
dipendenze, usa async/await e compila in modalità linguaggio Swift 6.
Requisiti: Swift 5.9+ (Xcode 15+); iOS 15, macOS 12, tvOS 15, watchOS 8, visionOS 1.
Installazione
Sezione intitolata “Installazione”Xcode: File → Add Package Dependencies… → inserisci
https://github.com/frontmail-dev/frontmail-swift → aggiungi la libreria Frontmail al target
della tua app.
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")]),]Consenti app mobile
Sezione intitolata “Consenti app mobile”Le app native non inviano l’header Origin né Referer, quindi le richieste dall’app vengono
rifiutate (che tu abbia impostato o meno dei siti consentiti) con
403 origin_not_allowed finché non attivi
Sicurezza → App mobile → Consenti app mobile nella dashboard e fai clic su Salva. Vedi
Impostazioni di sicurezza.
Le richieste dai siti web vengono comunque confrontate con la lista, quindi l’interruttore non indebolisce la protezione dei tuoi moduli web. L’interruttore è necessario anche con la lista vuota.
Crea il client una sola volta (è immutabile e thread-safe) e chiama send:
import Frontmail
let frontmail = try Frontmail(publicKey: "pk_4f2a…")
let result = try await frontmail.send( templateID: "tpl_contact", params: ["email": "giulia@example.com", "message": "Ciao!", "qty": 2, "items": [["sku": "A-1"]]], serviceID: "svc_01J9…" // facoltativo – nil usa il servizio predefinito del modello)if result.status == .held { // Ricevuto – verrà recapitato a breve (l’organizzazione è in attesa di crediti).}send restituisce SendResult con messageID, status (.queued o .held) e statusToken.
L’inizializzatore lancia un errore quando passi una chiave privata (vedi Sicurezza).
Opzioni dell’inizializzatore:
let frontmail = try Frontmail( publicKey: "pk_4f2a…", apiURL: URL(string: "https://api.frontmail.dev")!, // predefinito retry: RetryPolicy(maxRetries: 3, baseDelay: 0.3, maxDelay: 10), // predefinito; .disabled = nessun tentativo timeout: 15, // secondi per tentativo (predefinito) session: .shared // URLSession (predefinito))Ogni richiesta porta l’header X-Frontmail-Client: frontmail-swift/<version>.
Parametri
Sezione intitolata “Parametri”I parametri sono [String: FrontmailValue] – un valore JSON che scrivi con normali letterali
(stringhe, numeri, booleani, nil, array e dizionari). Per i valori provenienti da variabili usa i
case: ["email": .string(email)].
Funziona anche qualsiasi valore Encodable che si codifica in un oggetto JSON. I nomi delle
proprietà vengono inviati così come sono (usa CodingKeys per rinominarli) e le date come stringhe
ISO-8601:
struct Contact: Encodable { let email: String let message: String}
try await frontmail.send(templateID: "tpl_contact", params: Contact(email: email, message: message))Allegati e altre opzioni
Sezione intitolata “Allegati e altre opzioni”SendOptions contiene il token Turnstile, gli allegati, una chiave di idempotenza esplicita e la
lingua del modello:
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_…"), // caricato in precedenza tramite POST /v1/uploads ] ))SendOptions(locale: …) invia l’email in una lingua del modello
(un tag BCP 47). Per la lingua del dispositivo usa Locale.preferredLanguages.first (ad es. de-AT)
– Locale.current.identifier usa _ e viene rifiutato come invalid_body. Una lingua sconosciuta
ripiega sulla stessa lingua di base, poi sulla lingua predefinita del modello; result.locale è la
lingua usata:
let result = try await frontmail.send( templateID: "tpl_contact", params: ["name": "Jan"], options: SendOptions(locale: Locale.preferredLanguages.first))print(result.locale ?? "default") // "de"Stato di recapito
Sezione intitolata “Stato di recapito”Lo statusToken restituito da send permette all’app di leggere lo stato di quel messaggio. L’SDK
lo invia nell’header X-Frontmail-Status-Token, mai nell’URL:
let status = try await frontmail.status(messageID: result.messageID, statusToken: result.statusToken)print(status.status) // .queued, .sending, .sent, .delivered, .bounced, … oppure .unknown("…")print(status.events.map(\.type)) // ["accepted", "sent", …]Turnstile
Sezione intitolata “Turnstile”Se il modello richiede Turnstile, mostra il widget di Cloudflare in una
WKWebView e passa il token a send. Non ti serve una chiave del sito: per impostazione
predefinita viene usata la chiave mobile condivisa di Frontmail. Turnstile.widget(client:) la
recupera da GET /v1/public-config (con cache in memoria,
quindi viene caricata una volta sola) e il widget gira come pagina HTML inline con l’URL
https://mobile.frontmail.dev – da quell’indirizzo non viene caricato nulla.
L’SDK ti fornisce i pezzi, la web view vive nella tua app:
| API | Cosa fa |
|---|---|
Turnstile.widget(client:) |
chiave del sito + URL della pagina + il turnstileKey con cui inviare i token |
Turnstile.html(siteKey:theme:size:action:language:) |
la pagina del widget; invia messaggi allo script message handler frontmailTurnstile |
Turnstile.parseMessage(_:) |
valida un messaggio → .token(String), .expired, .error(code:) |
Turnstile.navigationAction(url:baseURL:isTopFrame:) |
controllo della navigazione: .allow, .cancel, .openExternally(URL) |
Turnstile.isTrustedMessageOrigin(_:baseURL:) |
accetta messaggi solo dalla pagina del widget |
Turnstile.resetScript |
JavaScript che reimposta il widget per ottenere un nuovo token |
Una view SwiftUI completa – copiala nella tua app:
import SwiftUIimport WebKitimport Frontmail
struct TurnstileView: UIViewRepresentable { let widget: Turnstile.Widget var theme: Turnstile.Theme = .auto var size: Turnstile.Size = .normal /// Cambia il valore per reimpostare il widget e ottenere un nuovo token (i token sono monouso). 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 }
// Solo la pagina inline (frame principale, servita come baseURL) può comunicare con l’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) }
// Il frame principale resta sul widget; i link al suo interno si aprono nel browser di sistema. 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": non creare mai una seconda 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 } }}Usala in un modulo: ottieni un token, invia, poi reimposta il 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("Messaggio", 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 = "Errore Turnstile \(code ?? "")" } } .frame(height: Turnstile.Size.flexible.recommendedHeight) } Button("Invia") { 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 } // Un token funziona una sola volta – chiedine uno nuovo qualunque sia l’esito. 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 ? "Ricevuto – verrà recapitato a breve." : "Grazie!" } catch let error as FrontmailError { info = error.message } catch { info = "\(error)" } }}La web view è vincolata al widget: carica solo la pagina inline (baseURL) e la challenge di
Cloudflare, i link all’interno del widget si aprono nel browser di sistema, non è possibile aprire
una seconda finestra e solo i messaggi della pagina del widget arrivano al tuo codice. Lo stesso
Coordinator funziona anche in UIKit.
Le richieste dall’app non hanno l’header Origin, quindi Frontmail verifica i loro token con la
sua chiave segreta mobile condivisa. Per questo non ti servono chiavi Turnstile tue – con
Consenti app mobile attivo puoi attivare il CAPTCHA sui modelli dell’app anche se la tua
organizzazione non ha ancora chiavi.
La tua chiave del sito
Sezione intitolata “La tua chiave del sito”Per usare invece il tuo widget Turnstile, crea tu il widget:
let widget = Turnstile.Widget.custom(siteKey: "YOUR_TURNSTILE_SITE_KEY", baseURL: URL(string: "https://example.com")!)Il suo turnstileKey è .org, quindi SendOptions(turnstileToken: token, turnstileKey: widget.turnstileKey)
invia turnstile_key: "org" e Frontmail verifica il token con la chiave segreta impostata in
Sicurezza → Protezione dai bot (Turnstile).
Un token è valido per circa 5 minuti e può essere usato una sola volta: reimposta il widget dopo ogni invio, riuscito o no.
Tentativi e idempotenza
Sezione intitolata “Tentativi e idempotenza”Gli errori di rete, i timeout, i 5xx e i 429 vengono ripetuti automaticamente (3 tentativi per
impostazione predefinita, backoff esponenziale con full jitter). L’header Retry-After viene
rispettato; se chiede più di 60 secondi, l’errore viene lanciato subito. Le altre risposte 4xx non
vengono mai ripetute. Tutti i tentativi di un invio riutilizzano la stessa chiave di idempotenza (un
UUID casuale, a meno che tu non passi idempotencyKey), quindi un nuovo tentativo non invia mai
l’email due volte. Annullare il Task chiamante interrompe subito la richiesta con il codice
aborted. Vedi Tentativi e idempotenza.
Tutto lancia FrontmailError con code, message, status (nil per gli errori lato client),
docsURL, details e retryAfter. I codici lato client sono network_error, timeout,
aborted, invalid_response e private_key_in_browser; i controlli isAuth, isValidation,
isRateLimit, isInsufficientCredits, isNetwork e isCancelled li raggruppano:
do { try await frontmail.send(templateID: "tpl_contact", params: params)} catch let error as FrontmailError where error.isRateLimit { show("Troppi messaggi – riprova tra \(Int(error.retryAfter ?? 60)) s.")} catch let error as FrontmailError where error.code == .originNotAllowed { assertionFailure("Nella dashboard attiva Sicurezza → App mobile → Consenti app mobile.")} catch let error as FrontmailError { show(error.message)}Come gestire ciascun codice è spiegato in Gestione degli errori.
Sicurezza
Sezione intitolata “Sicurezza”- In un’app metti sempre e solo la chiave pubblica (
pk_…). Tutto ciò che sta nel bundle di un’app può essere estratto, quindi una chiave privata lì dentro è come se fosse pubblicata. L’SDK rifiuta le chiavi che iniziano consk_con l’erroreprivate_key_in_browser. Vedi Chiavi pubbliche e private. - Con Consenti app mobile attivo, le richieste senza header
Originvengono accettate da qualsiasi parte – qualunque script che conosca la tua chiave pubblica può inviarle. Affidati al rate limit per IP, a Turnstile su ogni modello usato dall’app e alla lista dei bloccati. - Un modello che prende il destinatario dai parametri (per esempio una conferma inviata all’indirizzo digitato dall’utente) è bloccato: richiede Turnstile, un mittente fisso, parametri brevi e senza markup e link solo verso i tuoi domini. Vedi Allowlist dei link e blocco del contenuto.
Privacy
Sezione intitolata “Privacy”L’SDK non traccia gli utenti, non raccoglie dati propri e non usa API che richiedono una
motivazione (required-reason API) – lo dichiara il suo PrivacyInfo.xcprivacy. Ciò che inviano i
tuoi modelli (come un indirizzo email) sono dati raccolti dalla tua app: dichiarali nel privacy
manifest della tua app e nei dettagli sulla privacy dell’App Store.