feat(notifications): grouped email digest, new music as a daily summary (#5346)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped

Nothing is emailed per event. New music (request_completed) goes out at
most once a day, at the summary hour in each user's own timezone, grouped
by artist. Everything else is batched: one email a window after the first
un-emailed item, holding whatever accumulated.

- Migration 0074: notification_email_settings (summary hour, batch window,
  admin-configurable) and user_notification_email_state (batch start, last
  sent, failures and retry_after per user and group). Existing rows are
  stamped emailed so the upgrade sends no backlog.
- The Notifier stamps emailed_at at write time when the recipient's email
  channel is off, so turning email on later doesn't send old items.
- Read rows are never selected. A row is stamped only after the mailer
  accepts, in one transaction with the state, against the read's clock, so
  a coalesced row updated mid-send stays pending.
- A failed send backs off 5m doubling to 6h; SMTP not configured just waits.
- Links come from the public address; without one the email has none.
- The mailer now RFC 2047-encodes subjects and strips line breaks from them.
- Admin → Integrations gains a Notification emails card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:48:22 -04:00
co-authored by Claude Opus 5.5
parent 62f76290fb
commit 63709a433d
28 changed files with 1764 additions and 22 deletions
+16
View File
@@ -921,3 +921,19 @@ export async function updateReacquisitionSettings(
): Promise<ReacquisitionSettings> {
return api.put<ReacquisitionSettings>('/api/admin/library/reacquisition', s);
}
// Notification emails (M489 #5346): when the grouped emails go out.
export type NotificationEmailSettings = {
summary_hour: number;
batch_window_minutes: number;
};
export async function getNotificationEmailSettings(): Promise<NotificationEmailSettings> {
return api.get<NotificationEmailSettings>('/api/admin/notification-email');
}
export async function updateNotificationEmailSettings(
s: NotificationEmailSettings
): Promise<NotificationEmailSettings> {
return api.put<NotificationEmailSettings>('/api/admin/notification-email', s);
}
@@ -0,0 +1,126 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
getNotificationEmailSettings,
updateNotificationEmailSettings,
type NotificationEmailSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
// When notification emails go out (M489 #5346). Nothing is emailed per
// event: new music is a daily summary, everything else a batch.
let saved = $state<NotificationEmailSettings | null>(null);
let form = $state<NotificationEmailSettings | null>(null);
let saving = $state(false);
let loadError = $state(false);
const dirty = $derived(
!!saved &&
!!form &&
(saved.summary_hour !== form.summary_hour ||
saved.batch_window_minutes !== form.batch_window_minutes)
);
const hours = Array.from({ length: 24 }, (_, h) => h);
function hourLabel(h: number): string {
return `${String(h).padStart(2, '0')}:00`;
}
async function load() {
try {
saved = await getNotificationEmailSettings();
form = { ...saved };
loadError = false;
} catch {
loadError = true;
}
}
onMount(load);
async function save() {
if (!form) return;
saving = true;
try {
saved = await updateNotificationEmailSettings(form);
form = { ...saved };
pushToast('Notification email schedule saved.');
} catch (e) {
// The server names the field it refused (#3937: errMessage, not
// e.message, because the client throws a plain object).
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
</script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Notification emails</h3>
<p class="mt-1 text-sm text-text-secondary">
Nothing is emailed per event. Each person chooses which notifications reach their email in
their own settings.
</p>
</div>
{#if loadError}
<p class="text-sm text-action-destructive">
Couldn't load the email schedule.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if form === null}
<p class="text-sm text-text-secondary">Loading the email schedule…</p>
{:else}
<div class="grid gap-4 sm:grid-cols-2">
<label class="block">
<span class="text-sm text-text-primary">New music summary</span>
<span class="block text-xs text-text-secondary">
Once a day at this hour, in each person's own timezone.
</span>
<select
bind:value={form.summary_hour}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
focus-visible:outline-accent"
>
{#each hours as h (h)}
<option value={h}>{hourLabel(h)}</option>
{/each}
</select>
</label>
<label class="block">
<span class="text-sm text-text-primary">Batch everything else (minutes)</span>
<span class="block text-xs text-text-secondary">
One email this long after the first item, holding all that came in.
</span>
<input
type="number"
min="15"
max="1440"
bind:value={form.batch_window_minutes}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
</div>
<div class="flex justify-end">
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving}
onclick={save}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/admin', () => ({
getNotificationEmailSettings: vi.fn(),
updateNotificationEmailSettings: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
import NotificationEmailCard from './NotificationEmailCard.svelte';
import { getNotificationEmailSettings, updateNotificationEmailSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
afterEach(() => vi.clearAllMocks());
async function renderCard() {
vi.mocked(getNotificationEmailSettings).mockResolvedValue({ summary_hour: 9, batch_window_minutes: 60 });
render(NotificationEmailCard);
return screen.findByRole('combobox', { name: /new music summary/i });
}
describe('NotificationEmailCard', () => {
test('shows the summary hour and the batch window', async () => {
const hour = await renderCard();
expect(hour).toHaveValue('9');
expect(screen.getByRole('option', { name: '09:00' })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: /batch everything else/i })).toHaveValue(60);
});
test('save stays off until something changes, then sends both values', async () => {
const hour = await renderCard();
const save = screen.getByRole('button', { name: 'Save' });
expect(save).toBeDisabled();
vi.mocked(updateNotificationEmailSettings).mockResolvedValue({ summary_hour: 7, batch_window_minutes: 60 });
await fireEvent.change(hour, { target: { value: '7' } });
expect(save).toBeEnabled();
await fireEvent.click(save);
await waitFor(() =>
expect(updateNotificationEmailSettings).toHaveBeenCalledWith({ summary_hour: 7, batch_window_minutes: 60 })
);
await waitFor(() => expect(save).toBeDisabled());
});
test("a refused save shows the server's reason", async () => {
await renderCard();
vi.mocked(updateNotificationEmailSettings).mockRejectedValue({
code: 'invalid_setting',
message: 'batch_window_minutes must be 15-1440',
status: 400
});
const batch = screen.getByRole('spinbutton', { name: /batch everything else/i });
await fireEvent.input(batch, { target: { value: '5' } });
await fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(pushToast).toHaveBeenCalledWith(expect.stringContaining('15-1440'), 'error'));
});
test('a failed load offers a retry', async () => {
vi.mocked(getNotificationEmailSettings).mockRejectedValue(new Error('down'));
render(NotificationEmailCard);
expect(await screen.findByRole('button', { name: 'Try again' })).toBeInTheDocument();
});
});