Przejdź do głównej zawartości

Android (Kotlin)

dev.frontmail:frontmail pozwala aplikacji na Androida (albo dowolnej aplikacji w Kotlinie/JVM) wysyłać e-maile przez twoje szablony z kluczem publicznym – bez własnego backendu. To zwykła biblioteka w Kotlinie z API opartym na suspend; jej jedyne zależności to kotlinx-coroutines-core i kotlinx-serialization-json.

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

Aplikacja wymaga Kotlina 2.2 lub nowszego oraz uprawnienia INTERNET:

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

Kod źródłowy i wydania: github.com/frontmail-dev/frontmail-kotlin.

Aplikacje natywne nie wysyłają nagłówka Origin ani Referer, więc żądania z aplikacji są odrzucane (niezależnie od tego, czy masz ustawione dozwolone domeny) z błędem 403 origin_not_allowed, dopóki nie włączysz w panelu Bezpieczeństwo → Aplikacje mobilne → Zezwól na aplikacje mobilne i nie klikniesz Zapisz. Zobacz Ustawienia bezpieczeństwa.

Żądania ze stron są nadal sprawdzane z listą, więc przełącznik nie osłabia ochrony twoich formularzy na stronach. Przełącznik jest wymagany nawet przy pustej liście.

Utwórz jednego klienta i używaj go wielokrotnie (jest bezpieczny wątkowo). send to funkcja suspend, więc wywołuj ją z korutyny, np. lifecycleScope albo 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…", // opcjonalne
)
state.value = if (result.status == AcceptedStatus.HELD) "Otrzymaliśmy – wkrótce ją dostarczymy." else "Dziękujemy!"
} catch (e: FrontmailException) {
state.value = e.message
}
}

params przyjmuje ciągi znaków, liczby, wartości logiczne, null, zagnieżdżone mapy i listy, tablice oraz JsonElementy; jest też przeciążenie przyjmujące JsonObject. Bez serviceId zostanie użyta usługa szablonu.

result.status to QUEUED (przyjęta do doręczenia) albo HELD (przyjęta i czeka na kredyty). result.statusToken pozwala później odczytać status doręczenia.

Żeby wysłać e-mail w jednym z języków szablonu, przekaż SendOptions(locale = …) ze znacznikiem BCP 47 – Locale.getDefault().toLanguageTag() zwraca język urządzenia. Nieznany język przechodzi na ten sam język bazowy, a potem na język domyślny szablonu; result.locale to użyty język:

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

Opcje klienta:

Opcja Domyślnie Znaczenie
apiUrl https://api.frontmail.dev Bazowy URL API
retry RetryPolicy() (3 ponowienia) RetryPolicy(retries, baseDelayMillis, maxDelayMillis), RetryPolicy.NONE wyłącza ponawianie
timeoutMillis 15000 timeout pojedynczej próby

Klucz prywatny (sk_…) powoduje FrontmailException z kodem private_key_in_browser już w konstruktorze – aplikacja może zawierać tylko klucz publiczny.

Każde żądanie niesie nagłówek X-Frontmail-Client: frontmail-kotlin/<version>.

Jeśli szablon dopuszcza dynamiczne załączniki, przekaż je w SendOptions:

frontmail.send(
"tpl_job_application",
mapOf("name" to name),
options = SendOptions(
attachments = listOf(
Attachment.fromBytes("cv.pdf", "application/pdf", bytes),
Attachment.Upload("upl_…"), // przesłane wcześniej przez POST /v1/uploads
),
),
)

Jeśli szablon wymaga Turnstile, pokaż widget Cloudflare w WebView i przekaż token do send. SDK nie zależy od Androida, więc daje ci gotowe elementy, a WebView podłączasz sam:

  • Turnstile.sharedWidget() – wspólny mobilny klucz Frontmail. Nie potrzebujesz site key: SDK pobiera go z GET /v1/public-config (z cache, więc tylko raz), a widget działa jako osadzona strona HTML z adresem https://mobile.frontmail.dev – nic nie jest pobierane z tego adresu.
  • widget.html() – strona z widgetem. Wysyła wiadomości do mostka JavaScript FrontmailTurnstile (Turnstile.ANDROID_BRIDGE_NAME).
  • Turnstile.parseMessage(data) – przyjmuje wyłącznie dokładnie te wiadomości, które wysyła strona; zwraca Token, Expired, Error albo null.
  • widget.navigationDecision(url, isForMainFrame) – strażnik nawigacji: ALLOW, OPEN_EXTERNALLY (linki wewnątrz widgetu) albo 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)
}

Następnie wyślij token razem z turnstileKey widgetu:

val widget = Turnstile.sharedWidget()
webView.showTurnstile(widget, onToken = { token = it }, onExpire = { token = null })
// przy wysyłce:
try {
frontmail.send(
"tpl_contact",
mapOf("email" to email, "message" to message),
options = SendOptions(turnstileToken = token, turnstileKey = widget.turnstileKey),
)
} finally {
// Token można użyć tylko raz – przy następnej próbie potrzebny jest nowy.
token = null
webView.evaluateJavascript(Turnstile.RESET_SCRIPT, null)
}

Token jest ważny około 5 minut i można go użyć tylko raz: po każdej wysyłce, udanej lub nie, zresetuj widget. Zwykły widget potrzebuje WebView o rozmiarze około 300 × 70 dp (kompaktowy 150 × 140 dp).

Żądania z aplikacji nie mają nagłówka Origin, więc Frontmail weryfikuje ich tokeny wspólnym mobilnym kluczem tajnym. Nie potrzebujesz do tego własnych kluczy Turnstile – przy włączonym Zezwól na aplikacje mobilne możesz włączyć CAPTCHA w szablonach używanych przez aplikację, nawet jeśli twoja organizacja nie ma jeszcze kluczy.

Pełny przykład (także z Jetpack Compose i mostkiem ograniczonym do originu strony przez androidx.webkit) znajdziesz w README SDK.

Żeby zamiast tego użyć własnego widgetu Turnstile, utwórz go przez TurnstileWidget.own:

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

widget.turnstileKey ma wtedy wartość TurnstileKey.ORG, więc token jest wysyłany z turnstile_key: "org", a Frontmail weryfikuje go kluczem tajnym z Bezpieczeństwo → Ochrona przed botami (Turnstile).

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

Token jest przesyłany w nagłówku X-Frontmail-Status-Token, nigdy w URL-u.

Błędy sieci, timeouty, 5xx i 429 są ponawiane automatycznie (z uwzględnieniem Retry-After; oczekiwanie dłuższe niż 60 sekund nie jest ponawiane), a wszystkie próby jednej wysyłki używają tego samego klucza idempotencji, więc ponowienie nigdy nie wyśle e-maila dwa razy. Pozostałe błędy 4xx nigdy nie są ponawiane. Anulowanie korutyny natychmiast przerywa żądanie. Zobacz Ponawianie i idempotencja.

Każdy błąd to FrontmailException z code, message, status (null dla błędów sieci), docsUrl, details i retryAfter, a do tego sprawdzeniami isAuth, isValidation, isRateLimit, isInsufficientCredits i isNetwork. Kody po stronie klienta to network_error, timeout, invalid_response i private_key_in_browser. Jak obsłużyć poszczególne kody, opisuje strona Obsługa błędów.

Jeśli pola Do/DW/UDW szablonu korzystają z parametrów, wysyłka z kluczem publicznym ma zablokowaną treść: szablon musi wymagać Turnstile, pola nadawcy muszą być stałe, parametry nie mogą zawierać HTML, linki muszą prowadzić do dozwolonych domen, a każdy odbiorca dostaje ograniczoną liczbę e-maili dziennie.

  • Do aplikacji wkładaj tylko klucz publiczny (pk_…). Wszystko w aplikacji da się wyciągnąć, więc klucz prywatny w niej jest tak dobry jak opublikowany. Zobacz Klucze publiczne i prywatne.
  • Przy włączonym Zezwól na aplikacje mobilne żądania bez nagłówka Origin są przyjmowane skądkolwiek – może je wysłać każdy skrypt, który zna twój klucz publiczny. Polegaj na limicie na IP, Turnstile w każdym szablonie używanym przez aplikację i liście blokad.