Android (Kotlin)
dev.frontmail:frontmail permette a un’app Android (o a qualsiasi app Kotlin/JVM) di inviare email
tramite i tuoi modelli con la chiave pubblica – senza bisogno di un tuo backend. È una semplice
libreria Kotlin con un’API suspend; le sue uniche dipendenze sono kotlinx-coroutines-core e
kotlinx-serialization-json.
Installazione
Sezione intitolata “Installazione”dependencies { implementation("dev.frontmail:frontmail:0.1.0")}L’app richiede Kotlin 2.2 o successivo e il permesso INTERNET:
<uses-permission android:name="android.permission.INTERNET" />Codice sorgente e release: github.com/frontmail-dev/frontmail-kotlin.
Consenti app mobile
Sezione intitolata “Consenti app mobile”Le app native non inviano l’header Origin né Referer, quindi le richieste dall’app vengono
rifiutate (che tu abbia impostato o meno dei siti consentiti) con
403 origin_not_allowed finché non attivi
Sicurezza → App mobile → Consenti app mobile nella dashboard e fai clic su Salva. Vedi
Impostazioni di sicurezza.
Le richieste dai siti web vengono comunque confrontate con la lista, quindi l’interruttore non indebolisce la protezione dei tuoi moduli web. L’interruttore è necessario anche con la lista vuota.
Crea un solo client e riutilizzalo (è thread-safe). send è una funzione suspend, quindi
chiamala da una coroutine, ad es. lifecycleScope o 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…", // facoltativo ) state.value = if (result.status == AcceptedStatus.HELD) "Ricevuto – verrà recapitato a breve." else "Grazie!" } catch (e: FrontmailException) { state.value = e.message }}params accetta stringhe, numeri, booleani, null, mappe e liste annidate, array e
JsonElement; esiste anche un overload che accetta un JsonObject. Senza serviceId viene usato
il servizio del modello.
result.status è QUEUED (accettato per il recapito) o HELD (accettato e in attesa di crediti).
result.statusToken ti permette di leggere lo stato di recapito in un secondo momento.
Per inviare in una lingua del modello, passa SendOptions(locale = …)
con un tag BCP 47 – Locale.getDefault().toLanguageTag() restituisce la lingua del dispositivo. Una
lingua sconosciuta ripiega sulla stessa lingua di base, poi sulla lingua predefinita del modello;
result.locale è la lingua usata:
val result = frontmail.send( "tpl_contact", mapOf("name" to name), options = SendOptions(locale = Locale.getDefault().toLanguageTag()),)Opzioni del client:
| Opzione | Predefinito | Significato |
|---|---|---|
apiUrl |
https://api.frontmail.dev |
URL di base dell’API |
retry |
RetryPolicy() (3 tentativi) |
RetryPolicy(retries, baseDelayMillis, maxDelayMillis), RetryPolicy.NONE disattiva i tentativi |
timeoutMillis |
15000 |
timeout di un singolo tentativo |
Una chiave privata (sk_…) lancia FrontmailException con codice private_key_in_browser già nel
costruttore – un’app può contenere solo la chiave pubblica.
Ogni richiesta porta l’header X-Frontmail-Client: frontmail-kotlin/<version>.
Allegati
Sezione intitolata “Allegati”Se il modello consente allegati dinamici, passali 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_…"), // caricato in precedenza tramite POST /v1/uploads ), ),)Turnstile
Sezione intitolata “Turnstile”Se il modello richiede Turnstile, mostra il widget di Cloudflare in una
WebView e passa il token a send. L’SDK non ha dipendenze da Android, quindi ti fornisce i pezzi
e sei tu a collegare la WebView:
Turnstile.sharedWidget()– la chiave mobile condivisa di Frontmail. Non ti serve una chiave del sito: l’SDK la recupera daGET /v1/public-config(con cache, quindi viene caricata una volta sola) e il widget gira come pagina HTML inline con l’URLhttps://mobile.frontmail.dev– da quell’indirizzo non viene caricato nulla.widget.html()– la pagina con il widget. Invia messaggi al bridge JavaScriptFrontmailTurnstile(Turnstile.ANDROID_BRIDGE_NAME).Turnstile.parseMessage(data)– accetta solo i messaggi esatti inviati dalla pagina; restituisceToken,Expired,Erroronull.widget.navigationDecision(url, isForMainFrame)– il controllo della navigazione:ALLOW,OPEN_EXTERNALLY(link all’interno del widget) oBLOCK.
@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)}Poi invia il token insieme al turnstileKey del widget:
val widget = Turnstile.sharedWidget()webView.showTurnstile(widget, onToken = { token = it }, onExpire = { token = null })
// all’invio:try { frontmail.send( "tpl_contact", mapOf("email" to email, "message" to message), options = SendOptions(turnstileToken = token, turnstileKey = widget.turnstileKey), )} finally { // I token sono monouso – prendine uno nuovo per il tentativo successivo. token = null webView.evaluateJavascript(Turnstile.RESET_SCRIPT, null)}Un token è valido per circa 5 minuti e può essere usato una sola volta: reimposta il widget
dopo ogni invio, riuscito o no. Il widget normale richiede una WebView di circa 300 × 70 dp
(compatto 150 × 140 dp).
Le richieste dall’app non hanno l’header Origin, quindi Frontmail verifica i loro token con la
sua chiave segreta mobile condivisa. Per questo non ti servono chiavi Turnstile tue – con
Consenti app mobile attivo puoi attivare il CAPTCHA sui modelli dell’app anche se la tua
organizzazione non ha ancora chiavi.
L’esempio completo (incluso Jetpack Compose e un bridge limitato all’origine della pagina con
androidx.webkit) si trova nel README dell’SDK.
La tua chiave del sito
Sezione intitolata “La tua chiave del sito”Per usare invece il tuo widget Turnstile, crealo con TurnstileWidget.own:
val widget = TurnstileWidget.own(siteKey = "YOUR_TURNSTILE_SITE_KEY", baseUrl = "https://example.com")widget.turnstileKey è allora TurnstileKey.ORG, quindi il token viene inviato con
turnstile_key: "org" e Frontmail lo verifica con la chiave segreta impostata in
Sicurezza → Protezione dai bot (Turnstile).
getStatus
Sezione intitolata “getStatus”val status = frontmail.getStatus(result) // oppure getStatus(messageId, statusToken)status.status // MessageStatus.SENT, DELIVERED, BOUNCED, …status.events // [MessageEvent(type = "accepted", at = "…"), …]Il token viaggia nell’header X-Frontmail-Status-Token, mai nell’URL.
Tentativi e idempotenza
Sezione intitolata “Tentativi e idempotenza”Gli errori di rete, i timeout, i 5xx e i 429 vengono ripetuti automaticamente (rispettando
Retry-After; un’attesa superiore a 60 secondi non viene ripetuta), e tutti i tentativi di un invio
riutilizzano la stessa chiave di idempotenza, quindi un nuovo tentativo non invia mai l’email due
volte. Gli altri errori 4xx non vengono mai ripetuti. Annullare la coroutine interrompe subito la
richiesta. Vedi Tentativi e idempotenza.
Ogni errore è una FrontmailException con code, message, status (null per gli errori di
rete), docsUrl, details e retryAfter, più i controlli isAuth, isValidation,
isRateLimit, isInsufficientCredits e isNetwork. I codici lato client sono network_error,
timeout, invalid_response e private_key_in_browser. Come gestire ciascun codice è spiegato in
Gestione degli errori.
Destinatari dinamici
Sezione intitolata “Destinatari dinamici”Se A/CC/CCN di un modello usano parametri, un invio con la chiave pubblica ha il contenuto bloccato: il modello deve richiedere Turnstile, i campi del mittente devono essere fissi, i parametri non possono contenere HTML, i link devono puntare a domini consentiti e ogni destinatario riceve un numero limitato di email al giorno.
Sicurezza
Sezione intitolata “Sicurezza”- In un’app metti sempre e solo la chiave pubblica (
pk_…). Tutto ciò che sta in un’app può essere estratto, quindi una chiave privata lì dentro è come se fosse pubblicata. Vedi Chiavi pubbliche e private. - Con Consenti app mobile attivo, le richieste senza header
Originvengono accettate da qualsiasi parte – qualunque script che conosca la tua chiave pubblica può inviarle. Affidati al rate limit per IP, a Turnstile su ogni modello usato dall’app e alla lista dei bloccati.