diff --git a/.gitignore b/.gitignore index e8444ec..e06a335 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Claude Code: personal settings stay local, shared settings/skills get committed .claude/settings.local.json +.claude/launch.json # Secrets / env .env diff --git a/backend/InTheKnow.Tests/EmailChannelTests.cs b/backend/InTheKnow.Tests/EmailChannelTests.cs index 43cf04e..898933e 100644 --- a/backend/InTheKnow.Tests/EmailChannelTests.cs +++ b/backend/InTheKnow.Tests/EmailChannelTests.cs @@ -180,7 +180,7 @@ public void ValuesAreEncodedInEveryKind() var messages = new[] { new Message(MessageKind.Confirmation, "en", Amsterdam, url, url, null, null, [], 0), - new Message(MessageKind.Test, "en", Amsterdam, null, null, null, url, [], 0), + new Message(MessageKind.Test, "en", Amsterdam, null, null, url, url, [], 0), Changes("en", Amsterdam, 0, new CalendarGroup(1, evil, url, [new ChangeItem(View(9, "changed", "name", evil, evil, name: evil), false)])), }; foreach (var m in messages) @@ -340,22 +340,41 @@ public async Task SmtpFailuresMapToResults() Assert.Equal(new SendResult(SendStatus.TypeUnavailable, "unreachable"), await closed.SendAsync("ok@example.nl", Test(), Ct)); } - // US2-1, FR-009, SC-003: per-calendar settings links; the only /n/ URL is the List-Unsubscribe header. + // US2-1, FR-009, SC-003 (as amended by 008): per-calendar settings links; the only /n/ URL in either part is exactly UnsubscribeUrl. [Fact] public void SettingsLinksAndNoActionLinks() { var (text, html, mail) = Render(Changes("en", Amsterdam, 0, Group(1, "X", Room), Group(2, "Y", Status))); + Assert.Contains(Texts.Get("en", "changes.footer"), text); + Assert.Contains(Encoder.Encode(Texts.Get("en", "changes.footer")), html); foreach (var part in new[] { text, html }) { Assert.Contains($"{Frontend}/calendars/1/notifications", part); Assert.Contains($"{Frontend}/calendars/2/notifications", part); - Assert.Contains("unsubscribe option to stop these notifications", part); // the footer - Assert.All(Url().Matches(part), u => Assert.Matches($"^{Regex.Escape(Frontend)}/calendars/[12]/notifications$", u.Value)); + Assert.Contains($"{Public}/n/unsubscribe/UNSUB", part); // the footer link + Assert.All(Url().Matches(part), u => Assert.Matches($"^({Regex.Escape(Frontend)}/calendars/[12]/notifications|{Regex.Escape(Public)}/n/unsubscribe/UNSUB)$", u.Value)); + Assert.DoesNotContain("/n/confirm", part); + Assert.DoesNotContain("/n/decline", part); } Assert.Contains("/n/unsubscribe/", mail.Headers["List-Unsubscribe"]); AssertFooterBelowCard(html, Texts.Get("en", "changes.footer")); } + // 008 FR-001, FR-002, FR-004: the footer link, below the footer sentence, goes where List-Unsubscribe goes. + [Theory] + [InlineData(false, "en")] + [InlineData(false, "nl")] + [InlineData(true, "en")] + [InlineData(true, "nl")] + public void FooterLinkMatchesListUnsubscribe(bool test, string language) + { + var (text, html, mail) = Render(test ? Test(language) : Changes(language, Amsterdam, 0, Group(1, "X", Room))); + var url = mail.Headers["List-Unsubscribe"]!.Trim('<', '>'); + var (footer, label) = (Texts.Get(language, "changes.footer"), Texts.Get(language, "page.unsubscribe.title")); + Assert.Contains($"{footer}\n\n{label}: {url}\n\n", text); + Assert.Contains($"{Encoder.Encode(footer)}

{Encoder.Encode(label)}

", html); + } + // SC-009, research R9. [Fact] public void LanguagesHaveTheSameKeys() => Assert.Equal(Texts.Keys("en").Order(), Texts.Keys("nl").Order()); @@ -441,12 +460,12 @@ public void TestEmail(string language, string subject) Assert.Contains(Texts.Get(language, "test.body"), part); Assert.Contains($"{Frontend}/notifications", part); } - Assert.Equal([$"{Frontend}/notifications"], Url().Matches(text).Select(u => u.Value)); + Assert.Equal([$"{Frontend}/notifications", $"{Public}/n/unsubscribe/UNSUB"], Url().Matches(text).Select(u => u.Value)); Assert.Matches("]*>" + Regex.Escape(subject) + "", html); Assert.Contains("InThe", html); Assert.DoesNotContain("mso-padding-alt", html); // a text link, not a button - Assert.DoesNotContain("max-width:600px;margin:0 auto;padding:16px 4px 0", html); // no footer + AssertFooterBelowCard(html, Texts.Get(language, "changes.footer")); } static JsonObject RangeOf(string start, string end) => new() { ["start"] = start, ["end"] = end }; diff --git a/backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt index e95c5f2..71a8f6a 100644 --- a/backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt +++ b/backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt @@ -42,5 +42,7 @@ Notification settings for this calendar: http://localhost:5173/calendars/2/notif 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. +You receive this email because this address was confirmed for InTheKnow timetable notifications. To stop these notifications to this address, use the link below or your mail client's unsubscribe option. + +Unsubscribe: http://localhost:5000/n/unsubscribe/UNSUB diff --git a/backend/InTheKnow.Tests/EmailSnapshots/changes.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/changes.nl.txt index 5ece51b..09fdb59 100644 --- a/backend/InTheKnow.Tests/EmailSnapshots/changes.nl.txt +++ b/backend/InTheKnow.Tests/EmailSnapshots/changes.nl.txt @@ -42,5 +42,7 @@ Meldingsinstellingen voor dit rooster: http://localhost:5173/calendars/2/notific 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. +Je krijgt deze e-mail omdat dit adres is bevestigd voor roostermeldingen van InTheKnow. Gebruik de link hieronder of de afmeldoptie van je mailprogramma om deze meldingen naar dit adres te stoppen. + +Afmelden: http://localhost:5000/n/unsubscribe/UNSUB diff --git a/backend/InTheKnow.Tests/EmailSnapshots/test.en.txt b/backend/InTheKnow.Tests/EmailSnapshots/test.en.txt index 2aad855..31d03c7 100644 --- a/backend/InTheKnow.Tests/EmailSnapshots/test.en.txt +++ b/backend/InTheKnow.Tests/EmailSnapshots/test.en.txt @@ -8,3 +8,7 @@ This is a test notification from InTheKnow. It contains no changes. Notification settings: http://localhost:5173/notifications +You receive this email because this address was confirmed for InTheKnow timetable notifications. To stop these notifications to this address, use the link below or your mail client's unsubscribe option. + +Unsubscribe: http://localhost:5000/n/unsubscribe/UNSUB + diff --git a/backend/InTheKnow.Tests/EmailSnapshots/test.nl.txt b/backend/InTheKnow.Tests/EmailSnapshots/test.nl.txt index dcbb9cc..bbdfea8 100644 --- a/backend/InTheKnow.Tests/EmailSnapshots/test.nl.txt +++ b/backend/InTheKnow.Tests/EmailSnapshots/test.nl.txt @@ -8,3 +8,7 @@ Dit is een testmelding van InTheKnow. Er staan geen wijzigingen in. Meldingsinstellingen: http://localhost:5173/notifications +Je krijgt deze e-mail omdat dit adres is bevestigd voor roostermeldingen van InTheKnow. Gebruik de link hieronder of de afmeldoptie van je mailprogramma om deze meldingen naar dit adres te stoppen. + +Afmelden: http://localhost:5000/n/unsubscribe/UNSUB + diff --git a/backend/InTheKnow.Tests/PublicPagesTests.cs b/backend/InTheKnow.Tests/PublicPagesTests.cs index 1ef33fe..73ba282 100644 --- a/backend/InTheKnow.Tests/PublicPagesTests.cs +++ b/backend/InTheKnow.Tests/PublicPagesTests.cs @@ -107,7 +107,7 @@ public async Task UnknownTokens(string action) } } - // US2-4, FR-006, FR-010: links in emails change nothing until the button is used. + // US2-4, FR-006, FR-010, SC-003 (008), FR-003 (008): links in emails, the footer unsubscribe link too, change nothing until the button is used. [Fact] public async Task DeclineAndUnsubscribePages() { diff --git a/backend/InTheKnow/EmailChannel.cs b/backend/InTheKnow/EmailChannel.cs index 792fcc5..ded8090 100644 --- a/backend/InTheKnow/EmailChannel.cs +++ b/backend/InTheKnow/EmailChannel.cs @@ -85,10 +85,13 @@ internal static MailMessage Build(string target, Message m, EmailOptions o) foreach (var g in m.Calendars) r.Calendar(g); if (m.NotListed > 0) r.Box(Texts.Format(m.Language, "changes.more", m.NotListed)); r.Footer(r.T("changes.footer")); + r.FooterLink(r.T("page.unsubscribe.title"), m.UnsubscribeUrl!); break; case MessageKind.Test: r.Paragraph(r.T("test.body")); r.Link(r.T("test.settings"), m.SettingsUrl!); + r.Footer(r.T("changes.footer")); + r.FooterLink(r.T("page.unsubscribe.title"), m.UnsubscribeUrl!); break; case MessageKind.Account: r.Paragraph(Texts.Format(m.Language, AccountBody(m), AccountValue(m))); @@ -217,6 +220,13 @@ public void Footer(string s) footer.Append("

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

"); } + // Muted, not LinkColor: (Muted, Page) is a checked TextPairs pair (specs/008 research R4). + public void FooterLink(string label, string url) + { + text.Append(label).Append(": ").Append(url).Append("\n\n"); + footer.Append($"

{E(label)}

"); + } + // A note in a grey box. public void Box(string s) { diff --git a/backend/InTheKnow/Texts.cs b/backend/InTheKnow/Texts.cs index 791fa1f..075fe41 100644 --- a/backend/InTheKnow/Texts.cs +++ b/backend/InTheKnow/Texts.cs @@ -24,7 +24,7 @@ static class Texts ["changes.lowPriority"] = "low priority", ["changes.detectedAt"] = "detected at {0}", ["changes.more"] = "and {0} more changes", - ["changes.footer"] = "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.", + ["changes.footer"] = "You receive this email because this address was confirmed for InTheKnow timetable notifications. To stop these notifications to this address, use the link below or your mail client's unsubscribe option.", ["test.body"] = "This is a test notification from InTheKnow. It contains no changes.", ["test.settings"] = "Notification settings", @@ -120,7 +120,7 @@ static class Texts ["changes.lowPriority"] = "lage prioriteit", ["changes.detectedAt"] = "gevonden op {0}", ["changes.more"] = "en nog {0} wijzigingen", - ["changes.footer"] = "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.", + ["changes.footer"] = "Je krijgt deze e-mail omdat dit adres is bevestigd voor roostermeldingen van InTheKnow. Gebruik de link hieronder of de afmeldoptie van je mailprogramma om deze meldingen naar dit adres te stoppen.", ["test.body"] = "Dit is een testmelding van InTheKnow. Er staan geen wijzigingen in.", ["test.settings"] = "Meldingsinstellingen", diff --git a/specs/003-notification-channels/contracts/messages.md b/specs/003-notification-channels/contracts/messages.md index c4bf96a..6a3f1e0 100644 --- a/specs/003-notification-channels/contracts/messages.md +++ b/specs/003-notification-channels/contracts/messages.md @@ -39,9 +39,10 @@ HTML-encoded (FR-020). - a "low priority" marker when `LowPriority` is true (FR-013); - "detected at" and the detection time. - **Beyond 50**: when `NotListed > 0`, a closing line says "and N more changes" / "en nog N wijzigingen" (FR-015). -- **Footer**: why the recipient gets this, and that the mail client's unsubscribe option stops these notifications to this - address. -- **Links**: none that change anything. The settings links lead to the frontend, which requires login (SC-003). +- **Footer**: why the recipient gets this, and that the link below or the mail client's unsubscribe option stops these + notifications to this address; then the line `{page.unsubscribe.title}: {UnsubscribeUrl}` (008). +- **Links**: none that change anything, except the footer unsubscribe link, which opens the one-button unsubscribe page + for this channel (008). The settings links lead to the frontend, which requires login (SC-003). Change kinds shown (translated): added, removed, name, time, status, location, description, activity type, rooms, teachers, groups, and `details.