Aller au contenu

Android (Kotlin)

dev.frontmail:frontmail permet à une application Android (ou à n’importe quelle application Kotlin/JVM) d’envoyer des e-mails à partir de vos modèles avec la clé publique – sans backend à vous. C’est une simple bibliothèque Kotlin avec une API suspend ; ses seules dépendances sont kotlinx-coroutines-core et kotlinx-serialization-json.

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

L’application nécessite Kotlin 2.2 ou plus récent et la permission INTERNET :

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

Code source et versions : github.com/frontmail-dev/frontmail-kotlin.

Les applications natives n’envoient pas d’en-tête Origin ni Referer : les requêtes de l’application sont donc refusées (que vous ayez défini des sites autorisés ou non) avec 403 origin_not_allowed tant que vous n’avez pas activé Sécurité → Applications mobiles → Autoriser les applications mobiles dans le tableau de bord et cliqué sur Enregistrer. Voir Paramètres de sécurité.

Les requêtes provenant de sites web restent comparées à la liste : l’option n’affaiblit donc pas la protection de vos formulaires web. L’option est nécessaire même avec une liste vide.

Créez un seul client et réutilisez-le (il est thread-safe). send est une fonction suspend : appelez-la depuis une coroutine, p. ex. lifecycleScope ou 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…", // facultatif
)
state.value = if (result.status == AcceptedStatus.HELD) "Bien reçu – il sera transmis sous peu." else "Merci !"
} catch (e: FrontmailException) {
state.value = e.message
}
}

params accepte des chaînes, des nombres, des booléens, null, des maps et des listes imbriquées, des tableaux et des JsonElement ; il existe aussi une surcharge qui prend un JsonObject. Sans serviceId, c’est le service du modèle qui est utilisé.

result.status vaut QUEUED (accepté pour livraison) ou HELD (accepté et en attente de crédits). result.statusToken vous permet de lire plus tard le statut de livraison.

Pour envoyer dans une langue du modèle, passez SendOptions(locale = …) avec une balise BCP 47 – Locale.getDefault().toLanguageTag() donne la langue de l’appareil. Une langue inconnue se rabat sur la même langue de base, puis sur la langue par défaut du modèle ; result.locale est la langue utilisée :

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

Options du client :

Option Par défaut Signification
apiUrl https://api.frontmail.dev URL de base de l’API
retry RetryPolicy() (3 nouvelles tentatives) RetryPolicy(retries, baseDelayMillis, maxDelayMillis) ; RetryPolicy.NONE désactive les nouvelles tentatives
timeoutMillis 15000 délai maximal d’une tentative

Une clé privée (sk_…) lève une FrontmailException de code private_key_in_browser dès le constructeur – une application ne peut contenir que la clé publique.

Chaque requête porte l’en-tête X-Frontmail-Client: frontmail-kotlin/<version>.

Si le modèle autorise les pièces jointes dynamiques, passez-les dans SendOptions :

frontmail.send(
"tpl_job_application",
mapOf("name" to name),
options = SendOptions(
attachments = listOf(
Attachment.fromBytes("cv.pdf", "application/pdf", bytes),
Attachment.Upload("upl_…"), // uploadé au préalable via POST /v1/uploads
),
),
)

Si le modèle exige Turnstile, affichez le widget Cloudflare dans une WebView et passez le jeton à send. Le SDK n’a pas de dépendance Android : il vous fournit les briques et c’est vous qui câblez la WebView :

  • Turnstile.sharedWidget() – la clé mobile partagée de Frontmail. Vous n’avez pas besoin de clé de site : le SDK la récupère depuis GET /v1/public-config (mise en cache, elle n’est donc chargée qu’une fois) et le widget s’exécute dans une page HTML intégrée à l’URL https://mobile.frontmail.dev – rien n’est chargé depuis cette adresse.
  • widget.html() – la page contenant le widget. Elle envoie des messages au pont JavaScript FrontmailTurnstile (Turnstile.ANDROID_BRIDGE_NAME).
  • Turnstile.parseMessage(data) – n’accepte que les messages exacts envoyés par la page ; renvoie Token, Expired, Error ou null.
  • widget.navigationDecision(url, isForMainFrame) – le garde de navigation : ALLOW, OPEN_EXTERNALLY (liens à l’intérieur du widget) ou 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)
}

Envoyez ensuite le jeton avec la turnstileKey du widget :

val widget = Turnstile.sharedWidget()
webView.showTurnstile(widget, onToken = { token = it }, onExpire = { token = null })
// à l’envoi :
try {
frontmail.send(
"tpl_contact",
mapOf("email" to email, "message" to message),
options = SendOptions(turnstileToken = token, turnstileKey = widget.turnstileKey),
)
} finally {
// Un jeton ne sert qu’une fois – demandez-en un nouveau pour la tentative suivante.
token = null
webView.evaluateJavascript(Turnstile.RESET_SCRIPT, null)
}

Un jeton est valable environ 5 minutes et ne peut être utilisé qu’une fois : réinitialisez le widget après chaque envoi, réussi ou non. Le widget normal nécessite une WebView d’environ 300 × 70 dp (compact : 150 × 140 dp).

Les requêtes de l’application n’ont pas d’en-tête Origin : Frontmail vérifie donc leurs jetons avec sa clé secrète mobile partagée. Vous n’avez pas besoin de vos propres clés Turnstile pour cela – avec Autoriser les applications mobiles activé, vous pouvez activer le CAPTCHA sur les modèles de l’application même si votre organisation n’a pas encore de clés.

L’exemple complet (y compris Jetpack Compose et un pont limité à l’origine de la page avec androidx.webkit) se trouve dans le README du SDK.

Pour utiliser plutôt votre propre widget Turnstile, créez-le avec TurnstileWidget.own :

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

widget.turnstileKey vaut alors TurnstileKey.ORG : le jeton est envoyé avec turnstile_key: "org" et Frontmail le vérifie avec la clé secrète de Sécurité → Protection contre les bots (Turnstile).

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

Le jeton circule dans l’en-tête X-Frontmail-Status-Token, jamais dans l’URL.

Les erreurs réseau, les délais dépassés, les 5xx et les 429 sont relancés automatiquement (en respectant Retry-After ; une attente de plus de 60 secondes n’est pas relancée), et toutes les tentatives d’un même envoi réutilisent la même clé d’idempotence : une nouvelle tentative n’envoie donc jamais l’e-mail deux fois. Les autres erreurs 4xx ne sont jamais relancées. Annuler la coroutine arrête immédiatement la requête. Voir Nouvelles tentatives et idempotence.

Chaque échec est une FrontmailException avec code, message, status (null pour les erreurs réseau), docsUrl, details et retryAfter, ainsi que les tests isAuth, isValidation, isRateLimit, isInsufficientCredits et isNetwork. Les codes côté client sont network_error, timeout, invalid_response et private_key_in_browser. La façon de traiter chaque code est décrite dans Gestion des erreurs.

Si les champs To/CC/BCC d’un modèle utilisent des paramètres, un envoi avec la clé publique a un contenu verrouillé : le modèle doit exiger Turnstile, les champs d’expéditeur doivent être fixes, les paramètres ne peuvent pas contenir de HTML, les liens doivent pointer vers des domaines autorisés et chaque destinataire reçoit un nombre limité d’e-mails par jour.

  • Ne placez jamais que la clé publique (pk_…) dans une application. Tout ce qui se trouve dans une application peut être extrait : une clé privée y serait comme publiée. Voir Clés publiques et privées.
  • Avec Autoriser les applications mobiles activé, les requêtes sans en-tête Origin sont acceptées de n’importe où – tout script qui connaît votre clé publique peut les envoyer. Appuyez-vous sur le rate limit par IP, sur Turnstile pour chaque modèle utilisé par l’application et sur la liste de blocage.