Android (Kotlin)
dev.frontmail:frontmail permite que una app de Android (o cualquier app de Kotlin/JVM) envíe
correos a través de tus plantillas con la clave pública, sin necesidad de un backend propio. Es una
biblioteca de Kotlin normal y corriente con una API suspend; sus únicas dependencias son
kotlinx-coroutines-core y kotlinx-serialization-json.
Instalación
Sección titulada «Instalación»dependencies { implementation("dev.frontmail:frontmail:0.1.0")}La app necesita Kotlin 2.2 o posterior y el permiso INTERNET:
<uses-permission android:name="android.permission.INTERNET" />Código fuente y versiones: github.com/frontmail-dev/frontmail-kotlin.
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 un único cliente y reutilízalo (es seguro entre hilos). send es una función suspend, así
que llámala desde una corrutina, p. ej. lifecycleScope o viewModelScope:
import dev.frontmail.AcceptedStatusimport dev.frontmail.Frontmailimport dev.frontmail.FrontmailException
val frontmail = Frontmail("pk_4f2a…")
viewModelScope.launch { try { val result = frontmail.send( templateId = "tpl_contact", params = mapOf("email" to email, "message" to message), serviceId = "svc_01J9…", // opcional ) state.value = if (result.status == AcceptedStatus.HELD) "Recibido: lo entregaremos en breve." else "¡Gracias!" } catch (e: FrontmailException) { state.value = e.message }}params acepta cadenas, números, booleanos, null, mapas y listas anidados, arrays y
JsonElement; también hay una sobrecarga que recibe un JsonObject. Sin serviceId se usa el
servicio de la plantilla.
result.status es QUEUED (aceptado para su entrega) o HELD (aceptado y a la espera de
créditos). result.statusToken te permite leer más adelante el estado de entrega.
Para enviar en un idioma de la plantilla, pasa
SendOptions(locale = …) con una etiqueta BCP 47: Locale.getDefault().toLanguageTag() te da el
idioma del dispositivo. Un idioma desconocido recurre al mismo idioma base y, si no, al idioma
predeterminado de la plantilla; result.locale es el idioma usado:
val result = frontmail.send( "tpl_contact", mapOf("name" to name), options = SendOptions(locale = Locale.getDefault().toLanguageTag()),)Opciones del cliente:
| Opción | Valor por defecto | Significado |
|---|---|---|
apiUrl |
https://api.frontmail.dev |
URL base de la API |
retry |
RetryPolicy() (3 reintentos) |
RetryPolicy(retries, baseDelayMillis, maxDelayMillis); RetryPolicy.NONE desactiva los reintentos |
timeoutMillis |
15000 |
tiempo de espera de un solo intento |
Una clave privada (sk_…) lanza FrontmailException con el código private_key_in_browser ya en
el constructor: una app solo puede contener la clave pública.
Todas las solicitudes llevan la cabecera X-Frontmail-Client: frontmail-kotlin/<version>.
Adjuntos
Sección titulada «Adjuntos»Si la plantilla permite adjuntos dinámicos, pásalos en SendOptions:
frontmail.send( "tpl_job_application", mapOf("name" to name), options = SendOptions( attachments = listOf( Attachment.fromBytes("cv.pdf", "application/pdf", bytes), Attachment.Upload("upl_…"), // subido antes mediante POST /v1/uploads ), ),)Turnstile
Sección titulada «Turnstile»Si la plantilla exige Turnstile, muestra el widget de Cloudflare en un
WebView y pasa el token a send. El SDK no depende de Android, así que te da las piezas y tú
conectas el WebView:
Turnstile.sharedWidget(): la clave móvil compartida de Frontmail. No necesitas site key: el SDK la obtiene deGET /v1/public-config(en caché, así que solo se carga una vez) y el widget se ejecuta como una página HTML incrustada con la URLhttps://mobile.frontmail.dev; desde esa dirección no se carga nada.widget.html(): la página con el widget. Envía mensajes al puente de JavaScriptFrontmailTurnstile(Turnstile.ANDROID_BRIDGE_NAME).Turnstile.parseMessage(data): solo acepta exactamente los mensajes que envía la página; devuelveToken,Expired,Erroronull.widget.navigationDecision(url, isForMainFrame): la protección de navegación:ALLOW,OPEN_EXTERNALLY(enlaces dentro del widget) oBLOCK.
@SuppressLint("SetJavaScriptEnabled")fun WebView.showTurnstile(widget: TurnstileWidget, onToken: (String) -> Unit, onExpire: () -> Unit) { settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.allowFileAccess = false settings.allowContentAccess = false settings.javaScriptCanOpenWindowsAutomatically = false settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW setBackgroundColor(Color.TRANSPARENT)
val main = Handler(Looper.getMainLooper()) addJavascriptInterface( object { @JavascriptInterface fun postMessage(data: String?) { main.post { when (val m = Turnstile.parseMessage(data)) { is TurnstileMessage.Token -> onToken(m.token) TurnstileMessage.Expired -> onExpire() is TurnstileMessage.Error -> Log.w("Turnstile", "error ${m.code}") null -> Unit } } } }, Turnstile.ANDROID_BRIDGE_NAME, ) webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = when (widget.navigationDecision(request.url.toString(), request.isForMainFrame)) { Turnstile.NavigationDecision.ALLOW -> false Turnstile.NavigationDecision.OPEN_EXTERNALLY -> { runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, request.url)) } true } Turnstile.NavigationDecision.BLOCK -> true } } loadDataWithBaseURL(widget.baseUrl, widget.html(), "text/html", "utf-8", null)}Después, envía el token junto con la turnstileKey del widget:
val widget = Turnstile.sharedWidget()webView.showTurnstile(widget, onToken = { token = it }, onExpire = { token = null })
// al enviar:try { frontmail.send( "tpl_contact", mapOf("email" to email, "message" to message), options = SendOptions(turnstileToken = token, turnstileKey = widget.turnstileKey), )} finally { // Los tokens son de un solo uso: obtén uno nuevo para el siguiente intento. token = null webView.evaluateJavascript(Turnstile.RESET_SCRIPT, null)}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. El widget normal necesita un WebView de unos
300 × 70 dp (el compacto, 150 × 140 dp).
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.
El ejemplo completo (incluido Jetpack Compose y un puente limitado al origen de la página con
androidx.webkit) está en el README del SDK.
Tu propia site key
Sección titulada «Tu propia site key»Para usar en su lugar tu propio widget de Turnstile, créalo con TurnstileWidget.own:
val widget = TurnstileWidget.own(siteKey = "YOUR_TURNSTILE_SITE_KEY", baseUrl = "https://example.com")widget.turnstileKey es entonces TurnstileKey.ORG, así que el token se envía con
turnstile_key: "org" y Frontmail lo verifica con la secret key de
Seguridad → Protección contra bots (Turnstile).
getStatus
Sección titulada «getStatus»val status = frontmail.getStatus(result) // o getStatus(messageId, statusToken)status.status // MessageStatus.SENT, DELIVERED, BOUNCED, …status.events // [MessageEvent(type = "accepted", at = "…"), …]El token viaja en la cabecera X-Frontmail-Status-Token, nunca en la URL.
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 (respetando Retry-After; una espera de más de 60 segundos no se reintenta), y
todos los intentos de un mismo envío reutilizan la misma clave de idempotencia, así que un
reintento nunca envía el correo dos veces. Los demás errores 4xx no se reintentan nunca. Cancelar
la corrutina detiene la solicitud de inmediato. Consulta
Reintentos e idempotencia.
Errores
Sección titulada «Errores»Cualquier fallo es una FrontmailException con code, message, status (null en los errores
de red), docsUrl, details y retryAfter, además de las comprobaciones isAuth,
isValidation, isRateLimit, isInsufficientCredits e isNetwork. Los códigos del lado del
cliente son network_error, timeout, invalid_response y private_key_in_browser. Cómo tratar
cada código se explica en Gestión de errores.
Destinatarios dinámicos
Sección titulada «Destinatarios dinámicos»Si el To/CC/BCC de una plantilla usa parámetros, un envío con la clave pública tiene el contenido bloqueado: la plantilla debe exigir Turnstile, los campos del remitente deben ser fijos, los parámetros no pueden contener HTML, los enlaces deben apuntar a dominios permitidos y cada destinatario recibe un número limitado de correos al día.
Seguridad
Sección titulada «Seguridad»- En una app pon siempre solo la clave pública (
pk_…). Todo lo que hay en una app se puede extraer, así que una clave privada ahí es como si estuviera publicada. 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.