Skip to content

How to build a contact form in Android with Kotlin and Jetpack Compose

A Jetpack Compose contact screen that emails you every message through your own mailbox – no backend, no SMTP password in the APK. Uses the Frontmail Kotlin SDK.

Updated:

You’ll add a contact screen to an Android app written in Kotlin and Jetpack Compose. The app keeps only a public key – the email is sent by Frontmail through your mailbox, so no SMTP password ends up in the APK where anyone could extract it.

You need: a free Frontmail account, an Android project with Compose and about fifteen minutes.

1. Connect your mailbox and create the template

  1. Email services → Add service: connect Gmail, Outlook, SMTP or a transactional provider, tick Make default, Verify & save.
  2. Templates → From gallery → Contact form → Use template. Required parameters: name, email, message; optional: phone, topic.
  3. Copy the template ID (tpl_…) from the Snippets tab.

2. Allow mobile apps

  • Security → Mobile apps → Allow mobile apps → Save. Apps don’t send an Origin header, so the API rejects them with origin_not_allowed until you do this.
  • Template → Settings → Protection → Require CAPTCHA (Turnstile). Apps use Frontmail’s shared Turnstile widget – no Cloudflare keys needed.

3. Add the SDK

// app/build.gradle.kts
dependencies {
    implementation("dev.frontmail:frontmail:0.1.0")
}

Add <uses-permission android:name="android.permission.INTERNET" /> to AndroidManifest.xml. Then copy the WebView.showTurnstile(…) and WebView.resetTurnstile() helpers from the Turnstile section of the Android SDK documentation into your project – it’s one file you own.

4. Build the screen

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

Network errors, timeouts and 5xx responses are retried automatically with the same idempotency key, so a weak signal never sends you the same message twice.

5. Test it

Run the app on a device or emulator, fill in the form and tap Send. The email arrives in your inbox and is listed in History in the dashboard.

  • origin_not_allowed – Allow mobile apps is off or wasn’t saved.
  • captcha_required – no Turnstile token yet; the button is disabled until the check finishes.
  • invalid_template_params – a required field is empty or the email is invalid.

Out of credits? The message is accepted with the status HELD and sent automatically once you top up – check result.status if you want to tell the user.

Next steps

  • Add the Auto-reply template from the gallery to confirm the message to the user.
  • Pass SendOptions(locale = Locale.getDefault().toLanguageTag()) to use the template’s language variant for the phone’s language.
All tutorials