Cómo crear un formulario de contacto en iOS con Swift y SwiftUI
Un formulario de contacto en SwiftUI que te envía cada mensaje a través de tu propio buzón – sin backend y sin contraseña SMTP en la app. Usa el paquete de Swift de Frontmail.
Actualizado:
Vas a añadir un formulario de contacto a una app iOS con SwiftUI y el paquete de Swift de Frontmail. La app solo guarda una clave pública; Frontmail envía el correo a través de tu buzón, así que no hay contraseña SMTP en la app ni ningún servidor que mantener.
Necesitas: una cuenta gratuita de Frontmail, un proyecto de Xcode con SwiftUI y unos quince minutos.
1. Conecta tu buzón y crea la plantilla
- Servicios de correo → Añadir servicio: conecta Gmail, Outlook, SMTP o un proveedor transaccional, activa Marcar como predeterminado y pulsa Verificar y guardar.
- Plantillas → Desde la galería → Formulario de contacto → Usar plantilla.
Parámetros obligatorios:
name,email,message; opcionales:phone,topic. - Copia el ID de la plantilla (
tpl_…) de la pestaña Fragmentos.
2. Permite las apps móviles
- Seguridad → Apps móviles → Permitir apps móviles → Guardar. Las apps no envían
cabecera
Origin, así que hasta entonces la API respondeorigin_not_allowed. - Plantilla → Ajustes → Protección → Exigir CAPTCHA (Turnstile). Las apps usan el widget compartido de Turnstile de Frontmail; no necesitas claves de Cloudflare.
3. Añade el paquete
En Xcode elige File → Add Package Dependencies…, introduce
https://github.com/frontmail-dev/frontmail-swift y añade la librería Frontmail al target de
tu app. Después copia TurnstileView de la sección Turnstile de la documentación del SDK de iOS a
tu proyecto: un pequeño UIViewRepresentable alrededor de WKWebView que es tuyo.
4. Crea el formulario
import SwiftUI
import Frontmail
let frontmail = try! Frontmail(publicKey: "pk_…") // create once, share
struct ContactMessage: Encodable {
let name: String
let email: String
let message: String
}
struct ContactView: View {
@State private var name = ""
@State private var email = ""
@State private var message = ""
@State private var widget: Turnstile.Widget?
@State private var token: String?
@State private var resetID = 0
@State private var info = ""
var body: some View {
Form {
TextField("Name", text: $name)
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
TextField("Message", text: $message, axis: .vertical)
.lineLimit(4...8)
if let widget {
TurnstileView(widget: widget, size: .flexible, resetID: resetID) { event in
if case let .token(t) = event { token = t } else { token = nil }
}
.frame(height: Turnstile.Size.flexible.recommendedHeight)
}
Button("Send") { Task { await send() } }
.disabled(token == nil)
Text(info)
}
.task { widget = try? await Turnstile.widget(client: frontmail) }
}
private func send() async {
guard let widget, let token else { return }
defer { self.token = nil; resetID += 1 } // a token works only once
do {
_ = try await frontmail.send(
templateID: "tpl_…",
params: ContactMessage(name: name, email: email, message: message),
options: SendOptions(turnstileToken: token, turnstileKey: widget.turnstileKey)
)
info = "Thank you! We’ll get back to you soon."
} catch let error as FrontmailError {
info = error.message
} catch {
info = error.localizedDescription
}
}
}
Cualquier struct Encodable sirve como parámetros, así que el compilador comprueba los nombres de
los campos por ti. Los errores de red y las respuestas 5xx se reintentan con una misma clave de
idempotencia: nada de correos duplicados con una conexión inestable.
5. Pruébalo
Ejecuta la app en el simulador o en un dispositivo, rellena el formulario y pulsa Send. El correo llega a tu bandeja y aparece en Historial en el panel.
origin_not_allowed– Permitir apps móviles está desactivado o no se guardó.captcha_required– todavía no hay token de Turnstile; Send sigue desactivado hasta que termine la verificación.invalid_template_params– un campo obligatorio está vacío o el correo no es válido.
Cuando se te acaban los créditos, el mensaje se acepta con el estado
.heldy se envía automáticamente después de recargar: no se pierde nada.
Próximos pasos
- Confirma el mensaje al usuario con la plantilla Respuesta automática: gracias por tu mensaje de la galería.
- Envía
SendOptions(locale: Locale.preferredLanguages.first)para usar una variante de idioma de la plantilla en el idioma del usuario.


