From 21328a585a7edddc80e09bc6b7dc13a8551cf36a Mon Sep 17 00:00:00 2001 From: Wjouter <34310151+Wjouter@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:18:52 +0200 Subject: [PATCH 1/3] test(email): snapshot the plain-text part, subject and headers Co-Authored-By: Claude Sonnet 5.5 --- backend/InTheKnow.Tests/AccountEmailTests.cs | 2 +- backend/InTheKnow.Tests/EmailChannelTests.cs | 49 +++++++++++++++++++ .../EmailSnapshots/account-confirm.en.txt | 13 +++++ .../EmailSnapshots/account-confirm.nl.txt | 13 +++++ .../EmailSnapshots/account-deleted.en.txt | 10 ++++ .../EmailSnapshots/account-deleted.nl.txt | 10 ++++ .../EmailSnapshots/account-disabled.en.txt | 10 ++++ .../EmailSnapshots/account-disabled.nl.txt | 10 ++++ .../EmailSnapshots/account-emailChange.en.txt | 13 +++++ .../EmailSnapshots/account-emailChange.nl.txt | 13 +++++ .../account-emailChangeNotice.en.txt | 10 ++++ .../account-emailChangeNotice.nl.txt | 10 ++++ .../EmailSnapshots/account-enabled.en.txt | 10 ++++ .../EmailSnapshots/account-enabled.nl.txt | 10 ++++ .../account-invite-admin.en.txt | 13 +++++ .../account-invite-admin.nl.txt | 13 +++++ .../EmailSnapshots/account-invite.en.txt | 13 +++++ .../EmailSnapshots/account-invite.nl.txt | 13 +++++ .../EmailSnapshots/account-methodAdded.en.txt | 10 ++++ .../EmailSnapshots/account-methodAdded.nl.txt | 10 ++++ .../account-methodRemoved.en.txt | 10 ++++ .../account-methodRemoved.nl.txt | 10 ++++ .../account-passwordChanged.en.txt | 10 ++++ .../account-passwordChanged.nl.txt | 10 ++++ .../account-recoveryCodeUsed.en.txt | 10 ++++ .../account-recoveryCodeUsed.nl.txt | 10 ++++ .../EmailSnapshots/account-reset.en.txt | 13 +++++ .../EmailSnapshots/account-reset.nl.txt | 13 +++++ .../account-roleChanged-user.en.txt | 10 ++++ .../account-roleChanged-user.nl.txt | 10 ++++ .../EmailSnapshots/account-roleChanged.en.txt | 10 ++++ .../EmailSnapshots/account-roleChanged.nl.txt | 10 ++++ .../account-signUpAttempt.en.txt | 10 ++++ .../account-signUpAttempt.nl.txt | 10 ++++ .../account-twoFactorOff.en.txt | 10 ++++ .../account-twoFactorOff.nl.txt | 10 ++++ .../account-twoFactorOffByAdmin.en.txt | 10 ++++ .../account-twoFactorOffByAdmin.nl.txt | 10 ++++ .../EmailSnapshots/account-twoFactorOn.en.txt | 10 ++++ .../EmailSnapshots/account-twoFactorOn.nl.txt | 10 ++++ .../EmailSnapshots/changes.en.txt | 46 +++++++++++++++++ .../EmailSnapshots/changes.nl.txt | 46 +++++++++++++++++ .../EmailSnapshots/confirmation.en.txt | 14 ++++++ .../EmailSnapshots/confirmation.nl.txt | 14 ++++++ .../EmailSnapshots/test.en.txt | 10 ++++ .../EmailSnapshots/test.nl.txt | 10 ++++ 46 files changed, 600 insertions(+), 1 deletion(-) create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-confirm.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-confirm.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-deleted.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-deleted.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-disabled.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-disabled.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-emailChange.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-emailChange.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-emailChangeNotice.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-emailChangeNotice.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-enabled.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-enabled.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-invite-admin.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-invite-admin.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-invite.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-invite.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-methodAdded.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-methodAdded.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-methodRemoved.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-methodRemoved.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-passwordChanged.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-passwordChanged.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-recoveryCodeUsed.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-recoveryCodeUsed.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-reset.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-reset.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged-user.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged-user.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-signUpAttempt.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-signUpAttempt.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOff.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOff.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOffByAdmin.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOffByAdmin.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOn.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOn.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/changes.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/confirmation.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/confirmation.nl.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/test.en.txt create mode 100644 backend/InTheKnow.Tests/EmailSnapshots/test.nl.txt diff --git a/backend/InTheKnow.Tests/AccountEmailTests.cs b/backend/InTheKnow.Tests/AccountEmailTests.cs index b741fd1..96aa903 100644 --- a/backend/InTheKnow.Tests/AccountEmailTests.cs +++ b/backend/InTheKnow.Tests/AccountEmailTests.cs @@ -29,7 +29,7 @@ public partial class AccountEmailTests _ => [], }; - static Message Account(string kind, string language, IReadOnlyDictionary? args = null) => + internal static Message Account(string kind, string language, IReadOnlyDictionary? args = null) => new(MessageKind.Account, language, Amsterdam, null, null, null, null, [], 0, kind, LinkKinds.Contains(kind) ? $"{EmailChannelTests.Frontend}/account/x?token=TOKEN" : null, args ?? Args(kind)); diff --git a/backend/InTheKnow.Tests/EmailChannelTests.cs b/backend/InTheKnow.Tests/EmailChannelTests.cs index 37d0336..fc88506 100644 --- a/backend/InTheKnow.Tests/EmailChannelTests.cs +++ b/backend/InTheKnow.Tests/EmailChannelTests.cs @@ -1,4 +1,5 @@ using System.Net.Mail; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json.Nodes; using System.Text.RegularExpressions; @@ -288,4 +289,52 @@ public void TestEmail(string language, string subject) } Assert.Equal([$"{Frontend}/notifications"], Url().Matches(text).Select(u => u.Value)); } + + static JsonObject RangeOf(string start, string end) => new() { ["start"] = start, ["end"] = end }; + + // Every email shape in one language, for the snapshot test (SC-004) and the opt-in Mailpit preview. + internal static IEnumerable<(string Name, Message Message)> EmailFixtures(string language) + { + JsonObject Event(string name, JsonObject fields) => new() { ["name"] = name, ["start"] = "2026-10-06T06:30:00Z", ["end"] = "2026-10-06T08:00:00Z", ["fields"] = fields }; + var added = new ChangeItem(View(7, "added", null, null, Event("New lesson", new JsonObject { ["rooms"] = new JsonArray("AL00.A1.20", "AL00.A1.23") }), name: "New lesson"), false); + var removed = new ChangeItem(View(8, "removed", null, Event("Old lesson", new JsonObject { ["location"] = "Online" }), null, name: "Old lesson"), false); + var acrossDays = new ChangeItem(View(10, "changed", "time", + RangeOf("2026-10-06T06:30:00Z", "2026-10-06T08:00:00Z"), RangeOf("2026-10-07T06:30:00Z", "2026-10-07T08:00:00Z"), name: "Physics"), false); + var lowRooms = new ChangeItem(View(11, "changed", "rooms", new JsonArray("AL00.A1.38"), new JsonArray("AL00.A1.20"), name: "Elective", calendarId: 2), true); + yield return ("changes", Changes(language, Amsterdam, 7, + Group(1, "Minor robotics", Room, Status, Time, acrossDays, added, removed), Group(2, "Electives", lowRooms))); + yield return ("confirmation", Confirmation(language)); + yield return ("test", Test(language)); + foreach (var kind in AccountEmailTests.Kinds) yield return ($"account-{kind}", AccountEmailTests.Account(kind, language)); + yield return ("account-roleChanged-user", AccountEmailTests.Account("roleChanged", language, new Dictionary { ["role"] = "user" })); + yield return ("account-invite-admin", AccountEmailTests.Account("invite", language, + new Dictionary { ["admin"] = "true", ["expires"] = "Tue 6 Oct 2026 08:30" })); + } + + static string SnapshotDir([CallerFilePath] string file = "") => Path.Combine(Path.GetDirectoryName(file)!, "EmailSnapshots"); + + // SC-004: the text part, subject and headers do not change with the HTML layout. Set INTHEKNOW_UPDATE_SNAPSHOTS=1 to (re)write them. + [Fact] + public void TextPartMatchesSnapshot() + { + var update = Environment.GetEnvironmentVariable("INTHEKNOW_UPDATE_SNAPSHOTS") == "1"; + Directory.CreateDirectory(SnapshotDir()); + var problems = new List(); + foreach (var language in new[] { "en", "nl" }) + foreach (var (name, message) in EmailFixtures(language)) + { + var (text, _, mail) = Render(message); + var actual = string.Join("\n", new[] + { + "Subject: " + mail.Subject, "To: " + mail.To, "From: " + mail.From, + "List-Unsubscribe: " + mail.Headers["List-Unsubscribe"], "List-Unsubscribe-Post: " + mail.Headers["List-Unsubscribe-Post"], + "", text, + }); + var path = Path.Combine(SnapshotDir(), $"{name}.{language}.txt"); + if (update) File.WriteAllBytes(path, Encoding.UTF8.GetBytes(actual)); + else if (!File.Exists(path)) problems.Add($"{path} is missing"); + else if (!File.ReadAllBytes(path).SequenceEqual(Encoding.UTF8.GetBytes(actual))) problems.Add($"{name}.{language} differs"); + } + Assert.Empty(problems); + } } diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-confirm.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-confirm.en.txt new file mode 100644 index 0000000..bfebdbd --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-confirm.en.txt @@ -0,0 +1,13 @@ +Subject: Confirm your InTheKnow account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Someone signed up for InTheKnow with this email address. Open the link and enter your password to confirm the account. The link is valid for 24 hours. + +Confirm my account: +http://localhost:5173/account/x?token=TOKEN + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-confirm.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-confirm.nl.txt new file mode 100644 index 0000000..753a3b3 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-confirm.nl.txt @@ -0,0 +1,13 @@ +Subject: Bevestig je InTheKnow-account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Iemand heeft zich met dit e-mailadres aangemeld bij InTheKnow. Open de link en vul je wachtwoord in om het account te bevestigen. De link is 24 uur geldig. + +Mijn account bevestigen: +http://localhost:5173/account/x?token=TOKEN + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-deleted.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-deleted.en.txt new file mode 100644 index 0000000..95fc62e --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-deleted.en.txt @@ -0,0 +1,10 @@ +Subject: Your InTheKnow account was deleted +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Your InTheKnow account and everything in it were deleted. This is the last email about it. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-deleted.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-deleted.nl.txt new file mode 100644 index 0000000..a40279a --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-deleted.nl.txt @@ -0,0 +1,10 @@ +Subject: Je InTheKnow-account is verwijderd +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Je InTheKnow-account en alles erin zijn verwijderd. Dit is de laatste e-mail erover. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-disabled.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-disabled.en.txt new file mode 100644 index 0000000..0b5b8d4 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-disabled.en.txt @@ -0,0 +1,10 @@ +Subject: Your InTheKnow account was disabled +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +An admin disabled your InTheKnow account. You cannot sign in and get no timetable notifications until it is enabled again. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-disabled.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-disabled.nl.txt new file mode 100644 index 0000000..cb5ed25 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-disabled.nl.txt @@ -0,0 +1,10 @@ +Subject: Je InTheKnow-account is geblokkeerd +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Een beheerder heeft je InTheKnow-account geblokkeerd. Je kunt je niet aanmelden en krijgt geen roostermeldingen tot het weer wordt vrijgegeven. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-emailChange.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-emailChange.en.txt new file mode 100644 index 0000000..8873535 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-emailChange.en.txt @@ -0,0 +1,13 @@ +Subject: Confirm your new email address for InTheKnow +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Someone asked to use this email address for their InTheKnow account. Open the link to confirm it. The link is valid for 24 hours. Until then, the account keeps its current address. + +Confirm this address: +http://localhost:5173/account/x?token=TOKEN + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-emailChange.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-emailChange.nl.txt new file mode 100644 index 0000000..d2fd802 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-emailChange.nl.txt @@ -0,0 +1,13 @@ +Subject: Bevestig je nieuwe e-mailadres voor InTheKnow +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Iemand wil dit e-mailadres gebruiken voor zijn of haar InTheKnow-account. Open de link om het te bevestigen. De link is 24 uur geldig. Tot dan houdt het account zijn huidige adres. + +Dit adres bevestigen: +http://localhost:5173/account/x?token=TOKEN + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-emailChangeNotice.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-emailChangeNotice.en.txt new file mode 100644 index 0000000..0c93275 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-emailChangeNotice.en.txt @@ -0,0 +1,10 @@ +Subject: The email address of your InTheKnow account is being changed +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Someone asked to change the email address of your InTheKnow account. It changes only when the new address is confirmed. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-emailChangeNotice.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-emailChangeNotice.nl.txt new file mode 100644 index 0000000..9e0de27 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-emailChangeNotice.nl.txt @@ -0,0 +1,10 @@ +Subject: Het e-mailadres van je InTheKnow-account wordt gewijzigd +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Iemand heeft gevraagd het e-mailadres van je InTheKnow-account te wijzigen. Het verandert pas als het nieuwe adres is bevestigd. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-enabled.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-enabled.en.txt new file mode 100644 index 0000000..02a4769 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-enabled.en.txt @@ -0,0 +1,10 @@ +Subject: Your InTheKnow account was enabled again +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +An admin enabled your InTheKnow account again. You can sign in, and new timetable changes are notified again. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-enabled.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-enabled.nl.txt new file mode 100644 index 0000000..13c1eb8 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-enabled.nl.txt @@ -0,0 +1,10 @@ +Subject: Je InTheKnow-account is weer vrijgegeven +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Een beheerder heeft je InTheKnow-account weer vrijgegeven. Je kunt je aanmelden, en nieuwe roosterwijzigingen worden weer gemeld. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-invite-admin.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-invite-admin.en.txt new file mode 100644 index 0000000..4042a98 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-invite-admin.en.txt @@ -0,0 +1,13 @@ +Subject: You are invited to InTheKnow +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +You are invited to create an admin account on InTheKnow. Open the link to choose a password. The admin pages open once two-step verification is on. The invitation is valid until Tue 6 Oct 2026 08:30. + +Accept the invitation: +http://localhost:5173/account/x?token=TOKEN + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-invite-admin.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-invite-admin.nl.txt new file mode 100644 index 0000000..afb1772 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-invite-admin.nl.txt @@ -0,0 +1,13 @@ +Subject: Je bent uitgenodigd voor InTheKnow +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Je bent uitgenodigd om een beheerdersaccount te maken bij InTheKnow. Open de link om een wachtwoord te kiezen. De beheerpagina's gaan open zodra verificatie in twee stappen aanstaat. De uitnodiging is geldig tot Tue 6 Oct 2026 08:30. + +Uitnodiging accepteren: +http://localhost:5173/account/x?token=TOKEN + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-invite.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-invite.en.txt new file mode 100644 index 0000000..817f774 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-invite.en.txt @@ -0,0 +1,13 @@ +Subject: You are invited to InTheKnow +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +You are invited to create an account on InTheKnow, which tells you about changes in your timetable. Open the link to choose a password. The invitation is valid until Tue 6 Oct 2026 08:30. + +Accept the invitation: +http://localhost:5173/account/x?token=TOKEN + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-invite.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-invite.nl.txt new file mode 100644 index 0000000..6e69415 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-invite.nl.txt @@ -0,0 +1,13 @@ +Subject: Je bent uitgenodigd voor InTheKnow +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Je bent uitgenodigd om een account te maken bij InTheKnow, dat je laat weten wanneer je rooster verandert. Open de link om een wachtwoord te kiezen. De uitnodiging is geldig tot Tue 6 Oct 2026 08:30. + +Uitnodiging accepteren: +http://localhost:5173/account/x?token=TOKEN + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-methodAdded.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-methodAdded.en.txt new file mode 100644 index 0000000..d7331ae --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-methodAdded.en.txt @@ -0,0 +1,10 @@ +Subject: A sign-in method was added to your InTheKnow account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +The sign-in method "password" was added to your InTheKnow account. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-methodAdded.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-methodAdded.nl.txt new file mode 100644 index 0000000..7c84ad4 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-methodAdded.nl.txt @@ -0,0 +1,10 @@ +Subject: Er is een aanmeldmethode toegevoegd aan je InTheKnow-account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +De aanmeldmethode "password" is toegevoegd aan je InTheKnow-account. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-methodRemoved.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-methodRemoved.en.txt new file mode 100644 index 0000000..07dbea7 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-methodRemoved.en.txt @@ -0,0 +1,10 @@ +Subject: A sign-in method was removed from your InTheKnow account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +The sign-in method "password" was removed from your InTheKnow account, and the devices signed in with it were signed out. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-methodRemoved.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-methodRemoved.nl.txt new file mode 100644 index 0000000..d6b72ff --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-methodRemoved.nl.txt @@ -0,0 +1,10 @@ +Subject: Er is een aanmeldmethode verwijderd uit je InTheKnow-account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +De aanmeldmethode "password" is verwijderd uit je InTheKnow-account, en de apparaten die ermee waren aangemeld zijn afgemeld. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-passwordChanged.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-passwordChanged.en.txt new file mode 100644 index 0000000..e586021 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-passwordChanged.en.txt @@ -0,0 +1,10 @@ +Subject: Your InTheKnow password was changed +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +The password of your InTheKnow account was changed, and your other devices were signed out. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-passwordChanged.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-passwordChanged.nl.txt new file mode 100644 index 0000000..7bfead4 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-passwordChanged.nl.txt @@ -0,0 +1,10 @@ +Subject: Je InTheKnow-wachtwoord is gewijzigd +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Het wachtwoord van je InTheKnow-account is gewijzigd, en je andere apparaten zijn afgemeld. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-recoveryCodeUsed.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-recoveryCodeUsed.en.txt new file mode 100644 index 0000000..cc094c0 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-recoveryCodeUsed.en.txt @@ -0,0 +1,10 @@ +Subject: A recovery code was used for your InTheKnow account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +A recovery code was used to sign in to your InTheKnow account. You have 9 recovery codes left. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-recoveryCodeUsed.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-recoveryCodeUsed.nl.txt new file mode 100644 index 0000000..482b56a --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-recoveryCodeUsed.nl.txt @@ -0,0 +1,10 @@ +Subject: Er is een herstelcode gebruikt voor je InTheKnow-account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Er is een herstelcode gebruikt om je aan te melden bij je InTheKnow-account. Je hebt nog 9 herstelcodes. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-reset.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-reset.en.txt new file mode 100644 index 0000000..faa2a96 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-reset.en.txt @@ -0,0 +1,13 @@ +Subject: Reset your InTheKnow password +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Someone asked to reset the password of your InTheKnow account. Open the link to choose a new password. The link is valid for 1 hour. Your password stays the same until you choose a new one. + +Choose a new password: +http://localhost:5173/account/x?token=TOKEN + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-reset.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-reset.nl.txt new file mode 100644 index 0000000..634a403 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-reset.nl.txt @@ -0,0 +1,13 @@ +Subject: Stel je InTheKnow-wachtwoord opnieuw in +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Iemand heeft gevraagd het wachtwoord van je InTheKnow-account opnieuw in te stellen. Open de link om een nieuw wachtwoord te kiezen. De link is 1 uur geldig. Je wachtwoord blijft hetzelfde tot je een nieuw kiest. + +Kies een nieuw wachtwoord: +http://localhost:5173/account/x?token=TOKEN + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged-user.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged-user.en.txt new file mode 100644 index 0000000..723038f --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged-user.en.txt @@ -0,0 +1,10 @@ +Subject: Your InTheKnow role was changed +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Your InTheKnow account is no longer an admin. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged-user.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged-user.nl.txt new file mode 100644 index 0000000..8d9b2d2 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged-user.nl.txt @@ -0,0 +1,10 @@ +Subject: Je rol in InTheKnow is gewijzigd +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Je InTheKnow-account is geen beheerder meer. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged.en.txt new file mode 100644 index 0000000..ac49b43 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged.en.txt @@ -0,0 +1,10 @@ +Subject: Your InTheKnow role was changed +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Your InTheKnow account is now an admin. The admin pages open once two-step verification is on for your account. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged.nl.txt new file mode 100644 index 0000000..f7ebbab --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-roleChanged.nl.txt @@ -0,0 +1,10 @@ +Subject: Je rol in InTheKnow is gewijzigd +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Je InTheKnow-account is nu beheerder. De beheerpagina's gaan open zodra verificatie in twee stappen aanstaat voor je account. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-signUpAttempt.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-signUpAttempt.en.txt new file mode 100644 index 0000000..60fd002 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-signUpAttempt.en.txt @@ -0,0 +1,10 @@ +Subject: Someone tried to sign up with your InTheKnow address +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Someone tried to create an InTheKnow account with this email address, but it already has one. Nothing was changed. If you forgot your password, you can reset it on the sign-in page. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-signUpAttempt.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-signUpAttempt.nl.txt new file mode 100644 index 0000000..bee2ada --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-signUpAttempt.nl.txt @@ -0,0 +1,10 @@ +Subject: Iemand probeerde zich aan te melden met je InTheKnow-adres +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Iemand probeerde een InTheKnow-account te maken met dit e-mailadres, maar dat heeft er al een. Er is niets veranderd. Ben je je wachtwoord vergeten, dan kun je het op de aanmeldpagina opnieuw instellen. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOff.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOff.en.txt new file mode 100644 index 0000000..8214695 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOff.en.txt @@ -0,0 +1,10 @@ +Subject: Two-step verification is off for your InTheKnow account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Two-step verification was turned off for your InTheKnow account, and your other devices were signed out. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOff.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOff.nl.txt new file mode 100644 index 0000000..6286b85 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOff.nl.txt @@ -0,0 +1,10 @@ +Subject: Verificatie in twee stappen staat uit voor je InTheKnow-account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Verificatie in twee stappen is uitgezet voor je InTheKnow-account, en je andere apparaten zijn afgemeld. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOffByAdmin.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOffByAdmin.en.txt new file mode 100644 index 0000000..26f1092 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOffByAdmin.en.txt @@ -0,0 +1,10 @@ +Subject: An admin turned off two-step verification for your InTheKnow account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +An admin removed two-step verification from your InTheKnow account, and all your devices were signed out. Sign in again and turn it back on. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOffByAdmin.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOffByAdmin.nl.txt new file mode 100644 index 0000000..56d267e --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOffByAdmin.nl.txt @@ -0,0 +1,10 @@ +Subject: Een beheerder heeft verificatie in twee stappen uitgezet voor je InTheKnow-account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Een beheerder heeft verificatie in twee stappen van je InTheKnow-account verwijderd, en al je apparaten zijn afgemeld. Meld je opnieuw aan en zet het weer aan. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOn.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOn.en.txt new file mode 100644 index 0000000..062d44e --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOn.en.txt @@ -0,0 +1,10 @@ +Subject: Two-step verification is on for your InTheKnow account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Two-step verification with an authenticator app was turned on for your InTheKnow account. Keep your recovery codes in a safe place. + +If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOn.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOn.nl.txt new file mode 100644 index 0000000..3eadaeb --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/account-twoFactorOn.nl.txt @@ -0,0 +1,10 @@ +Subject: Verificatie in twee stappen staat aan voor je InTheKnow-account +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Verificatie in twee stappen met een authenticator-app is aangezet voor je InTheKnow-account. Bewaar je herstelcodes op een veilige plek. + +Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt new file mode 100644 index 0000000..e95c5f2 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt @@ -0,0 +1,46 @@ +Subject: 14 timetable changes in 2 calendars +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: List-Unsubscribe=One-Click + +Minor robotics +Notification settings for this calendar: http://localhost:5173/calendars/1/notifications + +- Robotics + Tue 6 Oct 2026 08:30–10:00 + Rooms: AL00.A1.38 → AL00.A1.20 + detected at Thu 1 Oct 2026 14:00 +- Robotics (low priority) + Tue 6 Oct 2026 08:30–10:00 + Status: tentative → confirmed + detected at Thu 1 Oct 2026 14:00 +- Robotics + Tue 6 Oct 2026 08:30–10:00 → Tue 6 Oct 2026 09:30–11:00 + Time + detected at Thu 1 Oct 2026 14:00 +- Physics + Tue 6 Oct 2026 08:30–10:00 → Wed 7 Oct 2026 08:30–10:00 + Time + detected at Thu 1 Oct 2026 14:00 +- New lesson + Tue 6 Oct 2026 08:30–10:00 + Added: AL00.A1.20, AL00.A1.23 + detected at Thu 1 Oct 2026 14:00 +- Old lesson + Tue 6 Oct 2026 08:30–10:00 + Removed: Online + detected at Thu 1 Oct 2026 14:00 + +Electives +Notification settings for this calendar: http://localhost:5173/calendars/2/notifications + +- Elective (low priority) + Tue 6 Oct 2026 08:30–10:00 + Rooms: AL00.A1.38 → AL00.A1.20 + detected at Thu 1 Oct 2026 14:00 + +and 7 more changes + +You receive this email because this address was confirmed for InTheKnow timetable notifications. Use your mail client's unsubscribe option to stop these notifications to this address. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/changes.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/changes.nl.txt new file mode 100644 index 0000000..5ece51b --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/changes.nl.txt @@ -0,0 +1,46 @@ +Subject: 14 roosterwijzigingen in 2 roosters +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: List-Unsubscribe=One-Click + +Minor robotics +Meldingsinstellingen voor dit rooster: http://localhost:5173/calendars/1/notifications + +- Robotics + di 6 okt 2026 08:30–10:00 + Lokalen: AL00.A1.38 → AL00.A1.20 + gevonden op do 1 okt 2026 14:00 +- Robotics (lage prioriteit) + di 6 okt 2026 08:30–10:00 + Status: tentative → confirmed + gevonden op do 1 okt 2026 14:00 +- Robotics + di 6 okt 2026 08:30–10:00 → di 6 okt 2026 09:30–11:00 + Tijd + gevonden op do 1 okt 2026 14:00 +- Physics + di 6 okt 2026 08:30–10:00 → wo 7 okt 2026 08:30–10:00 + Tijd + gevonden op do 1 okt 2026 14:00 +- New lesson + di 6 okt 2026 08:30–10:00 + Toegevoegd: AL00.A1.20, AL00.A1.23 + gevonden op do 1 okt 2026 14:00 +- Old lesson + di 6 okt 2026 08:30–10:00 + Verwijderd: Online + gevonden op do 1 okt 2026 14:00 + +Electives +Meldingsinstellingen voor dit rooster: http://localhost:5173/calendars/2/notifications + +- Elective (lage prioriteit) + di 6 okt 2026 08:30–10:00 + Lokalen: AL00.A1.38 → AL00.A1.20 + gevonden op do 1 okt 2026 14:00 + +en nog 7 wijzigingen + +Je krijgt deze e-mail omdat dit adres is bevestigd voor roostermeldingen van InTheKnow. Gebruik de afmeldoptie van je mailprogramma om deze meldingen naar dit adres te stoppen. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/confirmation.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/confirmation.en.txt new file mode 100644 index 0000000..63ac27d --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/confirmation.en.txt @@ -0,0 +1,14 @@ +Subject: Confirm your InTheKnow notifications +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +A student added this email address to InTheKnow to receive notifications about their timetable changes. + +Confirm notifications for this address: http://localhost:5000/n/confirm/TOKEN + +This wasn't me: http://localhost:5000/n/decline/TOKEN + +The confirmation link expires after 48 hours. If you did not expect this email, choose "This wasn't me" and this address gets no more email from InTheKnow for this student. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/confirmation.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/confirmation.nl.txt new file mode 100644 index 0000000..c13d22a --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/confirmation.nl.txt @@ -0,0 +1,14 @@ +Subject: Bevestig je InTheKnow-meldingen +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: + +Een student heeft dit e-mailadres aan InTheKnow toegevoegd om meldingen over zijn of haar roosterwijzigingen te krijgen. + +Meldingen voor dit adres bevestigen: http://localhost:5000/n/confirm/TOKEN + +Dit was ik niet: http://localhost:5000/n/decline/TOKEN + +De bevestigingslink verloopt na 48 uur. Verwachtte je deze e-mail niet, kies dan "Dit was ik niet" en dit adres krijgt geen e-mail meer van InTheKnow voor deze student. + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/test.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/test.en.txt new file mode 100644 index 0000000..2aad855 --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/test.en.txt @@ -0,0 +1,10 @@ +Subject: InTheKnow test notification +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: List-Unsubscribe=One-Click + +This is a test notification from InTheKnow. It contains no changes. + +Notification settings: http://localhost:5173/notifications + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/test.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/test.nl.txt new file mode 100644 index 0000000..dcbb9cc --- /dev/null +++ b/backend/InTheKnow.Tests/EmailSnapshots/test.nl.txt @@ -0,0 +1,10 @@ +Subject: InTheKnow-testmelding +To: student@example.nl +From: "InTheKnow" +List-Unsubscribe: +List-Unsubscribe-Post: List-Unsubscribe=One-Click + +Dit is een testmelding van InTheKnow. Er staan geen wijzigingen in. + +Meldingsinstellingen: http://localhost:5173/notifications + From 66730aa27429e60e286d88e3833c621bb0270e79 Mon Sep 17 00:00:00 2001 From: Wjouter <34310151+Wjouter@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:27:12 +0200 Subject: [PATCH 2/3] docs(007): branded email layout spec, plan and tasks; amend 003 body contract Co-Authored-By: Claude Sonnet 5.5 --- .../contracts/messages.md | 2 +- .../checklists/requirements.md | 37 +++ .../contracts/email-html.md | 62 +++++ specs/007-branded-email-layout/data-model.md | 38 +++ specs/007-branded-email-layout/plan.md | 100 ++++++++ specs/007-branded-email-layout/quickstart.md | 39 +++ specs/007-branded-email-layout/research.md | 146 +++++++++++ specs/007-branded-email-layout/spec.md | 230 ++++++++++++++++++ specs/007-branded-email-layout/tasks.md | 183 ++++++++++++++ 9 files changed, 836 insertions(+), 1 deletion(-) create mode 100644 specs/007-branded-email-layout/checklists/requirements.md create mode 100644 specs/007-branded-email-layout/contracts/email-html.md create mode 100644 specs/007-branded-email-layout/data-model.md create mode 100644 specs/007-branded-email-layout/plan.md create mode 100644 specs/007-branded-email-layout/quickstart.md create mode 100644 specs/007-branded-email-layout/research.md create mode 100644 specs/007-branded-email-layout/spec.md create mode 100644 specs/007-branded-email-layout/tasks.md diff --git a/specs/003-notification-channels/contracts/messages.md b/specs/003-notification-channels/contracts/messages.md index 99bee0e..c4bf96a 100644 --- a/specs/003-notification-channels/contracts/messages.md +++ b/specs/003-notification-channels/contracts/messages.md @@ -11,7 +11,7 @@ HTML-encoded (FR-020). |---|---| | From | `Notifications:Email:FromName` `` (FR-022) | | To | the channel's address only. One recipient per message, no Cc or Bcc (FR-020). | -| Body | `multipart/alternative`: `text/plain` first, then `text/html` (FR-021). The HTML uses inline styles only, with no images, scripts or remote resources. | +| Body | `multipart/alternative`: `text/plain` first, then `text/html` (FR-021). The HTML uses inline styles plus one optional ` + + + + outer table 100% (bgcolor #f0f0f0) + wordmark row: "InThe" #1d1d1b + "Know" #e20e80, Lato 900, centred above the card + + card table: width 100%, max-width 600px, #ffffff, border 1px #e3e3e3, radius 2px + bar row: 4px #e20e80 + content cell: padding 32px (20px on phones), body 16px/1.5 #3d3d3d +

subject, 28px (24px on phones), #1d1d1b + … body by kind (below) + footer (if the kind has one): 12px #5a5a5a, below the card + +``` + +## Body by kind + +| Kind | Body | +|---|---| +| Changes | per calendar: `

` name, settings text link; change rows separated by 1px #e3e3e3; if `NotListed > 0`, "and N more changes" in a #f7f7f7 box. Footer: `changes.footer`. | +| Confirmation | intro paragraph; **button** `confirmation.confirm` → `ConfirmUrl`; text link `confirmation.decline` → `DeclineUrl`; `confirmation.expiry` in muted text. No footer. | +| Test | `test.body`; text link `test.settings` → `SettingsUrl`. No footer. | +| Account | body paragraph; if `Link`: **button** `account.{kind}.action`, then a muted line `account.linkFallback` + the full link. Footer: `account.footer`. | + +## Change row + +``` +[LABEL] Event name (bold) [low priority] +date/time (monospace) ← before → after for a time change +what: old → new | where it is/was ← omitted for a time change +detected at … (small, muted) +``` + +Label colours (text on background): added `#16673a/#e3f4ea`, removed `#a50d26/#fbe3e7`, field change +`#7a4a00/#fdf0d9`, low priority `#5a5a5a/#f0f0f0` (and the whole row in `#5a5a5a`). Calendar values in a row, and +calendar names, are at most 200 bytes once encoded, the "…" included when shortened. + +## Guarantees (checked by tests) + +- Text part, subject and headers byte-for-byte equal to the pre-feature snapshots (SC-004). +- Every inserted value encoded (SC-005), for every kind. +- Every colour in the HTML is in the palette; every text pair ≥ 4.5:1 except the wordmark (SC-003). +- HTML of a changes email with `Notifier.Listed` long Dutch changes, one calendar each, with values that grow when + encoded, < 100 000 UTF-8 bytes (SC-007). +- ``, the light-mode and format-detection metas and the 600px card are present in every kind. diff --git a/specs/007-branded-email-layout/data-model.md b/specs/007-branded-email-layout/data-model.md new file mode 100644 index 0000000..0226381 --- /dev/null +++ b/specs/007-branded-email-layout/data-model.md @@ -0,0 +1,38 @@ +# Data model: Branded email layout + +No new data is stored, and no table, column or migration changes. No personal field is added (Principle II). + +## Input (unchanged) + +The email is rendered from the existing `Message` (`backend/InTheKnow/ChannelType.cs`, contract +[003 channel-type.md](../003-notification-channels/contracts/channel-type.md#message)): kind, language, time zone, +confirm/decline/unsubscribe/settings URLs, calendar groups with their `ChangeItem`s, `NotListed`, and for account +emails the account kind, link and args. + +## Derived per change row (HTML part only) + +| Part | Source | Rule | +|---|---|---| +| Label text | `kind.added` / `kind.removed` / the field's kind name (as in the text part) | shown in capitals | +| Label colour | change kind and `LowPriority` | low priority → grey; added → green; removed → red; any `changed` field → amber | +| Event name | `ChangeView.EventName` | bold; "low priority" text after it when `LowPriority`; ≤ 200 encoded bytes incl. "…" | +| When | same value as the text part's date line | monospaced | +| What | added/removed: where it is/was; changed: old → new; time: omitted | each value ≤ 200 encoded bytes incl. "…" | +| Detected | `changes.detectedAt` | small, muted | + +## Texts + +One new key in both languages (FR-016, SC-006), HTML part only: + +| Key | en | nl | +|---|---|---| +| `account.linkFallback` | Button not working? Copy this link into your browser: | Werkt de knop niet? Kopieer deze link naar je browser: | + +The link itself follows the text as an encoded, selectable `` (so it stays copyable). The existing +`LanguagesHaveTheSameKeys` test covers both languages. + +## Palette (named constants, R9) + +`ink #1d1d1b`, `magenta #e20e80` (bar, button, wordmark), `link #c70b70`, `body #3d3d3d`, `muted #5a5a5a`, +`page #f0f0f0`, `card #ffffff`, `border #e3e3e3`, `box #f7f7f7`, label pairs `#16673a/#e3f4ea`, +`#a50d26/#fbe3e7`, `#7a4a00/#fdf0d9`, `#5a5a5a/#f0f0f0`. diff --git a/specs/007-branded-email-layout/plan.md b/specs/007-branded-email-layout/plan.md new file mode 100644 index 0000000..7fe0787 --- /dev/null +++ b/specs/007-branded-email-layout/plan.md @@ -0,0 +1,100 @@ +# Implementation Plan: Branded email layout + +**Branch**: `007-branded-email-layout` (work currently on `main`) | **Date**: 2026-10-20 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/007-branded-email-layout/spec.md` + +## Summary + +Every email InTheKnow sends gets one branded HTML layout: a type-only wordmark, a centred white card of at most +600px with a magenta top bar, the subject as heading, colour-coded change rows, a magenta button for the main +action and the footer below the card. The work is a rewrite of the HTML side of the existing hand-written +`Renderer` in `backend/InTheKnow/EmailChannel.cs`, using table layout with MSO fallbacks for classic Outlook. The +plain-text part, subjects and headers stay byte-for-byte the same, proven by snapshots taken before the change. + +## Technical Context + +**Language/Version**: C# on .NET 10 (`global.json`) + +**Primary Dependencies**: BCL only (`System.Net.Mail`, `System.Text.Encodings.Web`); no new package + +**Storage**: N/A (no schema change) + +**Testing**: xUnit v3 (`backend/InTheKnow.Tests`), Mailpit (already in `compose.yaml`) for manual client checks + +**Target Platform**: email apps: classic Outlook 2016+ and new Outlook (Windows), Outlook web/Mac, Gmail web/apps, +Apple Mail macOS/iOS + +**Project Type**: web application (backend change only) + +**Performance Goals**: N/A (render cost is trivial next to SMTP) + +**Constraints**: HTML part < 100 KB at `Notifier.Listed` changes (each row value and calendar name ≤ 200 encoded bytes in HTML); +inline styles plus one optional ``; ``; outer ``; the wordmark row ("InThe" in ink + "Know" in magenta, `font-family:Lato,Helvetica,Arial,sans-serif;font-weight:900`, centred, no image); the MSO ghost table ``; the card table (`width="100%"`, `style="max-width:600px"`, `bgcolor="#ffffff"`, `border:1px solid #e3e3e3;border-radius:2px`, centred); a 4px bar row (`
`); the content cell (padding 32px, phone class, `font-family:Lato,Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5;color:#3d3d3d;word-break:break-word;overflow-wrap:anywhere`); `

` with the encoded subject (`Subject(m)`), 28px, weight 900, ink, phone class; then the body; then, if a footer is given, a row below the card with the footer in 12px `#5a5a5a`. No element below the card has a fixed width (R5). Colours go both as `bgcolor` and inline `background-color` where Outlook needs it (R1). +- [X] T007 In the `Renderer` in `EmailChannel.cs`: restyle `Paragraph` HTML (`margin:0 0 16px`) and `Link` HTML (link `color:#c70b70;text-decoration:underline`), leaving both text sides exactly as they are. Add `Footer(string s)` that appends to `text` exactly what `Paragraph` appends today (`s + "\n\n"`) and writes the HTML to a separate footer `StringBuilder` passed to `Document` (R7). Add an HTML-only `ButtonHtml(label, url)` helper per research.md R3: one-cell ``, `
`, `` (44px tall). Every inserted value stays encoded through `E(...)`. +- [X] T008 Add the encoding test in `backend/InTheKnow.Tests/EmailChannelTests.cs` (extend `CalendarTextIsEncoded` and `ArgsAreEncoded` in `backend/InTheKnow.Tests/AccountEmailTests.cs`): for every kind, a value containing `x & "q" 'a'` (calendar name, event name, values, account args) appears encoded in the HTML part and the raw `` never appears (SC-005). +- [X] T009 Add the palette and shell tests in `backend/InTheKnow.Tests/EmailChannelTests.cs`: (a) every declared text/background pair from T005, except the exempt wordmark, has WCAG 2.x contrast ≥ 4.5 (compute relative luminance in the test, ~10 lines); (b) for every fixture from T001, every `#[0-9a-fA-F]{6}` found in the HTML part is one of the palette constants (SC-003, FR-021); (c) for every fixture from T001, the HTML part has ``, the `format-detection` meta with `telephone=no,date=no,address=no,email=no`, the `color-scheme` and `supported-color-schemes` light metas, `max-width:600px` on the card and the MSO ghost table at `width="600"` (FR-004, FR-006, FR-019, FR-020). + +**Checkpoint**: shell in place; snapshot test still passes (text unchanged). + +--- + +## Phase 3: User Story 1 - Timetable changes are easy to scan (Priority: P1) 🎯 MVP + +**Goal**: Change notifications render as colour-coded rows grouped per calendar inside the card. + +**Independent Test**: render a changes email with every kind across two calendars and `NotListed > 0`; check structure in tests and look at it in Mailpit. + +### Tests for User Story 1 + +- [X] T010 [US1] Rewrite the HTML assertions in `RendersParts`, `AddedAndRemovedEvents`, `MultipleCalendarsSubject`, `DutchChangesEmail`, `MoreThanFifty`, `TimeZones` and `SettingsLinksAndNoActionLinks` in `backend/InTheKnow.Tests/EmailChannelTests.cs` so they assert structure instead of joined strings like `Rooms: AL00.A1.38 → AL00.A1.20` in HTML (plan ordering note 3): `

` contains the subject; one `

` per calendar in order followed by its settings link; each row has the label text with its colour pair (added green, removed red, field amber, low priority grey), the event name in ``, the date in a monospace element, old and new value present separately, "detected at" in muted text; time change rows have no "what" line; the "and N more changes" text sits in a `#f7f7f7` box; `changes.footer` comes after the card's closing. Keep the existing text-part assertions unchanged. +- [X] T011 [P] [US1] Add `LongValuesAreShortenedInHtml` in `backend/InTheKnow.Tests/EmailChannelTests.cs`: an event name, old/new value, room list and calendar name of 1000 characters each appear in the HTML as an encoded prefix ending in "…" whose UTF-8 byte count (prefix + "…") is ≤ 200, and in full in the text part (FR-022). Cases: plain ASCII (prefix is 197 characters), `"&<>'` repeated (no entity cut: the HTML around the value still parses, the prefix ends in a whole `&…;`), emoji and CJK (no surrogate pair cut); a value that already fits is not shortened. +- [X] T012 [P] [US1] Add `ChangesHtmlStaysUnder100KB` in `backend/InTheKnow.Tests/EmailChannelTests.cs`: `Notifier.Listed` changes in `nl`, each in its own calendar with a 1000-character name, mixed kinds (added, removed, time, rooms, description) and low priority, every calendar value 1000 characters of text that grows when encoded (a mix of `"&<>'`, emoji and CJK), `NotListed > 0`; assert `Encoding.UTF8.GetByteCount(html) < 100_000` on the decoded HTML string (SC-007, research.md R8). If it fails, slim the row markup; do not raise the 200-byte cap. + +### Implementation for User Story 1 + +- [X] T013 [US1] Add an HTML-only `Short(string s)` helper in the `Renderer` in `EmailChannel.cs` that returns the *encoded* value: `E(s)` when its UTF-8 byte count is ≤ 200, else the encoded text elements of `s` (walk with `StringInfo.GetTextElementEnumerator`, sum each element's encoded byte count) up to 197 bytes followed by "…" — so no character or entity is cut (R8). Use it instead of `E(...)` for event name, old value, new value and where in `Change`, and for the calendar name in `Calendar`'s `

` (T014). +- [X] T014 [US1] Rewrite `Calendar(CalendarGroup g)` HTML in `EmailChannel.cs`: `

` with the calendar name through `Short` (T013) (ink, bold, ~20px, top margin), then the settings text link (`changes.settings`, magenta underlined, 14px). The text side stays exactly as it is. +- [X] T015 [US1] Rewrite `Change(ChangeItem item)` HTML in `EmailChannel.cs` per contracts/email-html.md "Change row", keeping the text side exactly as it is: split the HTML into parts computed alongside today's `When`/`What` strings (do not change those strings, they feed the text part): label = `kind.added` / `kind.removed` / `kind.time` / `KindName(field)` in small bold capitals (`text-transform:uppercase;font-size:12px;font-weight:700;letter-spacing`, padding 2px 6px) with the colour pair from T005 (low priority overrides to grey); event name shortened, bold, ink; when low priority, `changes.lowPriority` muted after the name; date line in `font-family:Menlo,Consolas,'Courier New',monospace` (for a time change the shortened before → after); what line = shortened old → new for a field change, shortened where for added/removed (omit the label prefix, the label already says it), omitted for a time change; "detected at" 12px `#5a5a5a`. Rows separated by `border-top:1px solid #e3e3e3`; a low priority row has all text in `#5a5a5a`. Use table cells or block elements, no fixed widths. +- [X] T016 [US1] In the `MessageKind.Changes` case of `EmailChannel.cs`: write `changes.more` as text exactly as today (`Paragraph` text side) but in HTML inside a `#f7f7f7` box (padding 12px 16px, `bgcolor` + background) after the last section; write `changes.footer` through `Footer(...)` so it lands below the card. Pass the body and footer builders to `Document`. + +**Checkpoint**: US1 tests, snapshot, encoding and palette tests pass. + +--- + +## Phase 4: User Story 2 - Account and confirmation emails have one clear action (Priority: P2) + +**Goal**: The main action is a magenta button; account buttons get a copyable fallback link line. + +**Independent Test**: render the confirmation and every account kind; check button, fallback line and footer in tests and Mailpit. + +### Tests for User Story 2 + +- [X] T017 [P] [US2] Update `ConfirmationEmail` in `backend/InTheKnow.Tests/EmailChannelTests.cs`: the HTML has one button (cell `bgcolor="#e20e80"`) whose `href` is `ConfirmUrl` and text `confirmation.confirm`; `confirmation.decline` is a text link to `DeclineUrl` after it; `confirmation.expiry` in `#5a5a5a`; no footer. +- [X] T018 [P] [US2] Update `EveryKindRendersInBothLanguages` (and add assertions where needed) in `backend/InTheKnow.Tests/AccountEmailTests.cs`: a kind with `Link` has one button with `account.{kind}.action`, then `account.linkFallback` followed by an `` whose text and `href` are the full link; a kind without `Link` has no button and no fallback text; `account.footer` appears after the card in every account email. + +### Implementation for User Story 2 + +- [X] T019 [US2] In the `MessageKind.Confirmation` case of `EmailChannel.cs`: the confirm link keeps the text format `label: url` (today's `Link` text side) but renders `ButtonHtml` in HTML (plan ordering note 2, R3 trap) — e.g. a `Link(label, url, button: true)` overload or a `PrimaryLink` method that appends the `Link` text and the button HTML. Decline stays `Link`; expiry becomes a muted paragraph (HTML `color:#5a5a5a`, text unchanged). +- [X] T020 [US2] In the `Renderer.Button` and `MessageKind.Account` case of `EmailChannel.cs`: `Button` keeps its text `label:\nurl` and writes `ButtonHtml` plus, HTML-only, a muted 14px paragraph `account.linkFallback` + an encoded `` with the full link as text (`word-break` inherited so it wraps). `account.footer` goes through `Footer(...)` so it lands below the card. +- [X] T021 [P] [US2] Check `specs/004-accounts-login/contracts/account-emails.md` for wording about the HTML shape (button, footer placement); amend only if it describes the HTML, linking to `specs/007-branded-email-layout/contracts/email-html.md` (plan ordering note 4). + +**Checkpoint**: US1 and US2 tests pass; snapshot unchanged. + +--- + +## Phase 5: User Story 3 - The test notification looks like the real thing (Priority: P3) + +**Goal**: The test email uses the same shell. + +**Independent Test**: render a test email in both languages. + +- [X] T022 [US3] Update `TestEmail` in `backend/InTheKnow.Tests/EmailChannelTests.cs`: HTML has the wordmark, `

` with the subject, `test.body`, and a magenta text link (not a button) `test.settings` → `SettingsUrl`; no footer. +- [X] T023 [US3] Confirm the `MessageKind.Test` case in `EmailChannel.cs` renders through `Document` with no footer; no other change should be needed after Phase 2. + +**Checkpoint**: all kinds render in the new layout. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T024 [P] Amend the "Body" row of `specs/003-notification-channels/contracts/messages.md`: the HTML part may contain one optional `"; + + static string Document(Message m, string subject, string body, string footer) + { + if (footer != "") footer = $"
{footer}
"; + return $$""" + + + + {{Css}} + + +
+
InTheKnow
+ + + +
 
+

{{Encoder.Encode(subject)}}

{{body}}
+ {{footer}} + +
+ """; + } + + // The HTML encoder, shared by the shell and the renderer. + static readonly HtmlEncoder Encoder = HtmlEncoder.Create(UnicodeRanges.All); + // roleChanged and invite have one body per role. static string AccountBody(Message m) => m.AccountKind switch { @@ -135,41 +180,79 @@ _ when Count(m) == 1 => Texts.Format(m.Language, "subject.change", m.Calendars[0 static int Count(Message m) => m.Calendars.Sum(g => g.Changes.Count) + m.NotListed; // Writes the text and HTML parts side by side. Every inserted value is HTML-encoded in the HTML part (FR-020). - sealed class Renderer(Message m, StringBuilder text, StringBuilder html) + sealed class Renderer(Message m, StringBuilder text, StringBuilder html, StringBuilder footer) { // Every character range allowed, so Dutch text and arrows stay readable; markup characters are still encoded. - static readonly HtmlEncoder Encoder = HtmlEncoder.Create(UnicodeRanges.All); static string E(string s) => Encoder.Encode(s); + // E(s) cut to MaxValueBytes encoded, whole text elements only, so no character or entity is split (R8). + static string Short(string s) + { + var e = E(s); + if (Encoding.UTF8.GetByteCount(e) <= MaxValueBytes) return e; + var (cut, bytes, parts) = (new StringBuilder(), 0, StringInfo.GetTextElementEnumerator(s)); + while (parts.MoveNext()) + { + var part = E((string)parts.Current); + var n = Encoding.UTF8.GetByteCount(part); + if (bytes + n > MaxValueBytes - 3) break; + bytes += n; + cut.Append(part); + } + return cut.Append('…').ToString(); + } + public string T(string key) => Texts.Get(m.Language, key); - public void Paragraph(string s) + public void Paragraph(string s, bool muted = false) { text.Append(s).Append("\n\n"); - html.Append("

").Append(E(s)).Append("

"); + html.Append("

").Append(E(s)).Append("

"); } - public void Link(string label, string url) + // After the card, not inside it (FR-008). + public void Footer(string s) + { + text.Append(s).Append("\n\n"); + footer.Append("

").Append(E(s)).Append("

"); + } + + // A note in a grey box. + public void Box(string s) + { + text.Append(s).Append("\n\n"); + html.Append($"
") + .Append(E(s)).Append("
"); + } + + // The text part is always "label: url"; the HTML part is a text link, or the main action's button. + public void Link(string label, string url, bool button = false) { text.Append(label).Append(": ").Append(url).Append("\n\n"); - html.Append("

").Append(E(label)).Append("

"); + if (button) ButtonHtml(label, url); + else html.Append("

").Append(Anchor(label, url)).Append("

"); } - // The link as a button in HTML, and on a line of its own in text. + // The link on a line of its own in text; a button and a copyable fallback line in HTML. public void Button(string label, string url) { text.Append(label).Append(":\n").Append(url).Append("\n\n"); - html.Append("

") - .Append(E(label)).Append("

"); + ButtonHtml(label, url); + html.Append($"

").Append(E(T("account.linkFallback"))).Append("
").Append(Anchor(url, url)).Append("

"); } + static string Anchor(string label, string url) => + $"{E(label)}"; + + // A bulletproof button: a coloured cell around a padded link (research R3). + void ButtonHtml(string label, string url) => html.Append($"
") + .Append($"{E(label)}
"); + public void Calendar(CalendarGroup g) { text.Append(g.Name).Append('\n').Append(T("changes.settings")).Append(": ").Append(g.SettingsUrl).Append("\n\n"); - html.Append("

").Append(E(g.Name)).Append("

") - .Append("

") - .Append(E(T("changes.settings"))).Append("

"); + html.Append($"

").Append(Short(g.Name)).Append("

") + .Append("

").Append(Anchor(T("changes.settings"), g.SettingsUrl)).Append("

"); foreach (var item in g.Changes) Change(item); text.Append('\n'); } @@ -181,13 +264,39 @@ void Change(ChangeItem item) string[] lines = [When(c), What(c), Texts.Format(m.Language, "changes.detectedAt", Date(c.DetectedAt))]; text.Append("- ").Append(title).Append('\n'); foreach (var l in lines) text.Append(" ").Append(l).Append('\n'); - html.Append("
").Append(E(c.EventName)).Append(""); - if (item.LowPriority) html.Append(" (").Append(E(T("changes.lowPriority"))).Append(")"); - foreach (var l in lines) html.Append("
").Append(E(l)); - html.Append("
"); + + var (label, fg, bg) = (c.Kind, item.LowPriority) switch + { + (_, true) => (LabelText(c), Muted, Page), + ("added", _) => (LabelText(c), AddedFg, AddedBg), + ("removed", _) => (LabelText(c), RemovedFg, RemovedBg), + _ => (LabelText(c), FieldFg, FieldBg), + }; + var what = c.Kind switch + { + "added" or "removed" => WhereOf(c) is { } w ? Short(w) : null, + _ when c.Field == "time" => null, + _ => $"{Short(Value(c.OldValue))} → {Short(Value(c.NewValue))}", + }; + html.Append($"
") + .Append($"").Append(E(label)).Append(" ") + .Append("').Append(Short(c.EventName)).Append(""); + if (item.LowPriority) html.Append(" (").Append(E(T("changes.lowPriority"))).Append(")"); + html.Append($"
").Append(Short(lines[0])).Append("
"); + if (what is not null) html.Append("
").Append(what).Append("
"); + html.Append($"
").Append(E(lines[2])).Append("
"); } + string LabelText(ChangeView c) => c.Kind switch + { + "added" or "removed" => T("kind." + c.Kind), + _ when c.Field == "time" => T("kind.time"), + _ => KindName(c.Field!), + }; + + // Where an added or removed event is or was (FR-019). + string? WhereOf(ChangeView c) => (c.Kind == "added" ? c.NewValue : c.OldValue)?["fields"] is JsonObject f ? Where(f) : null; + // The event's date and time; before → after for a time change. string When(ChangeView c) => c is { Kind: "changed", Field: "time" } ? $"{Value(c.OldValue)} → {Value(c.NewValue)}" @@ -199,7 +308,7 @@ string When(ChangeView c) => c is { Kind: "changed", Field: "time" } string What(ChangeView c) => c.Kind switch { // FR-019: an added or removed event shows where it is or was. - "added" or "removed" => (c.Kind == "added" ? c.NewValue : c.OldValue)?["fields"] is JsonObject f && Where(f) is { } w + "added" or "removed" => WhereOf(c) is { } w ? $"{T("kind." + c.Kind)}: {w}" : T("kind." + c.Kind), _ when c.Field == "time" => T("kind.time"), diff --git a/backend/InTheKnow/Notifier.cs b/backend/InTheKnow/Notifier.cs index f3e844c..5a1ac47 100644 --- a/backend/InTheKnow/Notifier.cs +++ b/backend/InTheKnow/Notifier.cs @@ -16,7 +16,7 @@ sealed record TypeStatus(string Type, bool Configured, DateTimeOffset? LastAttem sealed class Notifier(NpgsqlDataSource db, IEnumerable types, Presets presets, Channels channels, Accounts accounts, Settings settings, TimeProvider time, ILogger log) : BackgroundService { - const int Listed = 50; + internal const int Listed = 50; static readonly TimeSpan IdleSleep = TimeSpan.FromSeconds(1), ExpiryEvery = TimeSpan.FromMinutes(1), CleanupEvery = TimeSpan.FromMinutes(10); static readonly TimeSpan ConfirmationValidity = TimeSpan.FromHours(48), MaxBackoff = TimeSpan.FromHours(1); // Account emails that are sent even while the account is disabled (FR-033c). diff --git a/backend/InTheKnow/Texts.cs b/backend/InTheKnow/Texts.cs index 917dc65..791fa1f 100644 --- a/backend/InTheKnow/Texts.cs +++ b/backend/InTheKnow/Texts.cs @@ -100,6 +100,7 @@ static class Texts ["account.invite.body.user"] = "You are invited to create an account on InTheKnow, which tells you about changes in your timetable. Open the link to choose a password. The invitation is valid until {0}.", ["account.invite.body.admin"] = "You are invited to create an admin account on InTheKnow. Open the link to choose a password. The admin pages open once two-step verification is on. The invitation is valid until {0}.", ["account.invite.action"] = "Accept the invitation", + ["account.linkFallback"] = "Button not working? Copy this link into your browser:", ["account.footer"] = "If you did not expect this email, you can ignore it; if something changed in your account that you did not do, reset your password or reply to this email to reach the operator.", }, ["nl"] = new() @@ -194,6 +195,7 @@ static class Texts ["account.invite.body.user"] = "Je bent uitgenodigd om een account te maken bij InTheKnow, dat je laat weten wanneer je rooster verandert. Open de link om een wachtwoord te kiezen. De uitnodiging is geldig tot {0}.", ["account.invite.body.admin"] = "Je bent uitgenodigd om een beheerdersaccount te maken bij InTheKnow. Open de link om een wachtwoord te kiezen. De beheerpagina's gaan open zodra verificatie in twee stappen aanstaat. De uitnodiging is geldig tot {0}.", ["account.invite.action"] = "Uitnodiging accepteren", + ["account.linkFallback"] = "Werkt de knop niet? Kopieer deze link naar je browser:", ["account.footer"] = "Verwachtte je deze e-mail niet, dan kun je hem negeren; is er iets in je account veranderd dat je niet zelf deed, stel dan je wachtwoord opnieuw in of beantwoord deze e-mail om de beheerder te bereiken.", }, };