feat(auth): codes go by mail, through the SMTP service an instance sets up

Email sign-in's codes are mailed over SMTP (pub.dev's mailer), through
whichever service the passwords name: emailSmtpHost, emailSmtpPort,
emailSmtpUser, emailSmtpPassword and emailFrom (EMAIL_SMTP_* and
EMAIL_FROM in deploy/.env, with EMAIL_SIGN_IN_PEPPER). TLS only:
STARTTLS, or TLS directly on 465. learn.halkyon.eu sends through Proton's
SMTP submission. Without a service, codes still go to the log in
development, and elsewhere there is no email sign-in.

The mail is plain text, in English or German: the code first and in the
subject, so it shows in the notification and the system can offer it,
the instance it signs in to, its 15 minutes, and that it can be ignored;
no links, no tracking, no HTML. A mail that couldn't be sent is a refusal
of its own (mailFailed): nothing of the request is kept, and the log
names the error's type only, as the service's answer may name the
address. A fake SMTP server on loopback drives the real mailer in the
tests: it delivers, and nothing goes to a server without TLS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-15 20:00:52 +02:00
commit 8075edb152
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 534 additions and 44 deletions

View file

@ -137,7 +137,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.info()`.
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
- **Switching devices' person** (the app's `switchToGitHub`, `switchToEmail`): swap the session directly, never through "signed out" (which would start another anonymous account), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so.
- **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (`lib/src/auth/email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail each time, no password: eight Crockford characters (`common/short_code.dart`, shared with connect codes), 15 minutes, 3 tries, a newer one replacing the older; at most 3 codes an hour per address and 10 per network. `emailCode.sendCode` answers alike for every address; `emailCode.verifyCode` (`requireLogin`) makes a new address the caller's (as linking GitHub does) or, for another person's, returns a switch token that `emailSignIn.signIn` (without a session) turns into a session for them. Refusals are `EmailSignInRefusedException` with a reason. Codes go to the server's log in development; elsewhere there is no email sign-in (`instance.info().emailSignIn`) until a mail service is set up.
- **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (`lib/src/auth/email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail each time, no password: eight Crockford characters (`common/short_code.dart`, shared with connect codes), 15 minutes, 3 tries, a newer one replacing the older; at most 3 codes an hour per address and 10 per network. `emailCode.sendCode` answers alike for every address; `emailCode.verifyCode` (`requireLogin`) makes a new address the caller's (as linking GitHub does) or, for another person's, returns a switch token that `emailSignIn.signIn` (without a session) turns into a session for them. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (`code_mail.dart`, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`instance.info().emailSignIn`). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would.
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, `feature/auth/data/`): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.

View file

@ -32,6 +32,26 @@ SESSION_KEY_HASH_PEPPER=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# Email sign-in (optional, ADR 0010): people sign in with a code mailed to their address,
# of which the instance keeps only a keyed hash. Off unless the pepper and all of the SMTP
# service's settings are set; the app then doesn't offer it.
# The pepper: NEVER change it once anybody signs in with an address, as every address's
# hash would be lost, and with it those people's way in.
# Generate: openssl rand -hex 32
EMAIL_SIGN_IN_PEPPER=
# The SMTP service codes go through, over TLS only (STARTTLS, or TLS directly on 465).
# Proton (a paid plan with an address on your own domain): smtp.protonmail.ch, 587, the
# address as the user, an SMTP token made for it in Proton Mail's settings (IMAP/SMTP) as
# the password, and the same address as EMAIL_FROM. Some hosts block outgoing 25 and 465;
# 587 gets through.
EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=
EMAIL_SMTP_PASSWORD=
# The address codes come from (shown as "Halkyon Learn"); its domain needs SPF, DKIM and
# DMARC records for the service, or mail lands in spam.
EMAIL_FROM=
# Where the source of what this instance runs is, shown in the app's About dialog: both
# licences (AGPL for the app, SSPL for the server) require offering it to the people who
# use the instance. Empty: the project's repository, which is right for unmodified code.

View file

@ -65,6 +65,13 @@ services:
# GitHub sign-in, only with this instance's GitHub OAuth app; empty: off.
SERVERPOD_PASSWORD_githubClientId: ${GITHUB_CLIENT_ID:-}
SERVERPOD_PASSWORD_githubClientSecret: ${GITHUB_CLIENT_SECRET:-}
# Email sign-in (ADR 0010), only with the pepper and an SMTP service; empty: off.
SERVERPOD_PASSWORD_emailSignInPepper: ${EMAIL_SIGN_IN_PEPPER:-}
SERVERPOD_PASSWORD_emailSmtpHost: ${EMAIL_SMTP_HOST:-}
SERVERPOD_PASSWORD_emailSmtpPort: ${EMAIL_SMTP_PORT:-}
SERVERPOD_PASSWORD_emailSmtpUser: ${EMAIL_SMTP_USER:-}
SERVERPOD_PASSWORD_emailSmtpPassword: ${EMAIL_SMTP_PASSWORD:-}
SERVERPOD_PASSWORD_emailFrom: ${EMAIL_FROM:-}
# Where the source of what this instance runs is; empty: the project's.
SOURCE_URL: ${SOURCE_URL:-}
# The Jellyfin server suggested to people; empty: jellyfin. on the

View file

@ -56,10 +56,19 @@ tries and completion tokens (`SecretChallengeUtil`), and a rate limiter
one network (IP address); the rate limiter's notes, which hold the IP address, go
hourly with the anonymous sign-ups' (`forgetOldSignUpAttempts`), and codes past their
time hourly too (`forgetExpiredEmailCodes`).
7. **Mail.** In development, codes go to the server's log (`LogCodeMailer`). Elsewhere
the instance offers no email sign-in (`instance.info().emailSignIn`) until its
operator sets up a mail service: chosen before the first deploy that brings this
(Proton SMTP and Scaleway TEM were the candidates, 11 Oct).
7. **Mail.** Codes go over SMTP, through whichever service the operator sets up in the
passwords (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`,
`emailFrom`; `deploy/.env` in production), with pub.dev's `mailer` (MIT), over TLS
only (STARTTLS, or TLS directly on port 465). learn.halkyon.eu sends through Proton's
SMTP submission (decided 2026-10-15, over Scaleway TEM): a token for an address on
its own domain. The mail is plain text: the code first and in the subject, so it
shows in the notification and the system can offer it, which instance it signs in
to, its 15 minutes, and that it can be ignored; no links, no tracking, no HTML. A
mail that couldn't be sent is a refusal of its own (`mailFailed`), keeping nothing
of the request; the log names the error's type only, as the service's answer may
name the address. Without a service, codes go to the server's log in development
(`LogCodeMailer`), and elsewhere the instance offers no email sign-in
(`instance.info().emailSignIn`).
8. **The pepper never changes** once anybody signs in with an address: every hash would
be lost, and with it those people's way in. Rotating it would need a list of earlier
peppers tried in turn, which isn't built.

View file

@ -37,7 +37,10 @@ enum EmailSignInRefusal implements _isc.SerializableModel {
codeExpired,
/// The person already signs in with another address.
otherAddressLinked;
otherAddressLinked,
/// The mail couldn't be sent; nothing was kept of the request.
mailFailed;
static EmailSignInRefusal fromJson(String name) {
switch (name) {
@ -55,6 +58,8 @@ enum EmailSignInRefusal implements _isc.SerializableModel {
return EmailSignInRefusal.codeExpired;
case 'otherAddressLinked':
return EmailSignInRefusal.otherAddressLinked;
case 'mailFailed':
return EmailSignInRefusal.mailFailed;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "EmailSignInRefusal"',

View file

@ -510,6 +510,7 @@
"emailSignInCodeExpired": "Dieser Code gilt nicht mehr. Fordere einen neuen an.",
"emailSignInOtherAddressLinked": "Du meldest dich schon mit einer anderen Adresse an.",
"emailSignInFailed": "Die Instanz war nicht erreichbar. Versuche es noch einmal.",
"emailSignInMailFailed": "Die Instanz konnte die Mail nicht senden. Versuche es später noch einmal.",
"publicProfileTitle": "Öffentliches Profil",
"profileSheetTitle": "Profil",
"profileSheetNotFound": "Niemand auf dieser Instanz hat diesen Handle.",

View file

@ -575,6 +575,7 @@
"emailSignInCodeExpired": "This code is no longer valid. Ask for a new one.",
"emailSignInOtherAddressLinked": "You already sign in with another address.",
"emailSignInFailed": "Couldn't reach the instance. Try again.",
"emailSignInMailFailed": "The instance couldn't send the mail. Try again later.",
"publicProfileTitle": "Public profile",
"profileSheetTitle": "Profile",
"profileSheetNotFound": "Nobody on this instance has this handle.",

View file

@ -3070,6 +3070,12 @@ abstract class AppLocalizations {
/// **'Couldn\'t reach the instance. Try again.'**
String get emailSignInFailed;
/// No description provided for @emailSignInMailFailed.
///
/// In en, this message translates to:
/// **'The instance couldn\'t send the mail. Try again later.'**
String get emailSignInMailFailed;
/// No description provided for @publicProfileTitle.
///
/// In en, this message translates to:

View file

@ -1885,6 +1885,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get emailSignInFailed =>
'Die Instanz war nicht erreichbar. Versuche es noch einmal.';
@override
String get emailSignInMailFailed =>
'Die Instanz konnte die Mail nicht senden. Versuche es später noch einmal.';
@override
String get publicProfileTitle => 'Öffentliches Profil';

View file

@ -1868,6 +1868,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get emailSignInFailed => 'Couldn\'t reach the instance. Try again.';
@override
String get emailSignInMailFailed =>
'The instance couldn\'t send the mail. Try again later.';
@override
String get publicProfileTitle => 'Public profile';

View file

@ -272,6 +272,7 @@ class ServerpodAuthGateway(final Client _client) implements AuthGateway {
EmailSignInRefusal.codeExpired => EmailSignInProblem.codeExpired,
EmailSignInRefusal.otherAddressLinked =>
EmailSignInProblem.otherAddressLinked,
EmailSignInRefusal.mailFailed => EmailSignInProblem.mailFailed,
});
}
}

View file

@ -24,6 +24,9 @@ enum EmailSignInProblem() {
/// The person already signs in with another address.
otherAddressLinked,
/// The instance couldn't send the mail.
mailFailed,
/// The instance couldn't be reached, or answered otherwise.
failed,
}

View file

@ -45,6 +45,7 @@ String emailSignInProblemText(
EmailSignInProblem.tooManyTries => l10n.emailSignInTooManyTries,
EmailSignInProblem.codeExpired => l10n.emailSignInCodeExpired,
EmailSignInProblem.otherAddressLinked => l10n.emailSignInOtherAddressLinked,
EmailSignInProblem.mailFailed => l10n.emailSignInMailFailed,
EmailSignInProblem.failed => l10n.emailSignInFailed,
};

View file

@ -217,6 +217,22 @@ void main() {
);
});
test("says when the instance couldn't send the mail", () async {
gateway.emailError = const EmailSignInFailed(
EmailSignInProblem.mailFailed,
);
final bloc = newBloc()
..add(const EmailSignInEventCodeRequested('anna@example.org'));
await expectLater(
bloc.stream,
emitsInOrder([
const EmailSignInStateAddress(sending: true),
const EmailSignInStateAddress(problem: EmailSignInProblem.mailFailed),
]),
);
});
test("says when the instance can't be reached", () async {
gateway.emailError = Exception('offline');
final bloc = newBloc()

View file

@ -0,0 +1,157 @@
import 'package:mailer/mailer.dart' as mailer;
import 'package:mailer/smtp_server.dart';
import 'package:serverpod/serverpod.dart';
// The mail that carries an email sign-in code (ADR 0010, decided
// 2026-10-15): plain text, the code in its subject so it shows in the
// notification and the system can offer it, no links, no tracking. Sent
// over SMTP, through any service the operator chooses (learn.halkyon.eu
// through Proton's).
/// Mails sign-in codes.
abstract interface class CodeMailer {
/// Mails [code] to [to], worded in [language] (`en` or `de`).
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
});
}
/// Writes codes into the server's log, for development, where nobody has a
/// mail service. Never elsewhere: the log would then hold every address
/// asked for.
final class LogCodeMailer implements CodeMailer {
const LogCodeMailer();
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) async => session.log('Email sign-in code for $to: $code');
}
/// A code's mail, worded in [language] (`en`, else German for `de`): its
/// subject and its body. [instance] is the instance's address, which the
/// mail names so that people know where they are signing in.
({String subject, String body}) codeMail({
required String code,
required String language,
required String instance,
}) => switch (language) {
'de' => (
subject: '$code ist dein Halkyon-Learn-Code',
body:
'$code\n'
'\n'
'Gib diesen Code in Halkyon Learn ein, um dich auf $instance '
'anzumelden. Er gilt 15 Minuten.\n'
'\n'
'Wenn du ihn nicht angefordert hast, ignoriere diese Mail: Ohne den '
'Code kann sich niemand mit deiner Adresse anmelden.\n'
'\n'
'Diese Instanz behält nur einen Fingerabdruck deiner Adresse und '
'schreibt ihr nur, wenn du einen Code anforderst.\n',
),
_ => (
subject: '$code is your Halkyon Learn code',
body:
'$code\n'
'\n'
'Type this code in Halkyon Learn to sign in on $instance. It is valid '
'for 15 minutes.\n'
'\n'
"If you didn't ask for it, ignore this mail: without the code, nobody "
'can sign in with your address.\n'
'\n'
'This instance keeps only a fingerprint of your address and writes to '
'it only when you ask for a code.\n',
),
};
/// Mails codes through an SMTP service, over TLS only: directly on port
/// 465, upgraded with STARTTLS elsewhere (Proton's: `smtp.protonmail.ch`,
/// 587). It logs nothing of a mail, neither its address nor its code.
final class SmtpCodeMailer implements CodeMailer {
SmtpCodeMailer({
required this._server,
required this._from,
required this._instance,
});
final SmtpServer _server;
final mailer.Address _from;
final String _instance;
/// The mailer [pod]'s passwords set up: `emailSmtpHost`,
/// `emailSmtpPort` (587 unless set), `emailSmtpUser`,
/// `emailSmtpPassword` and `emailFrom`, the address mails come from (for
/// Proton the address the token belongs to); null unless all but the port
/// are set. Empty values count as unset: Compose passes unset variables
/// on as empty strings.
static SmtpCodeMailer? fromPasswords(Serverpod pod) {
String? password(String name) => switch (pod.getPassword(name)) {
final value? when value.trim().isNotEmpty => value.trim(),
_ => null,
};
final host = password('emailSmtpHost');
final user = password('emailSmtpUser');
final secret = password('emailSmtpPassword');
final from = password('emailFrom');
if (host == null || user == null || secret == null || from == null) {
return null;
}
final port = int.tryParse(password('emailSmtpPort') ?? '') ?? 587;
return SmtpCodeMailer(
server: SmtpServer(
host,
port: port,
ssl: port == 465,
username: user,
password: secret,
),
from: mailer.Address(from, 'Halkyon Learn'),
instance: pod.config.apiServer.publicHost,
);
}
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) => mail(to: to, code: code, language: language);
/// Mails [code] to [to], worded in [language]; throws what the service
/// answered when it took no mail.
Future<void> mail({
required String to,
required String code,
required String language,
}) async {
final mail = codeMail(code: code, language: language, instance: _instance);
await mailer.send(
mailer.Message()
..from = _from
..recipients.add(to)
..subject = mail.subject
..text = mail.body,
_server,
timeout: const Duration(seconds: 20),
);
}
}
/// How codes are mailed on an instance in [runMode]: through [smtp] where
/// its operator set one up, into the log in development otherwise, and not
/// at all elsewhere (no email sign-in).
CodeMailer? codeMailerFor({
required String runMode,
required CodeMailer? smtp,
}) =>
smtp ??
(runMode == ServerpodRunMode.development ? const LogCodeMailer() : null);

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:halkyon_learn_server/src/auth/code_mail.dart';
import 'package:halkyon_learn_server/src/auth/identified.dart';
import 'package:halkyon_learn_server/src/common/short_code.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
@ -22,40 +23,17 @@ const emailMethod = 'email';
/// How long a code mailed is valid.
const emailCodeLifetime = Duration(minutes: 15);
/// Mails sign-in codes.
abstract interface class CodeMailer {
/// Mails [code] to [to], worded in [language] (`en` or `de`).
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
});
}
/// Writes codes into the server's log, for development, where nobody has a
/// mail service. Never elsewhere: the log would then hold every address
/// asked for.
final class LogCodeMailer implements CodeMailer {
const LogCodeMailer();
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) async => session.log('Email sign-in code for $to: $code');
}
/// How this instance mails sign-in codes: into the log in development, and
/// elsewhere not at all until its operator sets up a mail service. Tests put
/// their own [mailer].
/// How this instance mails sign-in codes ([codeMailerFor]): through the
/// SMTP service its operator set up, into the log in development without
/// one, and elsewhere not at all. Tests put their own [mailer].
final class EmailCodes {
EmailCodes._(this.mailer);
factory EmailCodes.of(Serverpod pod) => _ofPod[pod] ??= EmailCodes._(
pod.runMode == ServerpodRunMode.development ? const LogCodeMailer() : null,
codeMailerFor(
runMode: pod.runMode,
smtp: SmtpCodeMailer.fromPasswords(pod),
),
);
static final _ofPod = Expando<EmailCodes>('Email codes');
@ -124,7 +102,8 @@ final _codesPerNetwork = DatabaseRateLimiter(
/// Mails a new code to [address] and returns the request it answers, in the
/// same way whether anybody has the address or not. An earlier code for the
/// address stops being valid. Refused for no address, or too many codes
/// address stops being valid. Refused for no address, too many codes, or a
/// mail that couldn't be sent, leaving no request behind
/// ([EmailSignInRefusedException]).
Future<UuidValue> sendSignInCode(
Session session, {
@ -182,12 +161,23 @@ Future<UuidValue> sendSignInCode(
),
transaction: transaction,
);
await mailer.send(
session,
to: to,
code: code,
language: language == 'de' ? 'de' : 'en',
);
try {
await mailer.send(
session,
to: to,
code: code,
language: language == 'de' ? 'de' : 'en',
);
} on Exception catch (error) {
// Its type only: what the mail service answered may name the address.
session.log(
'Mailing a sign-in code failed: ${error.runtimeType}',
level: LogLevel.warning,
);
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.mailFailed,
);
}
return request.id!;
});
}

View file

@ -20,3 +20,5 @@ values:
- codeExpired
### The person already signs in with another address.
- otherAddressLinked
### The mail couldn't be sent; nothing was kept of the request.
- mailFailed

View file

@ -37,7 +37,10 @@ enum EmailSignInRefusal implements _is.SerializableModel {
codeExpired,
/// The person already signs in with another address.
otherAddressLinked;
otherAddressLinked,
/// The mail couldn't be sent; nothing was kept of the request.
mailFailed;
static EmailSignInRefusal fromJson(String name) {
switch (name) {
@ -55,6 +58,8 @@ enum EmailSignInRefusal implements _is.SerializableModel {
return EmailSignInRefusal.codeExpired;
case 'otherAddressLinked':
return EmailSignInRefusal.otherAddressLinked;
case 'mailFailed':
return EmailSignInRefusal.mailFailed;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "EmailSignInRefusal"',

View file

@ -11,6 +11,8 @@ resolution: workspace
dependencies:
clock: ^1.1.3
crypto: ^3.0.7
# Mails email sign-in's codes over SMTP (ADR 0010).
mailer: ^7.2.0
serverpod: 4.0.4
serverpod_auth_idp_server: 4.0.4
serverpod_offline_sync_server: 0.0.10

View file

@ -1,7 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_server/src/auth/account.dart';
import 'package:halkyon_learn_server/src/auth/code_mail.dart';
import 'package:halkyon_learn_server/src/auth/email.dart';
import 'package:halkyon_learn_server/src/auth/scopes.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
@ -31,6 +33,17 @@ final class _Mailbox implements CodeMailer {
sent.lastWhere((mail) => mail.to == address).code;
}
/// A mail service that takes no mail.
final class _Refusing implements CodeMailer {
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) async => throw const SocketException('Connection refused');
}
void main() {
withServerpod(
'Email sign-in',
@ -181,6 +194,21 @@ void main() {
},
);
test('a code it could not mail, keeping nothing of it', () async {
EmailCodes.of(session.serverpod).mailer = _Refusing();
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
),
throwsA(_refusal(EmailSignInRefusal.mailFailed)),
);
expect(await EmailCodeRequest.db.count(session), 0);
expect(await SecretChallenge.db.count(session), 0);
});
test('what is no address', () async {
for (final typed in ['anna', 'anna@', '@example.org', 'a b@c.de']) {
await expectLater(

Some files were not shown because too many files have changed in this diff Show more