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

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()