iOS y plataformas de Apple (Swift)
El paquete de Swift Frontmail permite que una app de iOS (o de cualquier plataforma de Apple)
envíe correos a través de tus plantillas con la clave pública, sin necesidad de un backend propio.
No tiene dependencias, usa async/await y compila en el modo de lenguaje de Swift 6.
Requisitos: Swift 5.9+ (Xcode 15+); iOS 15, macOS 12, tvOS 15, watchOS 8, visionOS 1.
Instalación
Sección titulada «Instalación»Xcode: File → Add Package Dependencies… → introduce
https://github.com/frontmail-dev/frontmail-swift → añade la biblioteca Frontmail al target de tu
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")]),]Permitir apps móviles
Sección titulada «Permitir apps móviles»Las apps nativas no envían cabecera Origin ni Referer, así que las solicitudes desde la app se
rechazan (tengas o no configurados sitios web permitidos) con
403 origin_not_allowed hasta que actives
Seguridad → Apps móviles → Permitir apps móviles en el panel y hagas clic en Guardar.
Consulta Ajustes de seguridad.
Las solicitudes desde sitios web se siguen comprobando contra la lista, así que el interruptor no debilita la protección de tus formularios web. El interruptor es necesario incluso con la lista vacía.
Crea el cliente una vez (es inmutable y seguro entre hilos) y llama a 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": "¡Hola!", "qty": 2, "items": [["sku": "A-1"]]], serviceID: "svc_01J9…" // opcional: nil usa el servicio por defecto de la plantilla)if result.status == .held { // Recibido: se entregará en breve (la organización está esperando créditos).}send devuelve un SendResult con messageID, status (.queued o .held) y statusToken.
El inicializador lanza un error cuando le pasas una clave privada (consulta Seguridad).
Opciones del inicializador:
let frontmail = try Frontmail( publicKey: "pk_4f2a…", apiURL: URL(string: "https://api.frontmail.dev")!, // por defecto retry: RetryPolicy(maxRetries: 3, baseDelay: 0.3, maxDelay: 10), // por defecto; .disabled = sin reintentos timeout: 15, // segundos por intento (por defecto) session: .shared // URLSession (por defecto))Todas las solicitudes llevan la cabecera X-Frontmail-Client: frontmail-swift/<version>.
Parámetros
Sección titulada «Parámetros»Los parámetros son [String: FrontmailValue], un valor JSON que escribes con literales normales
(cadenas, números, booleanos, nil, arrays y diccionarios). Para valores que vienen de variables,
usa los casos: ["email": .string(email)].
También funciona cualquier valor Encodable que se codifique como un objeto JSON. Los nombres de
las propiedades se envían tal cual (usa CodingKeys para renombrarlas) y las fechas como cadenas
ISO-8601:
struct Contact: Encodable { let email: String let message: String}
try await frontmail.send(templateID: "tpl_contact", params: Contact(email: email, message: message))Adjuntos y otras opciones
Sección titulada «Adjuntos y otras opciones»SendOptions lleva el token de Turnstile, los adjuntos, una clave de idempotencia explícita y el
idioma de la plantilla:
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_…"), // subido antes mediante POST /v1/uploads ] ))SendOptions(locale: …) envía el correo en un idioma de la plantilla
(una etiqueta BCP 47). Usa Locale.preferredLanguages.first (p. ej. de-AT) para el idioma del
dispositivo: Locale.current.identifier usa _ y se rechaza como invalid_body. Un idioma
desconocido recurre al mismo idioma base y, si no, al idioma predeterminado de la plantilla;
result.locale es el idioma usado:
let result = try await frontmail.send( templateID: "tpl_contact", params: ["name": "Jan"], options: SendOptions(locale: Locale.preferredLanguages.first))print(result.locale ?? "default") // "de"Estado de entrega
Sección titulada «Estado de entrega»El statusToken de send permite a la app leer el estado de ese mensaje. El SDK lo envía en la
cabecera X-Frontmail-Status-Token, nunca en la URL:
let status = try await frontmail.status(messageID: result.messageID, statusToken: result.statusToken)print(status.status) // .queued, .sending, .sent, .delivered, .bounced, … o .unknown("…")print(status.events.map(\.type)) // ["accepted", "sent", …]Turnstile
Sección titulada «Turnstile»Si la plantilla exige Turnstile, muestra el widget de Cloudflare en un
WKWebView y pasa el token a send. No necesitas site key: por defecto se usa la clave móvil
compartida de Frontmail. Turnstile.widget(client:) la obtiene de
GET /v1/public-config (en caché en memoria, así que solo
se carga una vez), y el widget se ejecuta como una página HTML incrustada con la URL
https://mobile.frontmail.dev; desde esa dirección no se carga nada.
El SDK te da las piezas; la vista web vive en tu app:
| API | Qué hace |
|---|---|
Turnstile.widget(client:) |
site key + URL de la página + la turnstileKey con la que enviar los tokens |
Turnstile.html(siteKey:theme:size:action:language:) |
la página del widget; envía mensajes al script message handler frontmailTurnstile |
Turnstile.parseMessage(_:) |
valida un mensaje → .token(String), .expired, .error(code:) |
Turnstile.navigationAction(url:baseURL:isTopFrame:) |
protección de navegación: .allow, .cancel, .openExternally(URL) |
Turnstile.isTrustedMessageOrigin(_:baseURL:) |
solo acepta mensajes de la página del widget |
Turnstile.resetScript |
JavaScript que restablece el widget para obtener un token nuevo |
Una vista de SwiftUI completa; cópiala en tu app:
import SwiftUIimport WebKitimport Frontmail
struct TurnstileView: UIViewRepresentable { let widget: Turnstile.Widget var theme: Turnstile.Theme = .auto var size: Turnstile.Size = .normal /// Cambia el valor para restablecer el widget y obtener un token nuevo (los tokens son de un solo uso). 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 página incrustada (marco principal, servida como baseURL) puede hablar con la 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) }
// El marco principal se queda en el widget; sus enlaces se abren en el navegador del 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": no crear nunca una segunda vista web. 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 } }}Úsala en un formulario: obtén un token, envía y luego restablece el 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("Correo", text: $email) TextField("Mensaje", 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 = "Error de Turnstile \(code ?? "")" } } .frame(height: Turnstile.Size.flexible.recommendedHeight) } Button("Enviar") { 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 sirve una sola vez: pide uno nuevo sea cual sea el resultado. 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 ? "Recibido: lo entregaremos en breve." : "¡Gracias!" } catch let error as FrontmailError { info = error.message } catch { info = "\(error)" } }}La vista web está limitada al widget: solo carga la página incrustada (baseURL) y el desafío de
Cloudflare, los enlaces dentro del widget se abren en el navegador del sistema, no se puede abrir
una segunda ventana y solo los mensajes de la página del widget llegan a tu código. El mismo
Coordinator funciona en UIKit.
Las solicitudes de la app no llevan cabecera Origin, así que Frontmail verifica sus tokens con su
secreto móvil compartido. No necesitas tus propias claves de Turnstile para esto: con Permitir
apps móviles activado, puedes activar la CAPTCHA en las plantillas de la app aunque tu
organización todavía no tenga claves.
Tu propia site key
Sección titulada «Tu propia site key»Para usar en su lugar tu propio widget de Turnstile, crea tú el widget:
let widget = Turnstile.Widget.custom(siteKey: "YOUR_TURNSTILE_SITE_KEY", baseURL: URL(string: "https://example.com")!)Su turnstileKey es .org, así que SendOptions(turnstileToken: token, turnstileKey: widget.turnstileKey)
envía turnstile_key: "org" y Frontmail verifica el token con la secret key de
Seguridad → Protección contra bots (Turnstile).
Un token es válido durante unos 5 minutos y se puede usar una sola vez: restablece el widget después de cada envío, tenga éxito o no.
Reintentos e idempotencia
Sección titulada «Reintentos e idempotencia»Los errores de red, los tiempos de espera agotados, los 5xx y los 429 se reintentan
automáticamente (3 reintentos por defecto, con espera exponencial y jitter completo). Se respeta la
cabecera Retry-After; si pide más de 60 segundos, el error se lanza de inmediato. Las demás
respuestas 4xx no se reintentan nunca. Todos los intentos de un mismo envío reutilizan la misma
clave de idempotencia (un UUID aleatorio, salvo que pases idempotencyKey), así que un reintento
nunca envía el correo dos veces. Cancelar la Task que hace la llamada detiene la solicitud de
inmediato con el código aborted. Consulta
Reintentos e idempotencia.
Errores
Sección titulada «Errores»Todo lanza FrontmailError con code, message, status (nil en los errores del lado del
cliente), docsURL, details y retryAfter. Los códigos del cliente son network_error,
timeout, aborted, invalid_response y private_key_in_browser; las comprobaciones isAuth,
isValidation, isRateLimit, isInsufficientCredits, isNetwork e isCancelled los agrupan:
do { try await frontmail.send(templateID: "tpl_contact", params: params)} catch let error as FrontmailError where error.isRateLimit { show("Demasiados mensajes: vuelve a intentarlo dentro de \(Int(error.retryAfter ?? 60)) s.")} catch let error as FrontmailError where error.code == .originNotAllowed { assertionFailure("Activa Seguridad → Apps móviles → Permitir apps móviles en el panel.")} catch let error as FrontmailError { show(error.message)}Cómo tratar cada código se explica en Gestión de errores.
Seguridad
Sección titulada «Seguridad»- En una app pon siempre solo la clave pública (
pk_…). Todo lo que hay en el bundle de una app se puede extraer, así que una clave privada ahí es como si estuviera publicada. El SDK rechaza las claves que empiezan porsk_con el errorprivate_key_in_browser. Consulta Claves públicas y privadas. - Con Permitir apps móviles activado, las solicitudes sin cabecera
Originse aceptan desde cualquier lugar: cualquier script que conozca tu clave pública puede enviarlas. Apóyate en el límite de solicitudes por IP, en Turnstile en todas las plantillas que use la app y en la lista de bloqueo. - Una plantilla que toma el destinatario de los parámetros (por ejemplo, una confirmación enviada a la dirección que ha escrito el usuario) tiene el contenido bloqueado: necesita Turnstile, un remitente fijo, parámetros cortos en texto plano y enlaces solo a tus dominios. Consulta Lista de enlaces permitidos y bloqueo de contenido.
Privacidad
Sección titulada «Privacidad»El SDK no rastrea a los usuarios, no recopila datos propios y no usa ninguna API de las que exigen
declarar un motivo (required-reason APIs); así lo indica su PrivacyInfo.xcprivacy. Lo que envían
tus plantillas (como una dirección de correo) son datos que recopila tu app: decláralos en el
manifiesto de privacidad de tu app y en los detalles de privacidad de la App Store.