Salta ai contenuti

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.

app/build.gradle.kts
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.

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.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…", // 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>.

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
),
),
)

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 da GET /v1/public-config (con cache, quindi viene caricata una volta sola) e il widget gira come pagina HTML inline con l’URL https://mobile.frontmail.dev – da quell’indirizzo non viene caricato nulla.
  • widget.html() – la pagina con il widget. Invia messaggi al bridge JavaScript FrontmailTurnstile (Turnstile.ANDROID_BRIDGE_NAME).
  • Turnstile.parseMessage(data) – accetta solo i messaggi esatti inviati dalla pagina; restituisce Token, Expired, Error o null.
  • widget.navigationDecision(url, isForMainFrame) – il controllo della navigazione: ALLOW, OPEN_EXTERNALLY (link all’interno del widget) o 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)
}

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.

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).

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.

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.

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.

  • 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 Origin vengono 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.