---
title: "Flutter | Proxy Mail QA"
description: "Test email verification codes and magic links in a Flutter app's integration tests with disposable Proxy Mail QA inboxes: a small Dart helper, no packages."
url: https://proxymail.qa/docs/flutter
---

# Flutter

Add this file next to your integration tests, for example `integration_test/proxymail_qa.dart`. It uses only `dart:io` and `dart:convert`:

proxymail\_qa.dart

```dart
// Disposable inboxes for Flutter integration tests: create a test address,
// sign up with it in your app, then wait for the code or link from the email
// it gets, and check the email itself. Guide: https://proxymail.qa/docs/flutter
//
// The API key comes from the test run: flutter test --dart-define=
// PROXYMAIL_QA_API_KEY=... (never from the app's own configuration). Add
// --dart-define=PROXYMAIL_QA_RUN=$PROXYMAIL_QA_RUN to add the calls to a run
// (the GitHub Action sets it), and pass test: to name the test they belong to.
import 'dart:convert';
import 'dart:io';

/// An account checked out of the pool: nobody else gets it until it's returned or the hold runs out.
typedef CheckedOutAccount = ({String address, String? password, List<String> tags, String token});

class ProxyMailQAException implements Exception {
  ProxyMailQAException(this.status, this.message);
  final int status;
  final String message;
  @override
  String toString() => message;
}

class ProxyMailQA {
  ProxyMailQA({String? apiKey, this.baseUrl = 'https://api.proxymail.qa/v1', String? run, this.test})
      : apiKey = apiKey ?? const String.fromEnvironment('PROXYMAIL_QA_API_KEY'),
        run = run ?? const String.fromEnvironment('PROXYMAIL_QA_RUN') {
    if (this.apiKey.isEmpty) {
      throw StateError('Pass --dart-define=PROXYMAIL_QA_API_KEY=... to flutter test.');
    }
  }

  final String apiKey;
  final String baseUrl;
  final String run;
  final String? test;
  final HttpClient _http = HttpClient();

  /// A new test address, with a password for the sign-up form. [environment] (such as 'staging')
  /// records where it's used; [age] ('adult', 'under18', 'under13'…) and [country] ('GB') give the
  /// persona a date of birth and a country.
  Future<({String address, String? password})> createAddress({String? environment, String? age, String? country}) async {
    final body = await _send('POST', '/addresses', body: {
      'password': true,
      'persona': true,
      if (environment != null) 'environment': environment,
      if (age != null) 'age': age,
      if (country != null) 'country': country,
    });
    return (address: body['address'] as String, password: body['password'] as String?);
  }

  /// The code from the newest email received since [since], waiting up to 30 seconds.
  /// [unused] true skips a code already used, for a second code.
  Future<String> waitForCode(String address, DateTime since, {bool unused = false}) => _waitFor('code', address, since, unused: unused);

  /// The link from the newest email received since [since]: verify, magic_signin, invite, reset or any.
  /// [app] true asks for the link that opens the app (myapp://…) when the email also has a web one.
  Future<Uri> waitForLink(String address, DateTime since, {String type = 'verify', bool? app}) async =>
      Uri.parse(await _waitFor('link', address, since, type: type, app: app));

  /// How many emails arrived since [since], to check the app sends exactly one.
  Future<int> countEmails(String address, DateTime since) async =>
      (await _send('GET', '/addresses/${Uri.encodeComponent(address)}/count?since=${_time(since)}'))['count'] as int;

  /// The checks the newest email since [since] fails: SPF, DKIM, DMARC, links to localhost or plain
  /// http, a stated expiry, its size and more. Empty when it passes. [expectHost] is the domain its
  /// link should open, such as your app's Universal Link or App Link domain.
  Future<List<String>> checkEmail(String address, DateTime since, {String? expectHost}) async {
    final host = expectHost == null ? '' : '&expect_host=${Uri.encodeQueryComponent(expectHost)}';
    final body = await _send('GET', '/addresses/${Uri.encodeComponent(address)}/checks?since=${_time(since)}$host');
    return (body['failed'] as List).cast<String>();
  }

  /// Completes if no email arrives within [timeout] seconds (at most 30), such as for an unsubscribed
  /// user; throws, saying what came, as soon as one does.
  Future<void> expectNoEmail(String address, DateTime since, {int timeout = 30}) =>
      _send('GET', '/addresses/${Uri.encodeComponent(address)}/none?since=${_time(since)}&timeout=$timeout');

  /// A saved test account to sign in with, and its password: one with every tag given that no test
  /// has checked out, the one that got mail most recently first. null when none fits. Needs an API
  /// key allowed to read saved passwords.
  Future<({String address, String? password})?> signInAccount({List<String> tags = const [], String? environment}) async {
    try {
      final body = await _send('POST', '/addresses/sign-in', body: {'tags': tags, if (environment != null) 'environment': environment});
      return (address: body['address'] as String, password: body['password'] as String?);
    } on ProxyMailQAException catch (e) {
      if (e.status == 404) return null;
      rethrow;
    }
  }

  /// Borrows an account with every tag given (verified, premium…) that no other test has, for
  /// [minutes], with its saved password when the key may read it. Give it back with [returnAccount].
  Future<CheckedOutAccount> checkOutAccount(List<String> tags, {int minutes = 10}) async {
    final body = await _send('POST', '/addresses/checkout', body: {'tags': tags, 'minutes': minutes});
    final address = body['address'] as String;
    String? password;
    try {
      password = (await _send('GET', '/addresses/${Uri.encodeComponent(address)}/password'))['secret'] as String;
    } on ProxyMailQAException catch (e) {
      if (e.status != 403 && e.status != 404) rethrow;
    }
    final hold = body['checkout'] as Map<String, dynamic>;
    return (address: address, password: password, tags: (body['tags'] as List).cast<String>(), token: hold['token'] as String);
  }

  /// Gives a borrowed account back, recording what the test changed (tags to add and remove).
  Future<void> returnAccount(CheckedOutAccount account, {List<String> addTags = const [], List<String> removeTags = const []}) =>
      _send('POST', '/addresses/${Uri.encodeComponent(account.address)}/return',
          body: {'token': account.token, 'add_tags': addTags, 'remove_tags': removeTags});

  Future<String> _waitFor(String kind, String address, DateTime since, {String? type, bool? app, bool unused = false}) async {
    final query = 'since=${_time(since)}${type == null ? '' : '&type=$type'}${app == null ? '' : '&app=$app'}'
        '${unused ? '&unused=true' : ''}';
    final body = await _send('GET', '/addresses/${Uri.encodeComponent(address)}/$kind?$query');
    return body['value'] as String;
  }

  String _time(DateTime time) => Uri.encodeQueryComponent(time.toUtc().toIso8601String());

  Future<Map<String, dynamic>> _send(String method, String path, {Object? body}) async {
    final request = await _http.openUrl(method, Uri.parse('$baseUrl$path'));
    request.headers.set('Authorization', 'Bearer $apiKey');
    request.headers.contentType = ContentType.json;
    if (run.isNotEmpty) {
      request.headers.set('Proxy-Mail-QA-Run', run);
      // Percent-encoded: a test's name can hold characters headers can't.
      if (test != null) request.headers.set('Proxy-Mail-QA-Test', Uri.encodeComponent(test!));
    }
    if (body != null) request.write(jsonEncode(body));
    final response = await request.close().timeout(const Duration(seconds: 40));
    final json = jsonDecode(await response.transform(utf8.decoder).join()) as Map<String, dynamic>;
    if (response.statusCode >= 300) {
      // A wait that finds nothing says why: read `hint` before retrying.
      throw ProxyMailQAException(response.statusCode, [json['message'], json['hint']].whereType<String>().join(' '));
    }
    return json;
  }
}
```

A sign-up test:

integration\_test/sign\_up\_test.dart

```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;
import 'proxymail_qa.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('sign-up sends a working code', (tester) async {
    final qa = ProxyMailQA();
    final account = await qa.createAddress();
    app.main();
    await tester.pumpAndSettle();

    final submitted = DateTime.now();
    await tester.enterText(find.byKey(const Key('email')), account.address);
    await tester.enterText(find.byKey(const Key('password')), account.password!);
    await tester.tap(find.text('Sign up'));
    await tester.pumpAndSettle();

    final code = await qa.waitForCode(account.address, submitted);
    await tester.enterText(find.byKey(const Key('code')), code);
  });
}
```

Run it with your [API key](https://proxymail.qa/docs/api-keys):

Terminal window

```bash
flutter test integration_test --dart-define=PROXYMAIL_QA_API_KEY=$PROXYMAIL_QA_API_KEY
```

If the email has both a web link and one that opens the app (`myapp://…`), `qa.waitForLink(account.address, submitted, app: true)` gets the app one.

## Check the email, not just the code

```dart
expect(await qa.countEmails(account.address, submitted), 1);
expect(await qa.checkEmail(account.address, submitted, expectHost: 'links.example.com'), isEmpty);
```

`checkEmail` returns the checks the email fails: SPF, DKIM and DMARC, links to localhost or plain http, where its link goes (`expectHost`, the domain your app’s links use), a stated expiry, its size and more ([what each check means](https://proxymail.qa/docs/rest-api#checking-an-emails-sending-and-build)). `expectNoEmail(address, since)` passes only if nothing arrives, for “an unsubscribed user gets nothing”. After a resend, `waitForCode(address, submitted, unused: true)` skips the code already used.

`createAddress(environment: 'staging', age: 'under18', country: 'GB')` records which environment the account is for and gives its persona a date of birth and a country, for forms that ask.

## Sign in as a saved account, or borrow one

For a sign-in test, `signInAccount(tags: ['verified'])` returns a saved test account and its password, or `null` when none fits. When tests run in parallel, `checkOutAccount(['verified'])` lends one no other test has, with its password, until you give it back:

```dart
final account = await qa.checkOutAccount(['verified']);
try {
  // Sign in with account.address and account.password.
} finally {
  await qa.returnAccount(account);
}
```

Both read saved passwords, so the key needs **Can read saved passwords** (Project settings → API keys). Tag accounts with their state in the app to make pools: see [an account pool](https://proxymail.qa/docs/rest-api#an-account-pool-for-parallel-tests).

## A report on the pull request

With the [GitHub Action](https://proxymail.qa/docs/ci#a-report-on-the-pull-request) in the job, pass the run it starts like the key, and name each test, so the report says which test each email came from:

Terminal window

```bash
flutter test integration_test --dart-define=PROXYMAIL_QA_API_KEY=$PROXYMAIL_QA_API_KEY --dart-define=PROXYMAIL_QA_RUN=$PROXYMAIL_QA_RUN
```

```dart
final qa = ProxyMailQA(test: 'sign-up sends a working code');
```

## Opening a link

A widget test can’t open a link the way the phone would. To test a verify or magic link end to end, use [Maestro](https://proxymail.qa/docs/maestro) (`openLink`), or open it from your computer with `xcrun simctl openurl booted '<link>'` or `adb shell "am start -a android.intent.action.VIEW -d '<link>'"`. [Patrol](https://patrol.leancode.co) can also drive the phone from a Flutter test.

[Previous Android (Espresso)](https://proxymail.qa/docs/espresso.html) [Next Detox and Appium](https://proxymail.qa/docs/detox-appium.html)
