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.
Installation
Abschnitt betitelt „Installation“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.
Mobile Apps erlauben
Abschnitt betitelt „Mobile Apps erlauben“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.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) "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>.
Anhänge
Abschnitt betitelt „Anhänge“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 ), ),)Turnstile
Abschnitt betitelt „Turnstile“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 vonGET /v1/public-config(zwischengespeichert, also nur einmal geladen), und das Widget läuft als Inline-HTML-Seite mit der URLhttps://mobile.frontmail.dev– von dieser Adresse wird nichts geladen.widget.html()– die Seite mit dem Widget. Sie sendet Nachrichten an die JavaScript-BridgeFrontmailTurnstile(Turnstile.ANDROID_BRIDGE_NAME).Turnstile.parseMessage(data)– akzeptiert nur genau die Nachrichten, die die Seite sendet; liefertToken,Expired,Errorodernull.widget.navigationDecision(url, isForMainFrame)– der Navigationsschutz:ALLOW,OPEN_EXTERNALLY(Links im Widget) oderBLOCK.
@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.
Eigener Site-Key
Abschnitt betitelt „Eigener Site-Key“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).
getStatus
Abschnitt betitelt „getStatus“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.
Wiederholungen und Idempotenz
Abschnitt betitelt „Wiederholungen und Idempotenz“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.
Dynamische Empfänger
Abschnitt betitelt „Dynamische Empfänger“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.
Sicherheit
Abschnitt betitelt „Sicherheit“- 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.