Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 25 additions & 6 deletions backend/InTheKnow.Tests/EmailChannelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)}</p><p style=\"margin:0 0 12px\"><a href=\"{Encoder.Encode(url)}\" style=\"color:{EmailChannel.Muted};text-decoration:underline\">{Encoder.Encode(label)}</a></p>", html);
}

// SC-009, research R9.
[Fact]
public void LanguagesHaveTheSameKeys() => Assert.Equal(Texts.Keys("en").Order(), Texts.Keys("nl").Order());
Expand Down Expand Up @@ -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("<h1[^>]*>" + Regex.Escape(subject) + "</h1>", html);
Assert.Contains("InThe<span", html); // the wordmark
Assert.Contains($"<a href=\"{Frontend}/notifications\" style=\"color:{EmailChannel.LinkColor};text-decoration:underline\">", 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 };
Expand Down
4 changes: 3 additions & 1 deletion backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

4 changes: 3 additions & 1 deletion backend/InTheKnow.Tests/EmailSnapshots/changes.nl.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

4 changes: 4 additions & 0 deletions backend/InTheKnow.Tests/EmailSnapshots/test.en.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

4 changes: 4 additions & 0 deletions backend/InTheKnow.Tests/EmailSnapshots/test.nl.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

2 changes: 1 addition & 1 deletion backend/InTheKnow.Tests/PublicPagesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
10 changes: 10 additions & 0 deletions backend/InTheKnow/EmailChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down Expand Up @@ -217,6 +220,13 @@ public void Footer(string s)
footer.Append("<p style=\"margin:0 0 12px\">").Append(E(s)).Append("</p>");
}

// 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($"<p style=\"margin:0 0 12px\"><a href=\"{E(url)}\" style=\"color:{Muted};text-decoration:underline\">{E(label)}</a></p>");
}

// A note in a grey box.
public void Box(string s)
{
Expand Down
4 changes: 2 additions & 2 deletions backend/InTheKnow/Texts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions specs/003-notification-channels/contracts/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<label>` (shown as its label). Values for status and activity type are recorded
Expand All @@ -52,6 +53,7 @@ calendar text and are shown as recorded.
- **Subject**: "InTheKnow test notification" / "InTheKnow-testmelding".
- **Body**: states that this is a test and contains no changes, then gives a link to the notification settings
(`SettingsUrl`, FR-010).
- **Footer**: the same as Changes, with the unsubscribe link (008).
- It has the unsubscribe headers.

## Public pages (`/n/…`)
Expand Down
6 changes: 3 additions & 3 deletions specs/003-notification-channels/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
- Q: When are changes sent, and how are low-priority changes handled? → A: One message per channel with everything that became due within a short collection window (default 5 minutes). Low-priority changes are included and marked.
- Q: Language? → A: Notifications are in English or Dutch, chosen per subscriber. The setting is available through the API now; the frontend will offer it later.
- Q: Consent? → A: Until accounts exist, an address must be confirmed before it receives anything but the confirmation email. The later account feature will not make an account valid before its address is confirmed, so an account's own address will count as confirmed.
- Q: Unsubscribing? → A: Through the notification settings of the calendar in the later frontend, which requires login. Each email links, per calendar, to that calendar's notification settings page; there is no login-free unsubscribe page. The only login-free way out is the unsubscribe button of the mail client itself, which stops all emails to that address (kept because large mail providers expect it from senders of large volumes).
- Q: Unsubscribing? → A: Through the notification settings of the calendar in the later frontend, which requires login. Each email links, per calendar, to that calendar's notification settings page; the login-free ways out are the unsubscribe button of the mail client itself, which stops all emails to that address (kept because large mail providers expect it from senders of large volumes), and, since 008, an unsubscribe link in the email footer that opens a one-button unsubscribe page for the same channel.
- Q: How does an email name a calendar, given that 001 stores no calendar name? → A: By the student's own label for that subscription, set through the API now and the frontend later. Without a label, the feed's own calendar name from its latest successful check is used, and the calendar's host when the feed has none.
- Q: In which time zone do emails show event and detection times? → A: In the subscriber's own time zone, used for all their notifications. It is set through the API now and in the account (frontend) later; the later account feature sets its default at account creation. Until then, a subscriber who has not set one gets the operator-configured default (Europe/Amsterdam).
- Q: How long are addresses of unconfirmed or unsubscribed channels kept? → A: A channel that has been *awaiting confirmation* or *unsubscribed* for 30 days is deleted automatically with its address. For "this wasn't me", only a one-way hash of the address is kept per subscriber, which blocks new confirmations to that address from that subscriber, also after the channel is removed.
Expand Down Expand Up @@ -252,7 +252,7 @@ After confirming their address, a student (or the operator, while setting up the
- **FR-028**: A subscriber MUST be able to add, list, pause, resume and remove their channels, request a new confirmation, send a test notification, turn each subscription's notifications on and off, set and clear each subscription's label, and view and set their language and time zone. Listing channels MUST show each channel's type, target, status, the reason when failing, and when it last received a message. Listing subscriptions MUST show whether notifications are on, the label, and the name used in emails.
- **FR-029**: Removing a channel MUST delete it with its target and its delivery records. Removing a subscriber's last subscription MUST NOT remove their channels, language or time zone.
- **FR-029a**: A channel that has been *awaiting confirmation* or *unsubscribed* for 30 days (counted from the later of entering that status and its latest confirmation email) MUST be deleted automatically, like a removal (FR-029). The "this wasn't me" block (FR-006) stays.
- **FR-030**: Notification management MUST be available through the HTTP API from 001, under the same authentication. The confirmation and "this wasn't me" links and the mail client's unsubscribe button MUST be the only notification actions that work without it, and only for their own channel.
- **FR-030**: Notification management MUST be available through the HTTP API from 001, under the same authentication. The confirmation and "this wasn't me" links, the mail client's unsubscribe button and the footer unsubscribe link (008) MUST be the only notification actions that work without it, and only for their own channel.
- **FR-030a**: The operator MUST be able to request, through the operator API of 001, the status of each channel type: whether it is configured, the outcome of its latest send attempt (success or failure, and the reason), the number of consecutive failed attempts and since when, and the number of messages waiting to be sent. It MUST NOT show addresses or other subscriber data. Every failed send attempt MUST be logged with its reason (within FR-031).
- **FR-031**: Logs MUST NOT contain full email addresses, confirmation or unsubscribe links or tokens, or email content.
- **FR-032**: Mail server credentials MUST be operator secrets, supplied like the other secrets of 001 and never stored in the repository.
Expand All @@ -278,7 +278,7 @@ After confirming their address, a student (or the operator, while setting up the

- **SC-001**: In an end-to-end test against a local mail capture, 100% of changes with outcome normal or low priority reach every active channel of their subscriber for calendars with notifications on, and 0 changes with outcome hidden, or of calendars with notifications off, are sent.
- **SC-002**: 0 emails other than the confirmation email are sent to an address that has not been confirmed.
- **SC-003**: 100% of change emails link to the notification settings page of each calendar they list, 100% of non-confirmation emails support the mail client's unsubscribe button, and after it is used 0 further emails are sent to that channel. 0 links in emails change anything without login, except the confirmation and "this wasn't me" links in confirmation emails.
- **SC-003**: 100% of change emails link to the notification settings page of each calendar they list, 100% of non-confirmation emails support the mail client's unsubscribe button, and after it is used 0 further emails are sent to that channel. 0 links in emails change anything without login, except the confirmation and "this wasn't me" links in confirmation emails and the footer unsubscribe link (008), each only through the button on the page it opens.
- **SC-004**: Across a test with repeated restarts while changes are pending, each change is delivered exactly once per channel.
- **SC-005**: After a mail server outage shorter than the retry window, 100% of pending changes are delivered after it recovers; 0 channels are marked failing because of the outage, and during the outage the operator status (FR-030a) shows email failing with its reason.
- **SC-006**: A change recorded on a calendar reaches a subscriber's inbox within 10 minutes with the default collection window, when the system and mail server are healthy and the send rate limit is not reached.
Expand Down
Loading
Loading