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
- Email services → Add service: connect Gmail, Outlook, SMTP or a transactional provider, tick Make default, Verify & save.
- Templates → From gallery → Contact form → Use template. Required parameters:
name,email,message; optional:phone,topic. - 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
Originheader, so the API rejects them withorigin_not_alloweduntil 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
HELDand sent automatically once you top up – checkresult.statusif 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.


