Skip to content

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

  1. Email services → Add service: connect Gmail, Outlook, SMTP or a transactional provider, tick Make default, Verify & save.
  2. Templates → From gallery → Contact form → Use template. It expects name, email and message (required) and phone, topic (optional).
  3. 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 held and sent automatically once you top up – result.status == DeliveryStatus.held lets 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.
All tutorials