iOS et plateformes Apple (Swift)
Le package Swift Frontmail permet à une application iOS (ou à n’importe quelle plateforme Apple)
d’envoyer des e-mails à partir de vos modèles avec la clé publique – sans backend à vous. Il n’a
aucune dépendance, utilise async/await et compile en mode de langage Swift 6.
Prérequis : Swift 5.9+ (Xcode 15+) ; iOS 15, macOS 12, tvOS 15, watchOS 8, visionOS 1.
Installation
Section intitulée « Installation »Xcode : File → Add Package Dependencies… → saisissez
https://github.com/frontmail-dev/frontmail-swift → ajoutez la bibliothèque Frontmail à la cible
de votre application.
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")]),]Autoriser les applications mobiles
Section intitulée « Autoriser les applications mobiles »Les applications natives n’envoient pas d’en-tête Origin ni Referer : les requêtes de
l’application sont donc refusées (que vous ayez défini des sites autorisés
ou non) avec 403 origin_not_allowed tant que vous
n’avez pas activé Sécurité → Applications mobiles → Autoriser les applications mobiles dans le
tableau de bord et cliqué sur Enregistrer. Voir
Paramètres de sécurité.
Les requêtes provenant de sites web restent comparées à la liste : l’option n’affaiblit donc pas la protection de vos formulaires web. L’option est nécessaire même avec une liste vide.
Créez le client une seule fois (il est immuable et thread-safe) et appelez 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": "Bonjour !", "qty": 2, "items": [["sku": "A-1"]]], serviceID: "svc_01J9…" // facultatif – nil utilise le service par défaut du modèle)if result.status == .held { // Bien reçu – il sera transmis sous peu (l’organisation attend des crédits).}send renvoie un SendResult avec messageID, status (.queued ou .held) et statusToken.
L’initialiseur lève une erreur si vous passez une clé privée (voir Sécurité).
Options de l’initialiseur :
let frontmail = try Frontmail( publicKey: "pk_4f2a…", apiURL: URL(string: "https://api.frontmail.dev")!, // par défaut retry: RetryPolicy(maxRetries: 3, baseDelay: 0.3, maxDelay: 10), // par défaut ; .disabled = aucune nouvelle tentative timeout: 15, // secondes par tentative (par défaut) session: .shared // URLSession (par défaut))Chaque requête porte l’en-tête X-Frontmail-Client: frontmail-swift/<version>.
Paramètres
Section intitulée « Paramètres »Les paramètres sont de type [String: FrontmailValue] – une valeur JSON que vous écrivez avec des
littéraux ordinaires (chaînes, nombres, booléens, nil, tableaux et dictionnaires). Pour des
valeurs issues de variables, utilisez les cas de l’énumération : ["email": .string(email)].
N’importe quelle valeur Encodable qui s’encode en objet JSON fonctionne aussi. Les noms de
propriétés sont envoyés tels quels (utilisez CodingKeys pour les renommer) et les dates sous forme
de chaînes ISO-8601 :
struct Contact: Encodable { let email: String let message: String}
try await frontmail.send(templateID: "tpl_contact", params: Contact(email: email, message: message))Pièces jointes et autres options
Section intitulée « Pièces jointes et autres options »SendOptions transporte le jeton Turnstile, les pièces jointes, une clé d’idempotence explicite et
la langue du modèle :
try await frontmail.send( templateID: "tpl_order", params: ["order": "1042"], options: SendOptions( turnstileToken: token, attachments: [ .data(pdfData, filename: "facture.pdf", contentType: "application/pdf"), .upload(id: "upl_…"), // uploadé au préalable via POST /v1/uploads ] ))SendOptions(locale: …) envoie l’e-mail dans une langue du modèle
(une balise BCP 47). Utilisez Locale.preferredLanguages.first (p. ex. de-AT) pour la langue de
l’appareil – Locale.current.identifier utilise _ et est refusé avec invalid_body. Une langue
inconnue se rabat sur la même langue de base, puis sur la langue par défaut du modèle ;
result.locale est la langue utilisée :
let result = try await frontmail.send( templateID: "tpl_contact", params: ["name": "Jan"], options: SendOptions(locale: Locale.preferredLanguages.first))print(result.locale ?? "default") // "de"Statut de livraison
Section intitulée « Statut de livraison »Le statusToken renvoyé par send permet à l’application de lire le statut de ce message. Le SDK
l’envoie dans l’en-tête X-Frontmail-Status-Token, jamais dans l’URL :
let status = try await frontmail.status(messageID: result.messageID, statusToken: result.statusToken)print(status.status) // .queued, .sending, .sent, .delivered, .bounced, … ou .unknown("…")print(status.events.map(\.type)) // ["accepted", "sent", …]Turnstile
Section intitulée « Turnstile »Si le modèle exige Turnstile, affichez le widget Cloudflare dans une
WKWebView et passez le jeton à send. Vous n’avez pas besoin de clé de site : par défaut, la
clé mobile partagée de Frontmail est utilisée. Turnstile.widget(client:) la récupère depuis
GET /v1/public-config (mise en cache en mémoire, elle
n’est donc chargée qu’une fois), et le widget s’exécute dans une page HTML intégrée à l’URL
https://mobile.frontmail.dev – rien n’est chargé depuis cette adresse.
Le SDK vous fournit les briques, la web view vit dans votre application :
| API | Rôle |
|---|---|
Turnstile.widget(client:) |
clé de site + URL de la page + la turnstileKey avec laquelle envoyer les jetons |
Turnstile.html(siteKey:theme:size:action:language:) |
la page du widget ; elle envoie ses messages au script message handler frontmailTurnstile |
Turnstile.parseMessage(_:) |
valide un message → .token(String), .expired, .error(code:) |
Turnstile.navigationAction(url:baseURL:isTopFrame:) |
garde de navigation : .allow, .cancel, .openExternally(URL) |
Turnstile.isTrustedMessageOrigin(_:baseURL:) |
n’accepte que les messages provenant de la page du widget |
Turnstile.resetScript |
JavaScript qui réinitialise le widget pour obtenir un nouveau jeton |
Une vue SwiftUI complète – copiez-la dans votre application :
import SwiftUIimport WebKitimport Frontmail
struct TurnstileView: UIViewRepresentable { let widget: Turnstile.Widget var theme: Turnstile.Theme = .auto var size: Turnstile.Size = .normal /// Changez la valeur pour réinitialiser le widget et obtenir un nouveau jeton (les jetons ne servent qu’une fois). 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 }
// Seule la page intégrée (cadre principal, servie comme baseURL) peut parler à l’application. 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) }
// Le cadre principal reste sur le widget ; ses liens s’ouvrent dans le navigateur du système. 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" : ne jamais créer de seconde 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 } }}Utilisez-la dans un formulaire : obtenez un jeton, envoyez, puis réinitialisez le 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("E-mail", 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 = "Erreur Turnstile \(code ?? "")" } } .frame(height: Turnstile.Size.flexible.recommendedHeight) } Button("Envoyer") { 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 jeton ne sert qu’une fois – demandez-en un nouveau quel que soit le résultat. 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 ? "Bien reçu – il sera transmis sous peu." : "Merci !" } catch let error as FrontmailError { info = error.message } catch { info = "\(error)" } }}La web view est verrouillée sur le widget : elle ne charge que la page intégrée (baseURL) et le
challenge Cloudflare, les liens du widget s’ouvrent dans le navigateur du système, aucune seconde
fenêtre ne peut être ouverte et seuls les messages de la page du widget parviennent à votre code. Le
même Coordinator fonctionne avec UIKit.
Les requêtes de l’application n’ont pas d’en-tête Origin : Frontmail vérifie donc leurs jetons avec
sa clé secrète mobile partagée. Vous n’avez pas besoin de vos propres clés Turnstile pour cela – avec
Autoriser les applications mobiles activé, vous pouvez activer le CAPTCHA sur les modèles de
l’application même si votre organisation n’a pas encore de clés.
Votre propre clé de site
Section intitulée « Votre propre clé de site »Pour utiliser plutôt votre propre widget Turnstile, créez vous-même le widget :
let widget = Turnstile.Widget.custom(siteKey: "YOUR_TURNSTILE_SITE_KEY", baseURL: URL(string: "https://example.com")!)Sa turnstileKey vaut .org : SendOptions(turnstileToken: token, turnstileKey: widget.turnstileKey)
envoie donc turnstile_key: "org" et Frontmail vérifie le jeton avec la clé secrète de
Sécurité → Protection contre les bots (Turnstile).
Un jeton est valable environ 5 minutes et ne peut être utilisé qu’une fois : réinitialisez le widget après chaque envoi, réussi ou non.
Nouvelles tentatives et idempotence
Section intitulée « Nouvelles tentatives et idempotence »Les erreurs réseau, les délais dépassés, les 5xx et les 429 sont relancés automatiquement
(3 nouvelles tentatives par défaut, backoff exponentiel avec jitter complet). Un en-tête
Retry-After est respecté ; s’il demande plus de 60 secondes, l’erreur est levée immédiatement. Les
autres réponses 4xx ne sont jamais relancées. Toutes les tentatives d’un même envoi réutilisent la
même clé d’idempotence (un UUID aléatoire, sauf si vous passez idempotencyKey) : une nouvelle
tentative n’envoie donc jamais l’e-mail deux fois. Annuler la Task appelante arrête immédiatement
la requête avec le code aborted. Voir
Nouvelles tentatives et idempotence.
Tout lève une FrontmailError avec code, message, status (nil pour les erreurs côté client),
docsURL, details et retryAfter. Les codes côté client sont network_error, timeout,
aborted, invalid_response et private_key_in_browser ; les tests isAuth, isValidation,
isRateLimit, isInsufficientCredits, isNetwork et isCancelled les regroupent :
do { try await frontmail.send(templateID: "tpl_contact", params: params)} catch let error as FrontmailError where error.isRateLimit { show("Trop de messages – réessayez dans \(Int(error.retryAfter ?? 60)) s.")} catch let error as FrontmailError where error.code == .originNotAllowed { assertionFailure("Activez Sécurité → Applications mobiles → Autoriser les applications mobiles dans le tableau de bord.")} catch let error as FrontmailError { show(error.message)}La façon de traiter chaque code est décrite dans Gestion des erreurs.
Sécurité
Section intitulée « Sécurité »- Ne placez jamais que la clé publique (
pk_…) dans une application. Tout ce qui se trouve dans un bundle d’application peut être extrait : une clé privée y serait comme publiée. Le SDK refuse les clés commençant parsk_avec l’erreurprivate_key_in_browser. Voir Clés publiques et privées. - Avec Autoriser les applications mobiles activé, les requêtes sans en-tête
Originsont acceptées de n’importe où – tout script qui connaît votre clé publique peut les envoyer. Appuyez-vous sur le rate limit par IP, sur Turnstile pour chaque modèle utilisé par l’application et sur la liste de blocage. - Un modèle qui prend son destinataire dans les paramètres (par exemple une confirmation envoyée à l’adresse saisie par l’utilisateur) est verrouillé : il exige Turnstile, un expéditeur fixe, des paramètres courts en texte brut et des liens uniquement vers vos domaines. Voir Liens autorisés et verrouillage du contenu.
Confidentialité
Section intitulée « Confidentialité »Le SDK ne suit pas les utilisateurs, ne collecte aucune donnée pour lui-même et n’utilise aucune API
soumise à justification (« required reason API ») – son PrivacyInfo.xcprivacy le déclare. Ce que
vos modèles envoient (comme une adresse e-mail) constitue des données collectées par votre
application : déclarez-les dans le manifeste de confidentialité de votre application et dans les
informations de confidentialité de l’App Store.