Salta ai contenuti

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.

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

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

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

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"

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

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

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.

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.

  • 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 con sk_ con l’errore private_key_in_browser. Vedi Chiavi pubbliche e private.
  • Con Consenti app mobile attivo, le richieste senza header Origin vengono 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.

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.