Flutter i Dart
Pakiet frontmail pozwala aplikacji we Flutterze wysyłać e-maile przez twoje szablony z kluczem
publicznym – bez własnego backendu. To czysty Dart (jedyną zależnością jest package:http), więc
ten sam kod działa na Androidzie, iOS, macOS, Windowsie, Linuksie, w przeglądarce i w programach
w Darcie.
Wymagania: Dart 3.5+ (Flutter 3.24+).
Instalacja
Dział zatytułowany „Instalacja”flutter pub add frontmailPakiet: pub.dev/packages/frontmail. Kod źródłowy i wydania: github.com/frontmail-dev/frontmail-flutter.
Buildy release na Androida wymagają uprawnienia INTERNET w
android/app/src/main/AndroidManifest.xml (Flutter dodaje je sam tylko do buildów debug):
<uses-permission android:name="android.permission.INTERNET"/>Na macOS dodaj entitlement com.apple.security.network.client do obu plików:
macos/Runner/DebugProfile.entitlements i macos/Runner/Release.entitlements.
Zezwól na aplikacje mobilne
Dział zatytułowany „Zezwól na aplikacje mobilne”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.
Flutter web działa w przeglądarce, która zawsze wysyła Origin: zamiast przełącznika dodaj
origin swojej aplikacji webowej do dozwolonych domen.
Wysyłka
Dział zatytułowany „Wysyłka”Utwórz klienta raz i współdziel go (top-level final, provider, twoje DI), a potem wywołuj send:
import 'package:frontmail/frontmail.dart';
final frontmail = Frontmail(publicKey: 'pk_4f2a…');
final result = await frontmail.send( templateId: 'tpl_contact', params: {'email': 'jan@example.com', 'message': 'Dzień dobry!', 'qty': 2, 'items': [{'sku': 'A-1'}]}, serviceId: 'svc_01J9…', // opcjonalne – null użyje domyślnej usługi szablonu);if (result.status == DeliveryStatus.held) { // Otrzymaliśmy – wkrótce ją dostarczymy (organizacja czeka na kredyty).}send zwraca SendResult z messageId, status (queued albo held) i statusToken.
Konstruktor rzuca błąd, gdy przekażesz klucz prywatny (zobacz Bezpieczeństwo).
Opcje konstruktora:
final frontmail = Frontmail( publicKey: 'pk_4f2a…', apiUrl: Uri.parse('https://api.frontmail.dev'), // domyślne retry: const RetryPolicy(maxRetries: 3), // domyślne; RetryPolicy.disabled = bez ponawiania timeout: const Duration(seconds: 15), // na jedną próbę (domyślnie) httpClient: myClient, // dowolny Client z package:http, np. cupertino_http albo cronet_http);close() zamyka klienta HTTP, którego SDK utworzyło samo; klient przekazany przez ciebie zostaje
otwarty. Każde żądanie niesie nagłówek X-Frontmail-Client: frontmail-flutter/<version>.
Parametry
Dział zatytułowany „Parametry”Parametry to Map<String, Object?> z wartościami JSON: String, num, bool, null, listy
i mapy. Wartości DateTime są wysyłane jako ciągi ISO-8601 w UTC, a obiekty z metodą toJson()
(np. z json_serializable albo freezed) są kodowane za jej pomocą:
await frontmail.send(templateId: 'tpl_contact', params: contact.toJson());Wartości, których nie da się zapisać jako JSON (double.nan, obiekt bez toJson()), rzucają
invalid_template_params, zanim cokolwiek zostanie wysłane.
Załączniki i inne opcje
Dział zatytułowany „Załączniki i inne opcje”SendOptions przenosi token Turnstile, załączniki, język szablonu, jawny klucz idempotencji
i wyzwalacz przerwania:
await frontmail.send( templateId: 'tpl_order', params: {'order': '1042'}, options: SendOptions( turnstileToken: token, attachments: [ Attachment.bytes(pdfBytes, filename: 'faktura.pdf', contentType: 'application/pdf'), const Attachment.upload('upl_…'), // przesłane wcześniej przez POST /v1/uploads ], ),);SendOptions(locale: …) wysyła e-mail w jednym z języków szablonu
(znacznik BCP 47). Użyj Localizations.localeOf(context).toLanguageTag() (np. de-AT) –
Locale.toString() używa _ i zostanie odrzucony jako invalid_body. 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:
final result = await frontmail.send( templateId: 'tpl_contact', params: {'name': 'Jan'}, options: SendOptions(locale: Localizations.localeOf(context).toLanguageTag()),);print(result.locale ?? 'default'); // "de"Future w Darcie nie da się anulować, więc send i status przyjmują zamiast tego abortTrigger:
kiedy ta future się zakończy – np. w dispose() – żądanie albo oczekiwanie przed ponowieniem
zostaje przerwane z kodem aborted:
final abort = Completer<void>();frontmail.send(templateId: 'tpl_contact', options: SendOptions(abortTrigger: abort.future));// późniejabort.complete();Status doręczenia
Dział zatytułowany „Status doręczenia”statusToken z send pozwala aplikacji odczytać status tej wiadomości. SDK wysyła go w nagłówku
X-Frontmail-Status-Token, nigdy w URL-u:
final status = await frontmail.status(messageId: result.messageId, statusToken: result.statusToken);print(status.status); // queued, sending, sent, delivered, bounced, … (nieznane wartości są zachowywane)print(status.events.map((e) => e.type)); // (accepted, sent, …)Turnstile
Dział zatytułowany „Turnstile”Jeśli szablon wymaga Turnstile, pokaż widget Cloudflare w web view
z webview_flutter i przekaż token do send.
Nie potrzebujesz site key: domyślnie używany jest wspólny mobilny klucz Frontmail.
Turnstile.site(client:) pobiera go z GET /v1/public-config
(z cache w pamięci, 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.
SDK daje ci gotowe elementy, a sam widget jest w twojej aplikacji (dzięki temu SDK pozostaje czystym Dartem):
| API | Co robi |
|---|---|
Turnstile.site(client:) |
site key + URL strony + turnstileKey, z którym wysyłasz tokeny |
Turnstile.html(siteKey:, theme:, size:, action:, language:) |
strona z widgetem; wysyła wiadomości do kanału JavaScript FrontmailTurnstile |
Turnstile.parseMessage(message) |
waliduje wiadomość → TurnstileToken, TurnstileExpired, TurnstileError |
Turnstile.navigationAction(url:, baseUrl:, isTopFrame:) |
strażnik nawigacji: TurnstileAllow, TurnstileCancel, TurnstileOpenExternally |
Turnstile.resetScript |
JavaScript, który resetuje widget, żeby uzyskać nowy token |
Kompletny widget dla Androida, iOS i macOS – dodaj webview_flutter i url_launcher
(flutter pub add webview_flutter url_launcher) i skopiuj go do swojej aplikacji:
import 'package:flutter/material.dart';import 'package:frontmail/frontmail.dart';import 'package:url_launcher/url_launcher.dart';import 'package:webview_flutter/webview_flutter.dart';
class TurnstileView extends StatefulWidget { const TurnstileView({ super.key, required this.site, required this.onMessage, this.theme = TurnstileTheme.auto, this.size = TurnstileSize.flexible, this.resetId = 0, });
final TurnstileSite site; final ValueChanged<TurnstileMessage> onMessage; final TurnstileTheme theme; final TurnstileSize size;
/// Zmiana wartości resetuje widget i daje nowy token (tokenu można użyć tylko raz). final int resetId;
@override State<TurnstileView> createState() => _TurnstileViewState();}
class _TurnstileViewState extends State<TurnstileView> { late final WebViewController _controller;
@override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(Colors.transparent) ..addJavaScriptChannel( Turnstile.javaScriptChannelName, onMessageReceived: (message) { final parsed = Turnstile.parseMessage(message.message); if (parsed != null && mounted) widget.onMessage(parsed); }, ) // Główna ramka zostaje na widgecie; linki z niej otwierają się w przeglądarce systemowej. ..setNavigationDelegate(NavigationDelegate( onNavigationRequest: (request) { final action = Turnstile.navigationAction( url: request.url, baseUrl: widget.site.baseUrl, isTopFrame: request.isMainFrame, ); switch (action) { case TurnstileAllow(): return NavigationDecision.navigate; case TurnstileCancel(): return NavigationDecision.prevent; case TurnstileOpenExternally(:final url): launchUrl(url, mode: LaunchMode.externalApplication); return NavigationDecision.prevent; } }, )) ..loadHtmlString( Turnstile.html(siteKey: widget.site.siteKey, theme: widget.theme, size: widget.size), baseUrl: widget.site.baseUrl.toString(), ); }
@override void didUpdateWidget(TurnstileView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.resetId != widget.resetId) _controller.runJavaScript(Turnstile.resetScript); }
@override Widget build(BuildContext context) => SizedBox( width: widget.size.recommendedWidth, height: widget.size.recommendedHeight, child: WebViewWidget(controller: _controller), );}Użyj go w formularzu: poczekaj na token, wyślij, a potem zresetuj widget:
class ContactForm extends StatefulWidget { const ContactForm({super.key, required this.frontmail});
final Frontmail frontmail;
@override State<ContactForm> createState() => _ContactFormState();}
class _ContactFormState extends State<ContactForm> { final _email = TextEditingController(); final _message = TextEditingController(); TurnstileSite? _site; String? _token; int _resetId = 0; String _info = '';
@override void initState() { super.initState(); Turnstile.site(client: widget.frontmail).then( (site) => setState(() => _site = site), onError: (Object e) => setState(() => _info = '$e'), ); }
@override void dispose() { _email.dispose(); _message.dispose(); super.dispose(); }
Future<void> _submit() async { final site = _site, token = _token; if (site == null || token == null) return; // Token działa tylko raz – poproś o nowy niezależnie od wyniku. setState(() { _token = null; _resetId++; }); try { final result = await widget.frontmail.send( templateId: 'tpl_contact', params: {'email': _email.text, 'message': _message.text}, options: SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey), ); setState(() => _info = result.status == DeliveryStatus.held ? 'Otrzymaliśmy – wkrótce ją dostarczymy.' : 'Dziękujemy!'); } on FrontmailException catch (e) { setState(() => _info = e.message); } }
@override Widget build(BuildContext context) { final site = _site; return Column(children: [ TextField(controller: _email, decoration: const InputDecoration(labelText: 'Email')), TextField(controller: _message, decoration: const InputDecoration(labelText: 'Message')), if (site != null) TurnstileView( site: site, resetId: _resetId, onMessage: (message) => setState(() { switch (message) { case TurnstileToken(:final token): _token = token; case TurnstileExpired(): _token = null; case TurnstileError(:final code): _info = 'Błąd Turnstile ${code ?? ''}'; } }), ), FilledButton(onPressed: _token == null ? null : _submit, child: const Text('Wyślij')), Text(_info), ]); }}Web view jest ograniczony do widgetu: ładuje tylko osadzoną stronę (baseUrl) i challenge
Cloudflare, linki wewnątrz widgetu otwierają się w przeglądarce systemowej, a parseMessage
przyjmuje tylko wiadomości dokładnie w takim kształcie, jaki wysyła strona widgetu.
Żą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.
webview_flutter nie ma implementacji dla webu. We Flutter web żądania mają nagłówek Origin,
więc Turnstile wymaga własnych kluczy: wyrenderuj widget na stronie
(np. przez HtmlElementView) i wyślij jego token z turnstileKey: TurnstileKey.org.
Własny site key
Dział zatytułowany „Własny site key”Żeby zamiast tego użyć własnego widgetu Turnstile, utwórz site samodzielnie:
final site = TurnstileSite.custom(siteKey: 'YOUR_TURNSTILE_SITE_KEY', baseUrl: Uri.parse('https://example.com'));Jego turnstileKey to TurnstileKey.org, więc
SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey) wysyła turnstile_key: "org",
a Frontmail weryfikuje token kluczem tajnym z Bezpieczeństwo → Ochrona przed botami (Turnstile).
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.
Ponawianie i idempotencja
Dział zatytułowany „Ponawianie i idempotencja”Błędy sieci, timeouty, 5xx i 429 są ponawiane automatycznie (domyślnie 3 ponowienia,
wykładniczy backoff z pełnym jitterem). Nagłówek Retry-After jest respektowany; jeśli wymaga
czekania dłużej niż 60 sekund, błąd jest rzucany od razu. Pozostałe odpowiedzi 4xx nigdy nie są
ponawiane. Wszystkie próby jednej wysyłki używają tego samego klucza idempotencji (losowego UUID,
chyba że przekażesz idempotencyKey), więc ponowienie nigdy nie wyśle e-maila dwa razy. Zobacz
Ponawianie i idempotencja.
Wszystko rzuca FrontmailException z code, message, status (null dla błędów po stronie
klienta), docsUrl, details i retryAfter. Kody klienta to network_error, timeout,
aborted, invalid_response i private_key_in_browser (stałe w FrontmailErrorCode); grupują je
sprawdzenia isAuth, isValidation, isRateLimit, isInsufficientCredits, isNetwork
i isAborted:
try { await frontmail.send(templateId: 'tpl_contact', params: params);} on FrontmailException catch (e) { if (e.isRateLimit) { show('Za dużo wiadomości – spróbuj ponownie za ${e.retryAfter?.inSeconds ?? 60} s.'); } else if (e.code == FrontmailErrorCode.originNotAllowed) { assert(false, 'Włącz w panelu Bezpieczeństwo → Aplikacje mobilne → Zezwól na aplikacje mobilne.'); } else { show(e.message); }}Jak obsłużyć poszczególne kody, opisuje strona Obsługa błędów.
Bezpieczeństwo
Dział zatytułowany „Bezpieczeństwo”- Do aplikacji wkładaj tylko klucz publiczny (
pk_…). Wszystko w paczce aplikacji da się wyciągnąć, więc klucz prywatny w niej jest tak dobry jak opublikowany. SDK odrzuca klucze zaczynające się odsk_błędemprivate_key_in_browser. Zobacz Klucze publiczne i prywatne. - Przy włączonym Zezwól na aplikacje mobilne żądania bez nagłówka
Originsą 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. - Szablon, który bierze odbiorcę z parametrów (np. potwierdzenie wysyłane na adres wpisany przez użytkownika), jest zablokowany: wymaga Turnstile, stałego nadawcy, krótkich parametrów bez HTML i linków tylko do twoich domen. Zobacz Dozwolone linki i blokada treści.
Prywatność
Dział zatytułowany „Prywatność”SDK nie śledzi użytkowników i samo nie zbiera żadnych danych. To, co wysyłają twoje szablony (np. adres e-mail), to dane zbierane przez twoją aplikację: zadeklaruj je w informacjach o prywatności w App Store i w formularzu Bezpieczeństwo danych w Google Play.