Cómo crear un formulario de contacto en Android con Kotlin y Jetpack Compose
Una pantalla de contacto en Jetpack Compose que te envía cada mensaje a través de tu propio buzón – sin backend y sin contraseña SMTP en el APK. Usa el SDK de Kotlin de Frontmail.
Actualizado:
Vas a añadir una pantalla de contacto a una app Android escrita en Kotlin con Jetpack Compose. La app solo guarda una clave pública: el correo lo envía Frontmail a través de tu buzón, así que no acaba ninguna contraseña SMTP dentro del APK, de donde cualquiera podría extraerla.
Necesitas: una cuenta gratuita de Frontmail, un proyecto Android con Compose y unos quince minutos.
1. Conecta tu buzón y crea la plantilla
- Servicios de correo → Añadir servicio: conecta Gmail, Outlook, SMTP o un proveedor transaccional, activa Marcar como predeterminado y pulsa Verificar y guardar.
- Plantillas → Desde la galería → Formulario de contacto → Usar plantilla.
Parámetros obligatorios:
name,email,message; opcionales:phone,topic. - Copia el ID de la plantilla (
tpl_…) de la pestaña Fragmentos.
2. Permite las apps móviles
- Seguridad → Apps móviles → Permitir apps móviles → Guardar. Las apps no envían
una cabecera
Origin, así que hasta que lo hagas la API las rechaza conorigin_not_allowed. - Plantilla → Ajustes → Protección → Exigir CAPTCHA (Turnstile). Las apps usan el widget compartido de Turnstile de Frontmail: no necesitas claves de Cloudflare.
3. Añade el SDK
// app/build.gradle.kts
dependencies {
implementation("dev.frontmail:frontmail:0.1.0")
}
Añade <uses-permission android:name="android.permission.INTERNET" /> a AndroidManifest.xml.
Después copia los helpers WebView.showTurnstile(…) y WebView.resetTurnstile() de la sección
Turnstile de la documentación del SDK de Android a tu proyecto: es un solo archivo y es tuyo.
4. Crea la pantalla
import android.webkit.WebView
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import dev.frontmail.Frontmail
import dev.frontmail.FrontmailException
import dev.frontmail.SendOptions
import dev.frontmail.Turnstile
import dev.frontmail.TurnstileWidget
import kotlinx.coroutines.launch
val frontmail = Frontmail("pk_…") // create once, reuse (thread-safe)
@Composable
fun ContactScreen() {
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var message by remember { mutableStateOf("") }
var widget by remember { mutableStateOf<TurnstileWidget?>(null) }
var token by remember { mutableStateOf<String?>(null) }
var webView by remember { mutableStateOf<WebView?>(null) }
var info by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { widget = Turnstile.sharedWidget() }
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(message, { message = it }, label = { Text("Message") }, minLines = 4, modifier = Modifier.fillMaxWidth())
widget?.let { w ->
AndroidView(
factory = { context ->
WebView(context).apply {
showTurnstile(w, onToken = { token = it }, onExpire = { token = null })
webView = this
}
},
modifier = Modifier.fillMaxWidth().height(70.dp),
)
}
Button(
enabled = token != null,
onClick = {
scope.launch {
try {
frontmail.send(
templateId = "tpl_…",
params = mapOf("name" to name, "email" to email, "message" to message),
options = SendOptions(turnstileToken = token, turnstileKey = widget?.turnstileKey),
)
info = "Thank you! We’ll get back to you soon."
} catch (e: FrontmailException) {
info = e.message ?: "Sending failed"
} finally {
token = null // a token works only once
webView?.resetTurnstile()
}
}
},
) { Text("Send") }
Text(info)
}
}
Los errores de red, los timeouts y las respuestas 5xx se reintentan automáticamente con la misma
clave de idempotencia, así que una cobertura débil nunca te envía el mismo mensaje dos veces.
5. Pruébalo
Ejecuta la app en un dispositivo o en el emulador, rellena el formulario y pulsa Send. El correo llega a tu bandeja y aparece en Historial en el panel.
origin_not_allowed– Permitir apps móviles está desactivado o no se guardó.captcha_required– todavía no hay token de Turnstile; el botón está desactivado hasta que termine la verificación.invalid_template_params– un campo obligatorio está vacío o el correo no es válido.
¿Sin créditos? El mensaje se acepta con el estado
HELDy se envía automáticamente en cuanto recargas; consultaresult.statussi quieres avisar al usuario.
Próximos pasos
- Añade la plantilla Respuesta automática: gracias por tu mensaje de la galería para confirmar el mensaje al usuario.
- Pasa
SendOptions(locale = Locale.getDefault().toLanguageTag())para usar la variante de idioma de la plantilla que corresponde al idioma del teléfono.


