Saltar al contenido

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:

El vídeo está en inglés; los pasos de abajo son el tutorial completo.

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

  1. Servicios de correo → Añadir servicio: conecta Gmail, Outlook, SMTP o un proveedor transaccional, activa Marcar como predeterminado y pulsa Verificar y guardar.
  2. Plantillas → Desde la galería → Formulario de contacto → Usar plantilla. Parámetros obligatorios: name, email, message; opcionales: phone, topic.
  3. 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 con origin_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 HELD y se envía automáticamente en cuanto recargas; consulta result.status si 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.
Todos los tutoriales