iOS i platformy Apple (Swift)
Pakiet Swift Frontmail pozwala aplikacji na iOS (albo dowolnej platformie Apple) wysyłać e-maile
przez twoje szablony z kluczem publicznym – bez własnego backendu. Nie ma zależności, korzysta
z async/await i kompiluje się w trybie języka Swift 6.
Wymagania: Swift 5.9+ (Xcode 15+); iOS 15, macOS 12, tvOS 15, watchOS 8, visionOS 1.
Instalacja
Dział zatytułowany „Instalacja”Xcode: File → Add Package Dependencies… → wpisz
https://github.com/frontmail-dev/frontmail-swift → dodaj bibliotekę Frontmail do targetu
aplikacji.
Package.swift:
dependencies: [ .package(url: "https://github.com/frontmail-dev/frontmail-swift", from: "0.1.0"),],targets: [ .target(name: "MyApp", dependencies: [.product(name: "Frontmail", package: "frontmail-swift")]),]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.
Wysyłka
Dział zatytułowany „Wysyłka”Utwórz klienta raz (jest niemutowalny i bezpieczny wątkowo) i wywołaj send:
import Frontmail
let frontmail = try Frontmail(publicKey: "pk_4f2a…")
let result = try await frontmail.send( templateID: "tpl_contact", params: ["email": "jan@example.com", "message": "Dzień dobry!", "qty": 2, "items": [["sku": "A-1"]]], serviceID: "svc_01J9…" // opcjonalne – nil użyje domyślnej usługi szablonu)if result.status == .held { // Otrzymaliśmy – wkrótce ją dostarczymy (organizacja czeka na kredyty).}send zwraca SendResult z messageID, status (.queued albo .held) i statusToken.
Inicjalizator rzuca błąd, gdy przekażesz klucz prywatny (zobacz Bezpieczeństwo).
Opcje inicjalizatora:
let frontmail = try Frontmail( publicKey: "pk_4f2a…", apiURL: URL(string: "https://api.frontmail.dev")!, // domyślne retry: RetryPolicy(maxRetries: 3, baseDelay: 0.3, maxDelay: 10), // domyślne; .disabled = bez ponawiania timeout: 15, // sekundy na jedną próbę (domyślnie) session: .shared // URLSession (domyślnie))Każde żądanie niesie nagłówek X-Frontmail-Client: frontmail-swift/<version>.
Parametry
Dział zatytułowany „Parametry”Parametry to [String: FrontmailValue] – wartość JSON, którą zapisujesz zwykłymi literałami (ciągi
znaków, liczby, wartości logiczne, nil, tablice i słowniki). Dla wartości ze zmiennych używaj
przypadków enuma: ["email": .string(email)].
Działa też każda wartość Encodable, która koduje się do obiektu JSON. Nazwy właściwości są
wysyłane bez zmian (do zmiany nazw użyj CodingKeys), a daty jako ciągi ISO-8601:
struct Contact: Encodable { let email: String let message: String}
try await frontmail.send(templateID: "tpl_contact", params: Contact(email: email, message: message))Załączniki i inne opcje
Dział zatytułowany „Załączniki i inne opcje”SendOptions przenosi token Turnstile, załączniki, jawny klucz idempotencji i język szablonu:
try await frontmail.send( templateID: "tpl_order", params: ["order": "1042"], options: SendOptions( turnstileToken: token, attachments: [ .data(pdfData, filename: "invoice.pdf", contentType: "application/pdf"), .upload(id: "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). Język urządzenia weźmiesz z Locale.preferredLanguages.first (np. de-AT) –
Locale.current.identifier zawiera _ 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:
let result = try await frontmail.send( templateID: "tpl_contact", params: ["name": "Jan"], options: SendOptions(locale: Locale.preferredLanguages.first))print(result.locale ?? "default") // "de"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:
let status = try await frontmail.status(messageID: result.messageID, statusToken: result.statusToken)print(status.status) // .queued, .sending, .sent, .delivered, .bounced, … albo .unknown("…")print(status.events.map(\.type)) // ["accepted", "sent", …]Turnstile
Dział zatytułowany „Turnstile”Jeśli szablon wymaga Turnstile, pokaż widget Cloudflare w WKWebView
i przekaż token do send. Nie potrzebujesz site key: domyślnie używany jest wspólny mobilny
klucz Frontmail. Turnstile.widget(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 web view jest w twojej aplikacji:
| API | Co robi |
|---|---|
Turnstile.widget(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 script message handlera frontmailTurnstile |
Turnstile.parseMessage(_:) |
waliduje wiadomość → .token(String), .expired, .error(code:) |
Turnstile.navigationAction(url:baseURL:isTopFrame:) |
strażnik nawigacji: .allow, .cancel, .openExternally(URL) |
Turnstile.isTrustedMessageOrigin(_:baseURL:) |
przyjmuje wiadomości tylko ze strony widgetu |
Turnstile.resetScript |
JavaScript, który resetuje widget, żeby uzyskać nowy token |
Kompletny widok SwiftUI – skopiuj go do swojej aplikacji:
import SwiftUIimport WebKitimport Frontmail
struct TurnstileView: UIViewRepresentable { let widget: Turnstile.Widget var theme: Turnstile.Theme = .auto var size: Turnstile.Size = .normal /// Zmiana wartości resetuje widget i daje nowy token (tokenu można użyć tylko raz). var resetID = 0 let onMessage: (Turnstile.Message) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.websiteDataStore = .nonPersistent() config.preferences.javaScriptCanOpenWindowsAutomatically = false config.userContentController.add(context.coordinator, name: Turnstile.messageHandlerName)
let webView = WKWebView(frame: .zero, configuration: config) webView.navigationDelegate = context.coordinator webView.uiDelegate = context.coordinator webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.isScrollEnabled = false context.coordinator.resetID = resetID webView.loadHTMLString(Turnstile.html(siteKey: widget.siteKey, theme: theme, size: size), baseURL: widget.baseURL) return webView }
func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.parent = self if context.coordinator.resetID != resetID { context.coordinator.resetID = resetID webView.evaluateJavaScript(Turnstile.resetScript) } }
static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) { webView.configuration.userContentController.removeScriptMessageHandler(forName: Turnstile.messageHandlerName) }
@MainActor final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate { var parent: TurnstileView var resetID = 0
init(_ parent: TurnstileView) { self.parent = parent }
// Z aplikacją może rozmawiać tylko osadzona strona (główna ramka z adresem baseURL). func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { guard message.frameInfo.isMainFrame, Turnstile.isTrustedMessageOrigin(message.frameInfo.request.url, baseURL: parent.widget.baseURL), let parsed = Turnstile.parseMessage(message.body) else { return } parent.onMessage(parsed) }
// Główna ramka zostaje na widgecie; linki z niej otwierają się w przeglądarce systemowej. func webView( _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void ) { switch Turnstile.navigationAction( url: navigationAction.request.url, baseURL: parent.widget.baseURL, isTopFrame: navigationAction.targetFrame?.isMainFrame ) { case .allow: decisionHandler(.allow) case .cancel: decisionHandler(.cancel) case let .openExternally(url): decisionHandler(.cancel) UIApplication.shared.open(url) } }
// window.open / target="_blank": nigdy nie twórz drugiego web view. func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { if case let .openExternally(url) = Turnstile.navigationAction( url: navigationAction.request.url, baseURL: parent.widget.baseURL, isTopFrame: true ) { UIApplication.shared.open(url) } return nil } }}Użyj go w formularzu: pobierz token, wyślij, a potem zresetuj widget:
struct ContactView: View { let frontmail: Frontmail @State private var widget: Turnstile.Widget? @State private var token: String? @State private var resetID = 0 @State private var email = "" @State private var message = "" @State private var info = ""
var body: some View { Form { TextField("Email", text: $email) TextField("Message", text: $message) if let widget { TurnstileView(widget: widget, size: .flexible, resetID: resetID) { event in switch event { case let .token(t): token = t case .expired: token = nil case let .error(code): info = "Błąd Turnstile \(code ?? "")" } } .frame(height: Turnstile.Size.flexible.recommendedHeight) } Button("Wyślij") { Task { await submit() } }.disabled(token == nil) Text(info) } .task { do { widget = try await Turnstile.widget(client: frontmail) } catch { info = "\(error)" } } }
private func submit() async { guard let widget, let token else { return } // Token działa tylko raz – poproś o nowy niezależnie od wyniku. defer { self.token = nil; resetID += 1 } do { let result = try await frontmail.send( templateID: "tpl_contact", params: ["email": .string(email), "message": .string(message)], options: SendOptions(turnstileToken: token, turnstileKey: widget.turnstileKey) ) info = result.status == .held ? "Otrzymaliśmy – wkrótce ją dostarczymy." : "Dziękujemy!" } catch let error as FrontmailError { info = error.message } catch { info = "\(error)" } }}Web view jest ograniczony do widgetu: ładuje tylko osadzoną stronę (baseURL) i challenge
Cloudflare, linki wewnątrz widgetu otwierają się w przeglądarce systemowej, nie da się otworzyć
drugiego okna, a do twojego kodu docierają tylko wiadomości ze strony widgetu. Ten sam
Coordinator działa w UIKit.
Żą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.
Własny site key
Dział zatytułowany „Własny site key”Żeby zamiast tego użyć własnego widgetu Turnstile, utwórz widget samodzielnie:
let widget = Turnstile.Widget.custom(siteKey: "YOUR_TURNSTILE_SITE_KEY", baseURL: URL(string: "https://example.com")!)Jego turnstileKey to .org, więc SendOptions(turnstileToken: token, turnstileKey: widget.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. Anulowanie
wywołującego Task natychmiast przerywa żądanie z kodem aborted. Zobacz
Ponawianie i idempotencja.
Wszystko rzuca FrontmailError z code, message, status (nil 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; grupują je sprawdzenia isAuth,
isValidation, isRateLimit, isInsufficientCredits, isNetwork i isCancelled:
do { try await frontmail.send(templateID: "tpl_contact", params: params)} catch let error as FrontmailError where error.isRateLimit { show("Za dużo wiadomości – spróbuj ponownie za \(Int(error.retryAfter ?? 60)) s.")} catch let error as FrontmailError where error.code == .originNotAllowed { assertionFailure("Włącz w panelu Bezpieczeństwo → Aplikacje mobilne → Zezwól na aplikacje mobilne.")} catch let error as FrontmailError { show(error.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, samo nie zbiera żadnych danych i nie używa API wymagających podania
powodu (required-reason APIs) – tak deklaruje jego PrivacyInfo.xcprivacy. To, co wysyłają twoje
szablony (np. adres e-mail), to dane zbierane przez twoją aplikację: zadeklaruj je w manifeście
prywatności aplikacji i w informacjach o prywatności w App Store.