Przejdź do głównej zawartości

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.

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")]),
]

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

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"

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", …]

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 SwiftUI
import WebKit
import 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.

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

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.

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