Ir al contenido

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.

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

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

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

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"

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

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

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.

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.

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.

  • 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 por sk_ con el error private_key_in_browser. Consulta Claves públicas y privadas.
  • Con Permitir apps móviles activado, las solicitudes sin cabecera Origin se 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.

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.