Skip to content

Android (Kotlin)

dev.frontmail:frontmail lets an Android app (or any Kotlin/JVM app) send emails through your templates with the public key – no backend of your own needed. It’s a plain Kotlin library with a suspend API; its only dependencies are kotlinx-coroutines-core and kotlinx-serialization-json.

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

The app needs Kotlin 2.2 or newer and the INTERNET permission:

<uses-permission android:name="android.permission.INTERNET" />

Source code and releases: github.com/frontmail-dev/frontmail-kotlin.

Native apps don’t send an Origin or Referer header, so requests from the app are rejected (whether or not you have allowed websites set) with 403 origin_not_allowed until you turn on Security → Mobile apps → Allow mobile apps in the dashboard and click Save. See Security settings.

Requests from websites are still checked against the list, so the switch doesn’t weaken the protection of your web forms. The switch is required even with an empty list.

Create one client and reuse it (it’s thread-safe). send is a suspend function, so call it from a coroutine, e.g. lifecycleScope or 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…", // optional
)
state.value = if (result.status == AcceptedStatus.HELD) "Received – it will be delivered shortly." else "Thank you!"
} catch (e: FrontmailException) {
state.value = e.message
}
}

params accepts strings, numbers, booleans, null, nested maps and lists, arrays and JsonElements; there’s also an overload that takes a JsonObject. Without serviceId the template’s service is used.

result.status is QUEUED (accepted for delivery) or HELD (accepted and waiting for credits). result.statusToken lets you read the delivery status later.

Client options:

Option Default Meaning
apiUrl https://api.frontmail.dev API base URL
retry RetryPolicy() (3 retries) RetryPolicy(retries, baseDelayMillis, maxDelayMillis), RetryPolicy.NONE disables retries
timeoutMillis 15000 timeout of a single attempt

A private key (sk_…) throws FrontmailException with code private_key_in_browser right in the constructor – an app may only contain the public key.

Every request carries the header X-Frontmail-Client: frontmail-kotlin/<version>.

If the template allows dynamic attachments, pass them in SendOptions:

frontmail.send(
"tpl_job_application",
mapOf("name" to name),
options = SendOptions(
attachments = listOf(
Attachment.fromBytes("cv.pdf", "application/pdf", bytes),
Attachment.Upload("upl_…"), // uploaded beforehand via POST /v1/uploads
),
),
)

If the template requires Turnstile, show the Cloudflare widget in a WebView and pass the token to send. The SDK has no Android dependency, so it gives you the pieces and you wire up the WebView:

  • Turnstile.sharedWidget() – Frontmail’s shared mobile key. You don’t need a site key: the SDK fetches it from GET /v1/public-config (cached, so it’s loaded once) and the widget runs as an inline HTML page with the URL https://mobile.frontmail.dev – nothing is loaded from that address.
  • widget.html() – the page with the widget. It posts messages to the JavaScript bridge FrontmailTurnstile (Turnstile.ANDROID_BRIDGE_NAME).
  • Turnstile.parseMessage(data) – accepts only the exact messages the page posts; returns Token, Expired, Error or null.
  • widget.navigationDecision(url, isForMainFrame) – the navigation guard: ALLOW, OPEN_EXTERNALLY (links inside the widget) or 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)
}

Then send the token together with the widget’s turnstileKey:

val widget = Turnstile.sharedWidget()
webView.showTurnstile(widget, onToken = { token = it }, onExpire = { token = null })
// on submit:
try {
frontmail.send(
"tpl_contact",
mapOf("email" to email, "message" to message),
options = SendOptions(turnstileToken = token, turnstileKey = widget.turnstileKey),
)
} finally {
// Tokens are single use – get a fresh one for the next attempt.
token = null
webView.evaluateJavascript(Turnstile.RESET_SCRIPT, null)
}

A token is valid for about 5 minutes and can be used once: reset the widget after every send, successful or not. The normal widget needs a WebView of about 300 × 70 dp (compact 150 × 140 dp).

App requests have no Origin header, so Frontmail verifies their tokens with its shared mobile secret. You don’t need your own Turnstile keys for this – with Allow mobile apps on, you can turn the CAPTCHA on for the app’s templates even if your organization has no keys yet.

The complete example (including Jetpack Compose and a bridge limited to the page origin with androidx.webkit) is in the SDK’s README.

To use your own Turnstile widget instead, create it with TurnstileWidget.own:

val widget = TurnstileWidget.own(siteKey = "YOUR_TURNSTILE_SITE_KEY", baseUrl = "https://example.com")

widget.turnstileKey is then TurnstileKey.ORG, so the token is sent with turnstile_key: "org" and Frontmail verifies it with the secret key from Security → Bot protection (Turnstile).

val status = frontmail.getStatus(result) // or getStatus(messageId, statusToken)
status.status // MessageStatus.SENT, DELIVERED, BOUNCED, …
status.events // [MessageEvent(type = "accepted", at = "…"), …]

The token travels in the X-Frontmail-Status-Token header, never in the URL.

Network errors, timeouts, 5xx and 429 are retried automatically (with Retry-After respected; a wait over 60 seconds isn’t retried), and all attempts of one send reuse the same idempotency key, so a retry never sends the email twice. Other 4xx errors are never retried. Cancelling the coroutine stops the request immediately. See Retries and idempotency.

Every failure is a FrontmailException with code, message, status (null for network errors), docsUrl, details and retryAfter, plus the checks isAuth, isValidation, isRateLimit, isInsufficientCredits and isNetwork. Client-side codes are network_error, timeout, invalid_response and private_key_in_browser. How to handle each code is in Error handling.

If the To/CC/BCC of a template use params, a send with the public key has locked content: the template must require Turnstile, the sender fields must be fixed, params can’t contain HTML, links must point to allowed domains and every recipient gets a limited number of emails per day.

  • Only ever put the public key (pk_…) into an app. Anything in an app can be extracted, so a private key there is as good as published. See Public and private keys.
  • With Allow mobile apps on, requests without an Origin header are accepted from anywhere – any script that knows your public key can send them. Rely on the per-IP rate limit, Turnstile on every template the app uses and the block list.