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.
Install
Section titled “Install”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.
Allow mobile apps
Section titled “Allow mobile apps”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.AcceptedStatusimport dev.frontmail.Frontmailimport 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>.
Attachments
Section titled “Attachments”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 ), ),)Turnstile
Section titled “Turnstile”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 fromGET /v1/public-config(cached, so it’s loaded once) and the widget runs as an inline HTML page with the URLhttps://mobile.frontmail.dev– nothing is loaded from that address.widget.html()– the page with the widget. It posts messages to the JavaScript bridgeFrontmailTurnstile(Turnstile.ANDROID_BRIDGE_NAME).Turnstile.parseMessage(data)– accepts only the exact messages the page posts; returnsToken,Expired,Errorornull.widget.navigationDecision(url, isForMainFrame)– the navigation guard:ALLOW,OPEN_EXTERNALLY(links inside the widget) orBLOCK.
@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.
Your own site key
Section titled “Your own site key”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).
getStatus
Section titled “getStatus”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.
Retries and idempotency
Section titled “Retries and idempotency”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.
Errors
Section titled “Errors”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.
Dynamic recipients
Section titled “Dynamic recipients”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.
Security
Section titled “Security”- 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
Originheader 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.