How to build a contact form in Flutter that sends email
A Flutter contact form that emails you every message – no backend, no SMTP password in the app. Works on Android, iOS, web and desktop with the Frontmail Dart package.
Updated:
You’ll add a contact screen to a Flutter app that sends every message to your inbox. The app only holds a public key; Frontmail sends the email through your own mailbox, so no SMTP password or server ends up in the app bundle.
You need: a free Frontmail account, a Flutter 3.24+ project and about fifteen minutes.
1. Connect your mailbox and create the template
- Email services → Add service: connect Gmail, Outlook, SMTP or a transactional provider, tick Make default, Verify & save.
- Templates → From gallery → Contact form → Use template. It expects
name,emailandmessage(required) andphone,topic(optional). - Copy the template ID (
tpl_…) from the Snippets tab.
2. Allow mobile apps
Apps don’t send an Origin header like websites do, so the API refuses them until you allow it:
Security → Mobile apps → turn on Allow mobile apps → Save.
Then open the template’s Settings → Protection and turn on Require CAPTCHA (Turnstile). Mobile apps use Frontmail’s shared Turnstile widget, so you don’t need any Cloudflare keys of your own.
3. Install the package
flutter pub add frontmail webview_flutter url_launcher
webview_flutter shows the Turnstile check. On Android, release builds also need
<uses-permission android:name="android.permission.INTERNET"/> in AndroidManifest.xml.
Copy the TurnstileView widget from the Turnstile section of the Flutter SDK documentation into
lib/turnstile_view.dart – it’s about sixty lines you own and can restyle.
4. Build the form
import 'package:flutter/material.dart';
import 'package:frontmail/frontmail.dart';
import 'turnstile_view.dart';
final frontmail = Frontmail(publicKey: 'pk_…'); // create once, share
class ContactScreen extends StatefulWidget {
const ContactScreen({super.key});
@override
State<ContactScreen> createState() => _ContactScreenState();
}
class _ContactScreenState extends State<ContactScreen> {
final _name = TextEditingController();
final _email = TextEditingController();
final _message = TextEditingController();
TurnstileSite? _site;
String? _token;
int _resetId = 0;
bool _sending = false;
String _info = '';
@override
void initState() {
super.initState();
Turnstile.site(client: frontmail).then((site) => setState(() => _site = site));
}
Future<void> _send() async {
final site = _site, token = _token;
if (site == null || token == null) return;
setState(() {
_sending = true;
_token = null; // a token works only once
_resetId++;
});
try {
await frontmail.send(
templateId: 'tpl_…',
params: {'name': _name.text, 'email': _email.text, 'message': _message.text},
options: SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey),
);
setState(() => _info = 'Thank you! We’ll get back to you soon.');
} on FrontmailException catch (e) {
setState(() => _info = e.message);
} finally {
setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final site = _site;
return Scaffold(
appBar: AppBar(title: const Text('Contact us')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
TextField(controller: _email, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress),
TextField(controller: _message, decoration: const InputDecoration(labelText: 'Message'), maxLines: 5),
const SizedBox(height: 16),
if (site != null)
TurnstileView(
site: site,
resetId: _resetId,
onMessage: (m) => setState(() => _token = m is TurnstileToken ? m.token : null),
),
FilledButton(onPressed: _token == null || _sending ? null : _send, child: const Text('Send')),
Text(_info),
],
),
);
}
}
The SDK retries network errors and server hiccups on its own with one idempotency key, so a flaky mobile connection never produces duplicate emails.
5. Test it
Run the app, fill in the form and tap Send. The email arrives in your inbox and appears in History in the dashboard.
origin_not_allowed– Allow mobile apps is off (or not saved).captcha_required– the Turnstile token is missing; wait until the check finishes.invalid_template_params– a required field is empty or the email address is invalid.
If you run out of credits, the message is accepted with the status
heldand sent automatically once you top up –result.status == DeliveryStatus.heldlets you tell the user.
Next steps
- Send the user a confirmation with the Auto-reply template from the gallery.
- Pass
SendOptions(locale: …)to send the email in the user’s language when the template has language variants.


