Ir al contenido

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.

app/build.gradle.kts
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.

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.AcceptedStatus
import dev.frontmail.Frontmail
import 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>.

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

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 de GET /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 URL https://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 JavaScript FrontmailTurnstile (Turnstile.ANDROID_BRIDGE_NAME).
  • Turnstile.parseMessage(data): solo acepta exactamente los mensajes que envía la página; devuelve Token, Expired, Error o null.
  • widget.navigationDecision(url, isForMainFrame): la protección de navegación: ALLOW, OPEN_EXTERNALLY (enlaces dentro del widget) o BLOCK.
@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.

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

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.

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.

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.

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.

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