Przejdź do głównej zawartości

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

Okno terminala
flutter pub add frontmail

Pakiet: 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.

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.

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

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óźniej
abort.complete();

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

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.

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

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.

  • 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ę od sk_ błędem private_key_in_browser. 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.
  • 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.

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.