import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:halkyon_base_server/base.dart'; import 'package:mailer/mailer.dart' as mailer; import 'package:mailer/smtp_server.dart'; import 'package:serverpod/serverpod.dart'; import 'package:test/test.dart'; /// An SMTP server on a loopback port that takes one mail at a time, offers /// no TLS, and keeps what it is told. final class _FakeSmtp { _FakeSmtp._(this._socket) { _socket.listen(_serve); } static Future<_FakeSmtp> start() async => _FakeSmtp._(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0)); final ServerSocket _socket; /// The commands received, in order. final commands = []; /// What each mail's DATA held, its lines joined. final mails = []; int get port => _socket.port; Future close() => _socket.close(); void _serve(Socket client) { client.write('220 fake ESMTP\r\n'); var step = ''; final data = StringBuffer(); utf8.decoder.bind(client).transform(const LineSplitter()).listen((line) { if (step == 'data') { if (line == '.') { step = ''; mails.add(data.toString()); data.clear(); client.write('250 2.0.0 queued\r\n'); } else { data.writeln(line); } return; } if (step == 'auth') { step = ''; client.write('235 2.7.0 Authentication successful\r\n'); return; } commands.add(line); final command = line.toUpperCase(); if (command.startsWith('EHLO')) { client.write('250-fake\r\n250 AUTH PLAIN\r\n'); } else if (command == 'AUTH PLAIN') { step = 'auth'; client.write('334 \r\n'); } else if (command == 'DATA') { step = 'data'; client.write('354 End data with .\r\n'); } else if (command == 'QUIT') { client ..write('221 2.0.0 Bye\r\n') ..close(); } else { client.write('250 2.0.0 OK\r\n'); } }); } } /// The text of [mail] as DATA held it: its headers and its body, decoded /// from base64 where it was encoded so. ({String headers, String body}) _read(String mail) { final at = mail.indexOf('\n\n'); final headers = mail.substring(0, at); final body = mail.substring(at + 2); return ( headers: headers, body: headers.toLowerCase().contains('content-transfer-encoding: base64') ? utf8.decode(base64.decode(body.replaceAll(RegExp(r'\s'), ''))) : body, ); } void main() { group('the mail', () { test('is plain text with the code first, in the subject too, naming ' 'the instance and how long the code lasts', () { final mail = codeMail( code: 'K7QX-M2PD', language: 'en', app: 'Halkyon Learn', instance: 'learn.halkyon.eu', purpose: EmailCodePurpose.signUp, ); expect( mail.subject, 'K7QX-M2PD: your Halkyon Learn code to create an account or give it ' 'a password', ); expect(mail.body, startsWith('K7QX-M2PD\n\n')); expect(mail.body, contains('create your account on learn.halkyon.eu')); expect(mail.body, contains('15 minutes')); expect(mail.body, isNot(contains('http'))); }); test('says what its code would do, and to type it nowhere but the app ' 'on the instance, in each language', () { ({String subject, String body}) mailFor( EmailCodePurpose purpose, String language, ) => codeMail( code: 'K7QX-M2PD', language: language, app: 'Halkyon Learn', instance: 'learn.halkyon.eu', purpose: purpose, ); const english = { EmailCodePurpose.signUp: 'to create your account', EmailCodePurpose.finishSetup: 'to finish setting up your account', EmailCodePurpose.authenticatorReset: "to switch off your authenticator app's codes", EmailCodePurpose.accountReset: 'to reset your account', EmailCodePurpose.deletion: 'to delete your account', EmailCodePurpose.firstPassword: 'to give your account on learn.halkyon.eu its first password', EmailCodePurpose.joinSpace: 'to join, with your account on learn.halkyon.eu, what this ' 'address was invited to', }; const german = { EmailCodePurpose.signUp: 'um dein Konto auf learn.halkyon.eu anzulegen', EmailCodePurpose.finishSetup: 'um die Einrichtung deines Kontos', EmailCodePurpose.authenticatorReset: 'um die Codes deiner Authenticator-App', EmailCodePurpose.accountReset: 'zurückzusetzen', EmailCodePurpose.deletion: 'mit allem darin zu löschen', EmailCodePurpose.firstPassword: 'um deinem Konto auf learn.halkyon.eu sein erstes Passwort zu ' 'geben', EmailCodePurpose.joinSpace: 'um mit deinem Konto auf learn.halkyon.eu dem beizutreten, wozu ' 'diese Adresse eingeladen wurde', }; for (final purpose in EmailCodePurpose.values) { final en = mailFor(purpose, 'en').body; expect(en, contains(english[purpose])); expect( en, contains('Type it only in Halkyon Learn on learn.halkyon.eu'), ); expect(en, isNot(contains('sign in'))); final de = mailFor(purpose, 'de').body; expect(de, contains(german[purpose])); expect( de, contains('Gib ihn nur in Halkyon Learn auf learn.halkyon.eu'), ); } expect( mailFor(EmailCodePurpose.accountReset, 'en').body, contains('what was encrypted with the old ones is lost'), ); }); test('names its purpose in the subject, which a notification shows ' 'alone (the third reading)', () { String subjectOf(EmailCodePurpose purpose, String language) => codeMail( code: 'K7QX-M2PD', language: language, app: 'Halkyon Learn', instance: 'learn.halkyon.eu', purpose: purpose, ).subject; const english = { EmailCodePurpose.signUp: 'K7QX-M2PD: your Halkyon Learn code to create an account or give ' 'it a password', EmailCodePurpose.finishSetup: 'K7QX-M2PD: your Halkyon Learn code to finish setting up your ' 'account', EmailCodePurpose.authenticatorReset: 'K7QX-M2PD: your Halkyon Learn code to switch off your ' 'authenticator', EmailCodePurpose.accountReset: 'K7QX-M2PD: your Halkyon Learn code to reset your account', EmailCodePurpose.deletion: 'K7QX-M2PD: your Halkyon Learn code to delete your account', EmailCodePurpose.firstPassword: 'K7QX-M2PD: your Halkyon Learn code to give your account a ' 'password', EmailCodePurpose.joinSpace: 'K7QX-M2PD: your Halkyon Learn code to accept an invitation', }; const german = { EmailCodePurpose.signUp: 'K7QX-M2PD: dein Halkyon-Learn-Code, um ein Konto anzulegen oder ' 'ihm ein Passwort zu geben', EmailCodePurpose.finishSetup: 'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto fertig ' 'einzurichten', EmailCodePurpose.authenticatorReset: 'K7QX-M2PD: dein Halkyon-Learn-Code, um deine Authenticator-App ' 'abzuschalten', EmailCodePurpose.accountReset: 'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto zurückzusetzen', EmailCodePurpose.deletion: 'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto zu löschen', EmailCodePurpose.firstPassword: 'K7QX-M2PD: dein Halkyon-Learn-Code, um deinem Konto ein Passwort ' 'zu geben', EmailCodePurpose.joinSpace: 'K7QX-M2PD: dein Halkyon-Learn-Code, um einer Einladung zu folgen', }; for (final purpose in EmailCodePurpose.values) { expect(subjectOf(purpose, 'en'), english[purpose]); expect(subjectOf(purpose, 'de'), german[purpose]); } }); test('for signing up, says it also gives a password to an account with ' 'that address and none yet', () { ({String subject, String body}) mailIn(String language) => codeMail( code: 'K7QX-M2PD', language: language, app: 'Halkyon Learn', instance: 'learn.halkyon.eu', purpose: EmailCodePurpose.signUp, ); expect( mailIn('en').body, contains( 'or, should an account there have this address and no password ' 'yet, to give it one', ), ); expect( mailIn('de').body, contains( 'oder, hat dort schon ein Konto diese Adresse und noch kein ' 'Passwort, ihm eines zu geben', ), ); }); test('in German for German, in English for anything else', () { ({String subject, String body}) mailIn(String language) => codeMail( code: 'K7QX-M2PD', language: language, app: 'Halkyon Learn', instance: 'x', purpose: EmailCodePurpose.signUp, ); expect( mailIn('de').subject, 'K7QX-M2PD: dein Halkyon-Learn-Code, um ein Konto anzulegen oder ihm ' 'ein Passwort zu geben', ); expect( mailIn('fr').subject, 'K7QX-M2PD: your Halkyon Learn code to create an account or give ' 'it a password', ); }); test('names the app the host is, in its text too', () { ({String subject, String body}) mailIn(String language) => codeMail( code: 'K7QX-M2PD', language: language, app: 'Halkyon Todo', instance: 'todo.example.org', purpose: EmailCodePurpose.signUp, ); expect( mailIn('en').subject, 'K7QX-M2PD: your Halkyon Todo code to create an account or give it a ' 'password', ); expect(mailIn('en').body, contains('Type this code in Halkyon Todo')); expect( mailIn('de').subject, 'K7QX-M2PD: dein Halkyon-Todo-Code, um ein Konto anzulegen oder ' 'ihm ein Passwort zu geben', ); expect(mailIn('de').body, contains('Gib diesen Code in Halkyon Todo')); }); }); group("a reset's notice", () { ({String subject, String body}) notice( AccountResetKind kind, String language, ) => resetMail( kind: kind, takesEffectAt: DateTime.utc(2026, 10, 23, 14, 5, 59), language: language, app: 'Halkyon Learn', instance: 'learn.halkyon.eu', ); test('says what goes when, in UTC, on which instance, and that signing ' 'in calls it off, in German for German and English else', () { for (final kind in AccountResetKind.values) { for (final language in ['en', 'de', 'fr']) { final mail = notice(kind, language); expect(mail.body, contains('2026-10-23 14:05 UTC')); expect(mail.body, contains('learn.halkyon.eu')); expect(mail.subject, contains('Halkyon Learn')); expect( mail.body, language == 'de' ? contains('bricht es ab') : contains('calls it off'), ); } } expect( notice(AccountResetKind.account, 'en').body, contains('can no longer be read'), ); }); test('holds no link and no code: it helps nobody but the owner', () { for (final kind in AccountResetKind.values) { final mail = notice(kind, 'en'); expect(mail.body, isNot(contains('http'))); expect(mail.body, isNot(matches(RegExp('[0-9A-Z]{4}-[0-9A-Z]{4}')))); } }); }); group('codes go', () { final smtp = SmtpCodeMailer( server: SmtpServer('localhost'), from: const mailer.Address('learn@halkyon.eu'), app: 'Halkyon Learn', instance: 'learn.halkyon.eu', ); test('through the SMTP service set up, in every run mode', () { for (final runMode in [ ServerpodRunMode.development, ServerpodRunMode.production, ]) { expect(codeMailerFor(runMode: runMode, smtp: smtp), same(smtp)); } }); test('into the log in development without one, and nowhere elsewhere ' '(no email sign-in)', () { expect( codeMailerFor(runMode: ServerpodRunMode.development, smtp: null), isA(), ); for (final runMode in [ ServerpodRunMode.staging, ServerpodRunMode.production, ServerpodRunMode.test, ]) { expect(codeMailerFor(runMode: runMode, smtp: null), isNull); } }); }); group('over SMTP', () { late _FakeSmtp server; setUp(() async => server = await _FakeSmtp.start()); tearDown(() => server.close()); SmtpCodeMailer mailerFor({required bool allowInsecure}) => SmtpCodeMailer( server: SmtpServer( '127.0.0.1', port: server.port, username: 'learn@halkyon.eu', password: 'token', allowInsecure: allowInsecure, ), from: const mailer.Address('learn@halkyon.eu', 'Halkyon Learn'), app: 'Halkyon Learn', instance: 'learn.halkyon.eu', ); test( 'mails the code from the address set up to the address typed', () async { await mailerFor(allowInsecure: true).mail( to: 'anna@example.org', code: 'K7QX-M2PD', language: 'de', purpose: EmailCodePurpose.signUp, ); expect(server.commands, contains('MAIL FROM:')); expect(server.commands, contains('RCPT TO:')); final mail = _read(server.mails.single); expect(mail.headers, contains('Halkyon Learn')); expect(mail.headers, contains('')); expect(mail.headers, contains('text/plain')); expect(mail.body, startsWith('K7QX-M2PD')); expect( mail.body, contains('um dein Konto auf learn.halkyon.eu anzulegen'), ); }, ); test('never over a connection without TLS', () async { await expectLater( mailerFor(allowInsecure: false).mail( to: 'anna@example.org', code: 'K7QX-M2PD', language: 'en', purpose: EmailCodePurpose.signUp, ), throwsA(isA()), ); expect(server.mails, isEmpty); expect( server.commands.where((command) => command.startsWith('AUTH')), isEmpty, ); }); }); test('an invitation names who invites and where, its link and when it ' 'lapses, that joining takes a code mailed here, and nothing of what is ' 'shared, in each language', () { for (final language in ['en', 'de']) { final mail = invitationMail( inviter: 'Alice (@alice)', app: 'Halkyon Learn', link: Uri.parse('https://learn.halkyon.eu/join/abc'), lapsesAt: DateTime.utc(2026, 11, 25, 12), language: language, ); expect(mail.subject, contains('Alice (@alice)')); expect(mail.subject, contains('Halkyon Learn')); expect(mail.body, contains('https://learn.halkyon.eu/join/abc')); expect(mail.body, contains('learn.halkyon.eu.')); expect(mail.body, contains('2026-11-25 12:00 UTC')); expect( mail.body, contains(language == 'de' ? 'einen Code' : 'mails a code'), ); } }); }