Zum Inhalt springen

Android (Kotlin)

Mit dev.frontmail:frontmail sendet eine Android-App (oder jede Kotlin/JVM-App) E-Mails über Ihre Vorlagen mit dem öffentlichen Schlüssel – ganz ohne eigenes Backend. Es ist eine schlichte Kotlin-Bibliothek mit suspend-API; ihre einzigen Abhängigkeiten sind kotlinx-coroutines-core und kotlinx-serialization-json.

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

Die App benötigt Kotlin 2.2 oder neuer und die Berechtigung INTERNET:

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

Quellcode und Releases: github.com/frontmail-dev/frontmail-kotlin.

Native Apps senden keinen Origin- oder Referer-Header, daher werden Anfragen aus der App (egal ob Sie erlaubte Websites eingetragen haben oder nicht) mit 403 origin_not_allowed abgelehnt, bis Sie im Dashboard Sicherheit → Mobile Apps → Mobile Apps erlauben einschalten und auf Speichern klicken. Siehe Sicherheitseinstellungen.

Anfragen von Websites werden weiterhin mit der Liste abgeglichen – der Schalter schwächt also den Schutz Ihrer Webformulare nicht. Er ist auch bei leerer Liste erforderlich.

Erstellen Sie einen Client und verwenden Sie ihn wieder (er ist threadsicher). send ist eine suspend-Funktion; rufen Sie sie also aus einer Coroutine auf, z. B. in lifecycleScope oder 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) "Erhalten – wird in Kürze zugestellt." else "Vielen Dank!"
} catch (e: FrontmailException) {
state.value = e.message
}
}

params akzeptiert Strings, Zahlen, Booleans, null, verschachtelte Maps und Listen, Arrays und JsonElements; außerdem gibt es eine Überladung, die ein JsonObject nimmt. Ohne serviceId wird der E-Mail-Dienst der Vorlage verwendet.

result.status ist QUEUED (zur Zustellung angenommen) oder HELD (angenommen, wartet auf Credits). Mit result.statusToken können Sie den Zustellstatus später abfragen.

Um in einer Sprache der Vorlage zu senden, übergeben Sie SendOptions(locale = …) mit einem BCP-47-Tag – Locale.getDefault().toLanguageTag() liefert die Sprache des Geräts. Eine unbekannte Sprache fällt auf dieselbe Basissprache zurück, danach auf die Standardsprache der Vorlage; result.locale ist die verwendete Sprache:

val result = frontmail.send(
"tpl_contact",
mapOf("name" to name),
options = SendOptions(locale = Locale.getDefault().toLanguageTag()),
)

Client-Optionen:

Option Standard Bedeutung
apiUrl https://api.frontmail.dev Basis-URL der API
retry RetryPolicy() (3 Wiederholungen) RetryPolicy(retries, baseDelayMillis, maxDelayMillis), RetryPolicy.NONE schaltet Wiederholungen ab
timeoutMillis 15000 Timeout eines einzelnen Versuchs

Ein privater Schlüssel (sk_…) löst schon im Konstruktor eine FrontmailException mit dem Code private_key_in_browser aus – eine App darf nur den öffentlichen Schlüssel enthalten.

Jede Anfrage trägt den Header X-Frontmail-Client: frontmail-kotlin/<version>.

Erlaubt die Vorlage dynamische Anhänge, übergeben Sie sie in SendOptions:

frontmail.send(
"tpl_job_application",
mapOf("name" to name),
options = SendOptions(
attachments = listOf(
Attachment.fromBytes("lebenslauf.pdf", "application/pdf", bytes),
Attachment.Upload("upl_…"), // zuvor über POST /v1/uploads hochgeladen
),
),
)

Verlangt die Vorlage Turnstile, zeigen Sie das Cloudflare-Widget in einer WebView an und übergeben den Token an send. Das SDK hat keine Android-Abhängigkeit – es liefert die Bausteine, die WebView verdrahten Sie selbst:

  • Turnstile.sharedWidget() – der gemeinsame mobile Schlüssel von Frontmail. Einen Site-Key brauchen Sie nicht: Das SDK holt ihn von GET /v1/public-config (zwischengespeichert, also nur einmal geladen), und das Widget läuft als Inline-HTML-Seite mit der URL https://mobile.frontmail.dev – von dieser Adresse wird nichts geladen.
  • widget.html() – die Seite mit dem Widget. Sie sendet Nachrichten an die JavaScript-Bridge FrontmailTurnstile (Turnstile.ANDROID_BRIDGE_NAME).
  • Turnstile.parseMessage(data) – akzeptiert nur genau die Nachrichten, die die Seite sendet; liefert Token, Expired, Error oder null.
  • widget.navigationDecision(url, isForMainFrame) – der Navigationsschutz: ALLOW, OPEN_EXTERNALLY (Links im Widget) oder 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)
}

Senden Sie den Token dann zusammen mit dem turnstileKey des Widgets:

val widget = Turnstile.sharedWidget()
webView.showTurnstile(widget, onToken = { token = it }, onExpire = { token = null })
// beim Absenden:
try {
frontmail.send(
"tpl_contact",
mapOf("email" to email, "message" to message),
options = SendOptions(turnstileToken = token, turnstileKey = widget.turnstileKey),
)
} finally {
// Tokens sind nur einmal gültig – für den nächsten Versuch einen neuen holen.
token = null
webView.evaluateJavascript(Turnstile.RESET_SCRIPT, null)
}

Ein Token ist etwa 5 Minuten gültig und kann einmal verwendet werden: Setzen Sie das Widget nach jedem Versand zurück, ob erfolgreich oder nicht. Das normale Widget braucht eine WebView von etwa 300 × 70 dp (kompakt 150 × 140 dp).

App-Anfragen haben keinen Origin-Header, daher prüft Frontmail ihre Tokens mit seinem gemeinsamen mobilen Secret. Eigene Turnstile-Schlüssel brauchen Sie dafür nicht – mit aktiviertem Mobile Apps erlauben können Sie das CAPTCHA für die Vorlagen der App einschalten, auch wenn Ihre Organisation noch keine Schlüssel hat.

Das vollständige Beispiel (einschließlich Jetpack Compose und einer mit androidx.webkit auf den Origin der Seite beschränkten Bridge) finden Sie in der README des SDK.

Um stattdessen Ihr eigenes Turnstile-Widget zu verwenden, erstellen Sie es mit TurnstileWidget.own:

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

widget.turnstileKey ist dann TurnstileKey.ORG, der Token wird also mit turnstile_key: "org" gesendet, und Frontmail prüft ihn mit dem geheimen Schlüssel aus Sicherheit → Bot-Schutz (Turnstile).

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

Der Token wird im Header X-Frontmail-Status-Token übertragen, nie in der URL.

Netzwerkfehler, Timeouts, 5xx und 429 werden automatisch wiederholt (unter Beachtung von Retry-After; eine Wartezeit über 60 Sekunden wird nicht wiederholt), und alle Versuche einer Sendung verwenden denselben Idempotenzschlüssel – eine Wiederholung sendet die E-Mail also nie doppelt. Andere 4xx-Fehler werden nie wiederholt. Wird die Coroutine abgebrochen, endet die Anfrage sofort. Siehe Wiederholungen und Idempotenz.

Jeder Fehlschlag ist eine FrontmailException mit code, message, status (null bei Netzwerkfehlern), docsUrl, details und retryAfter sowie den Prüfungen isAuth, isValidation, isRateLimit, isInsufficientCredits und isNetwork. Client-Codes sind network_error, timeout, invalid_response und private_key_in_browser. Wie Sie mit den einzelnen Codes umgehen, steht unter Fehlerbehandlung.

Verwenden To/CC/BCC einer Vorlage Parameter, hat ein Versand mit dem öffentlichen Schlüssel gesperrte Inhalte: Die Vorlage muss Turnstile verlangen, die Absenderfelder müssen fest sein, Parameter dürfen kein HTML enthalten, Links müssen auf erlaubte Domains zeigen, und jeder Empfänger erhält nur eine begrenzte Anzahl E-Mails pro Tag.

  • Bauen Sie in eine App immer nur den öffentlichen Schlüssel (pk_…) ein. Alles in einer App lässt sich extrahieren, ein privater Schlüssel darin ist also so gut wie veröffentlicht. Siehe Öffentliche und private Schlüssel.
  • Mit aktiviertem Mobile Apps erlauben werden Anfragen ohne Origin-Header von überall angenommen – jedes Skript, das Ihren öffentlichen Schlüssel kennt, kann sie senden. Verlassen Sie sich auf das Rate Limit pro IP, Turnstile für jede Vorlage, die die App verwendet, und die Blockliste.