Batch notifications into localized e-mail digests with unsubscribe (#95)
Some checks failed
CI / Lint, typecheck, test (push) Successful in 3m41s
CD / Build and push images (push) Successful in 3m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m53s
CI / Import/export fidelity gate (push) Has been skipped
Some checks failed
CI / Lint, typecheck, test (push) Successful in 3m41s
CD / Build and push images (push) Successful in 3m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m53s
CI / Import/export fidelity gate (push) Has been skipped
New per-user digestFrequency (hourly default | daily | off) on the profile and in the settings UI. A scheduler job (15 min cadence) mails a user once their oldest unread, unmailed notification exceeds the cadence window: one localized mail per batch, grouped per pond then per page with actor names and change/comment counts, enqueued through the mail outbox. Sending marks the batch mailed — never read — and re-checks page read permission per entry at send time; entries the user can no longer read are dropped from the mail but still marked handled, so revoked content cannot queue forever. Every mail carries a signed, single-purpose unsubscribe link: it only flips the setting to off, renders a session-free confirmation page, and sets no cookie. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
67fb01fe2b
commit
9674c0bae2
@ -0,0 +1,2 @@
|
|||||||
|
-- E-mail digest cadence per user (issue #95).
|
||||||
|
ALTER TABLE "users" ADD COLUMN "digest_frequency" TEXT NOT NULL DEFAULT 'hourly';
|
||||||
@ -40,6 +40,8 @@ model User {
|
|||||||
/// Auto-watch preferences (issue #93): watch pages I create / comment on.
|
/// Auto-watch preferences (issue #93): watch pages I create / comment on.
|
||||||
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
|
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
|
||||||
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
|
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
|
||||||
|
/// E-mail digest cadence (issue #95): hourly | daily | off.
|
||||||
|
digestFrequency String @default("hourly") @map("digest_frequency")
|
||||||
status UserStatus @default(PENDING_VERIFICATION)
|
status UserStatus @default(PENDING_VERIFICATION)
|
||||||
emailVerifiedAt DateTime? @map("email_verified_at")
|
emailVerifiedAt DateTime? @map("email_verified_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|||||||
@ -42,6 +42,10 @@ export function toCurrentUser(user: User): CurrentUserShape {
|
|||||||
isSiteAdmin: user.isSiteAdmin,
|
isSiteAdmin: user.isSiteAdmin,
|
||||||
autoWatchOwnPages: user.autoWatchOwnPages,
|
autoWatchOwnPages: user.autoWatchOwnPages,
|
||||||
autoWatchOnComment: user.autoWatchOnComment,
|
autoWatchOnComment: user.autoWatchOnComment,
|
||||||
|
digestFrequency:
|
||||||
|
user.digestFrequency === 'daily' || user.digestFrequency === 'off'
|
||||||
|
? user.digestFrequency
|
||||||
|
: 'hourly',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,13 +18,13 @@ export class MailService {
|
|||||||
locale: 'de' | 'en',
|
locale: 'de' | 'en',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const rendered = renderMail(template, params, locale);
|
const rendered = renderMail(template, params, locale);
|
||||||
|
await this.enqueueRaw(to, rendered.subject, rendered.text, rendered.html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-rendered mails (the #95 digests build their own body). */
|
||||||
|
async enqueueRaw(to: string, subject: string, text: string, html: string): Promise<void> {
|
||||||
await this.prisma.mailOutbox.create({
|
await this.prisma.mailOutbox.create({
|
||||||
data: {
|
data: { toAddress: to, subject, textBody: text, htmlBody: html },
|
||||||
toAddress: to,
|
|
||||||
subject: rendered.subject,
|
|
||||||
textBody: rendered.text,
|
|
||||||
htmlBody: rendered.html,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
217
apps/api/src/notifications/digest.e2e.db.test.ts
Normal file
217
apps/api/src/notifications/digest.e2e.db.test.ts
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient, User } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { DigestService } from './digest.service';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-mail digests (issue #95): grouping into exactly one mail, the cadence
|
||||||
|
* windows with time travel, the session-free unsubscribe link, and the
|
||||||
|
* send-time permission re-check.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('notification digests (e2e, issue #95)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let digest: DigestService;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'gebuendelt statt einzeln 1';
|
||||||
|
const users: Record<string, User> = {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
let pondId: string;
|
||||||
|
let pageId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
async function makeUser(handle: string): Promise<void> {
|
||||||
|
const service = app.get(UsersService);
|
||||||
|
const username = `di-${handle}-${suffix}`;
|
||||||
|
const user = await service.createUser({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.org`,
|
||||||
|
displayName: `Di ${handle}`,
|
||||||
|
password,
|
||||||
|
locale: 'de',
|
||||||
|
});
|
||||||
|
await service.markEmailVerified(user.id);
|
||||||
|
users[handle] = user;
|
||||||
|
cookies[handle] = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fan out an event and backdate the resulting unmailed notifications. */
|
||||||
|
async function seedEvent(type: 'page_changed' | 'comment_added', ageMs: number): Promise<void> {
|
||||||
|
await app.get(NotificationsService).fanoutPageEvent(type, pageId, [users.owner!.id]);
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { userId: users.watcher!.id, mailedAt: null },
|
||||||
|
data: { createdAt: new Date(Date.now() - ageMs) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function outboxFor(handle: string): Promise<{ subject: string; textBody: string }[]> {
|
||||||
|
return prisma.mailOutbox.findMany({
|
||||||
|
where: { toAddress: users[handle]!.email },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { subject: true, textBody: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
digest = app.get(DigestService);
|
||||||
|
for (const handle of ['owner', 'watcher']) await makeUser(handle);
|
||||||
|
|
||||||
|
const pond = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
slug: `di-pond-${suffix}`,
|
||||||
|
name: 'Digest Pond',
|
||||||
|
type: 'SHARED',
|
||||||
|
ownerId: users.owner!.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pondId = pond.id;
|
||||||
|
await prisma.roleGrant.create({
|
||||||
|
data: {
|
||||||
|
pondId,
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: users.owner!.id,
|
||||||
|
role: 'POND_ADMIN',
|
||||||
|
scopeType: 'POND',
|
||||||
|
effect: 'ALLOW',
|
||||||
|
createdBy: users.owner!.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const page = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ title: 'Digest target' })
|
||||||
|
.expect(201);
|
||||||
|
pageId = (page.body as { id: string }).id;
|
||||||
|
// The watcher joins as a reader through the API (permission cache!).
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/members`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ usernameOrEmail: `di-watcher-${suffix}`, role: 'reader' })
|
||||||
|
.expect(201);
|
||||||
|
await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.watcher!).expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const ids = Object.values(users).map((u) => u.id);
|
||||||
|
await prisma.mailOutbox.deleteMany({
|
||||||
|
where: { toAddress: { in: Object.values(users).map((u) => u.email) } },
|
||||||
|
});
|
||||||
|
await prisma.notification.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.watch.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.comment.deleteMany({ where: { page: { pondId } } });
|
||||||
|
await prisma.roleGrant.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.page.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.pond.deleteMany({ where: { id: pondId } });
|
||||||
|
await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } });
|
||||||
|
await prisma.session.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups two edits and a comment into exactly one localized mail', async () => {
|
||||||
|
await seedEvent('page_changed', 2 * HOUR);
|
||||||
|
await seedEvent('page_changed', 2 * HOUR);
|
||||||
|
await seedEvent('comment_added', 2 * HOUR);
|
||||||
|
|
||||||
|
expect(await digest.runOnce()).toBe(1);
|
||||||
|
const mails = await outboxFor('watcher');
|
||||||
|
expect(mails).toHaveLength(1);
|
||||||
|
expect(mails[0]!.subject).toContain('3');
|
||||||
|
expect(mails[0]!.textBody).toContain('Digest Pond');
|
||||||
|
expect(mails[0]!.textBody).toContain('Digest target');
|
||||||
|
expect(mails[0]!.textBody).toContain('2 Änderungen');
|
||||||
|
expect(mails[0]!.textBody).toContain('1 Kommentar');
|
||||||
|
expect(mails[0]!.textBody).toContain('Di owner');
|
||||||
|
expect(mails[0]!.textBody).toContain('/api/v1/notifications/unsubscribe?token=');
|
||||||
|
|
||||||
|
// Mailed, not read — and never mailed twice.
|
||||||
|
const rows = await prisma.notification.findMany({ where: { userId: users.watcher!.id } });
|
||||||
|
expect(rows.every((row) => row.mailedAt !== null && row.readAt === null)).toBe(true);
|
||||||
|
expect(await digest.runOnce()).toBe(0);
|
||||||
|
expect(await outboxFor('watcher')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects 'off' and batches 'daily' across the day", async () => {
|
||||||
|
await api()
|
||||||
|
.patch('/api/v1/users/me')
|
||||||
|
.set('Cookie', cookies.watcher!)
|
||||||
|
.send({ digestFrequency: 'off' })
|
||||||
|
.expect(200);
|
||||||
|
await seedEvent('page_changed', 3 * HOUR);
|
||||||
|
expect(await digest.runOnce()).toBe(0);
|
||||||
|
expect(await outboxFor('watcher')).toHaveLength(1); // unchanged
|
||||||
|
|
||||||
|
// Daily: a 3 h old batch is not due yet, a > 24 h old one is.
|
||||||
|
await api()
|
||||||
|
.patch('/api/v1/users/me')
|
||||||
|
.set('Cookie', cookies.watcher!)
|
||||||
|
.send({ digestFrequency: 'daily' })
|
||||||
|
.expect(200);
|
||||||
|
expect(await digest.runOnce()).toBe(0);
|
||||||
|
expect(await digest.runOnce(new Date(Date.now() + 25 * HOUR))).toBe(1);
|
||||||
|
expect(await outboxFor('watcher')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribes via the signed link without creating a session', async () => {
|
||||||
|
await api()
|
||||||
|
.patch('/api/v1/users/me')
|
||||||
|
.set('Cookie', cookies.watcher!)
|
||||||
|
.send({ digestFrequency: 'hourly' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const token = digest.unsubscribeToken(users.watcher!.id);
|
||||||
|
const res = await api().get(`/api/v1/notifications/unsubscribe?token=${token}`).expect(200);
|
||||||
|
expect(res.headers['set-cookie']).toBeUndefined();
|
||||||
|
expect(res.text).toContain('Digest-Mails deaktiviert');
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: users.watcher!.id } });
|
||||||
|
expect(user.digestFrequency).toBe('off');
|
||||||
|
|
||||||
|
// Tampered and garbage tokens are rejected.
|
||||||
|
await api().get(`/api/v1/notifications/unsubscribe?token=${token}x`).expect(400);
|
||||||
|
await api().get('/api/v1/notifications/unsubscribe?token=nonsense').expect(400);
|
||||||
|
await api().get('/api/v1/notifications/unsubscribe').expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-checks permission at send time: revoked content never reaches the mail', async () => {
|
||||||
|
await api()
|
||||||
|
.patch('/api/v1/users/me')
|
||||||
|
.set('Cookie', cookies.watcher!)
|
||||||
|
.send({ digestFrequency: 'hourly' })
|
||||||
|
.expect(200);
|
||||||
|
await seedEvent('page_changed', 2 * HOUR);
|
||||||
|
|
||||||
|
// Revoke the watcher's membership through the API (cache-safe).
|
||||||
|
await api()
|
||||||
|
.delete(`/api/v1/ponds/${pondId}/members/${users.watcher!.id}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
const before = await outboxFor('watcher');
|
||||||
|
expect(await digest.runOnce()).toBe(0);
|
||||||
|
expect(await outboxFor('watcher')).toHaveLength(before.length);
|
||||||
|
// The batch still counts as handled — no retry loop on revoked content.
|
||||||
|
const rows = await prisma.notification.findMany({
|
||||||
|
where: { userId: users.watcher!.id, mailedAt: null },
|
||||||
|
});
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
171
apps/api/src/notifications/digest.service.ts
Normal file
171
apps/api/src/notifications/digest.service.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { NotificationPayload } from '@dorfteich/shared';
|
||||||
|
import { Notification, User } from '@prisma/client';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { apiI18n } from '../i18n/api-i18n';
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { MailService } from '../mail/mail.service';
|
||||||
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { signUnsubscribeToken } from './unsubscribe-token';
|
||||||
|
|
||||||
|
const WINDOW_MS = { hourly: 60 * 60 * 1000, daily: 24 * 60 * 60 * 1000 } as const;
|
||||||
|
|
||||||
|
interface PageGroup {
|
||||||
|
pageTitle: string;
|
||||||
|
pondName: string;
|
||||||
|
changed: number;
|
||||||
|
comments: number;
|
||||||
|
actorNames: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-mail notification digests (issue #95): batched, never mail-per-edit.
|
||||||
|
* A user gets at most one mail per run, summarizing every unread, not-yet-
|
||||||
|
* mailed notification once the oldest of them is older than their cadence
|
||||||
|
* window (`hourly` default, `daily`, `off`). Sending marks the batch as
|
||||||
|
* mailed — not read. Every notification is permission-re-checked at send
|
||||||
|
* time; entries the user can no longer read are dropped from the mail but
|
||||||
|
* still marked mailed (no retry loop on revoked content).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DigestService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionService,
|
||||||
|
private readonly mail: MailService,
|
||||||
|
private readonly config: AppConfig,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(DigestService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One scheduler pass; `now` is injectable for the time-travel tests. */
|
||||||
|
async runOnce(now: Date = new Date()): Promise<number> {
|
||||||
|
const pending = await this.prisma.notification.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: { readAt: null, mailedAt: null },
|
||||||
|
_min: { createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let mails = 0;
|
||||||
|
for (const entry of pending) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: entry.userId } });
|
||||||
|
if (!user) continue;
|
||||||
|
const frequency = user.digestFrequency === 'daily' ? 'daily' : 'hourly';
|
||||||
|
if (user.digestFrequency === 'off') continue;
|
||||||
|
const oldest = entry._min.createdAt;
|
||||||
|
if (!oldest || now.getTime() - oldest.getTime() < WINDOW_MS[frequency]) continue;
|
||||||
|
if (await this.sendDigest(user, now)) mails += 1;
|
||||||
|
}
|
||||||
|
return mails;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendDigest(user: User, now: Date): Promise<boolean> {
|
||||||
|
const batch = await this.prisma.notification.findMany({
|
||||||
|
where: { userId: user.id, readAt: null, mailedAt: null },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
if (batch.length === 0) return false;
|
||||||
|
|
||||||
|
const readable = await this.filterReadable(user, batch);
|
||||||
|
// The whole batch counts as handled — including entries dropped by the
|
||||||
|
// permission re-check, which must not queue up forever.
|
||||||
|
await this.prisma.notification.updateMany({
|
||||||
|
where: { id: { in: batch.map((notification) => notification.id) } },
|
||||||
|
data: { mailedAt: now },
|
||||||
|
});
|
||||||
|
if (readable.length === 0) return false;
|
||||||
|
|
||||||
|
const locale = user.locale === 'de' ? 'de' : 'en';
|
||||||
|
const rendered = this.render(readable, locale);
|
||||||
|
await this.mail.enqueueRaw(user.email, rendered.subject, rendered.text, rendered.html);
|
||||||
|
this.logger.info(
|
||||||
|
{ userId: user.id, notifications: readable.length },
|
||||||
|
'notification digest enqueued',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send-time permission re-check, one page lookup per distinct page. */
|
||||||
|
private async filterReadable(user: User, batch: Notification[]): Promise<Notification[]> {
|
||||||
|
const verdicts = new Map<string, boolean>();
|
||||||
|
const readable: Notification[] = [];
|
||||||
|
for (const notification of batch) {
|
||||||
|
const payload = notification.payload as unknown as NotificationPayload;
|
||||||
|
if (!verdicts.has(payload.pageId)) {
|
||||||
|
const page = await this.prisma.page.findFirst({
|
||||||
|
where: { id: payload.pageId, deletedAt: null },
|
||||||
|
select: { id: true, pondId: true },
|
||||||
|
});
|
||||||
|
verdicts.set(
|
||||||
|
payload.pageId,
|
||||||
|
page !== null && (await this.permissions.canAccessPage(user, page, 'read')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (verdicts.get(payload.pageId)) readable.push(notification);
|
||||||
|
}
|
||||||
|
return readable;
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(
|
||||||
|
batch: Notification[],
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
): { subject: string; text: string; html: string } {
|
||||||
|
const t = (key: string, options: Record<string, string | number> = {}): string =>
|
||||||
|
apiI18n.t(`mails:digest.${key}`, { lng: locale, ...options });
|
||||||
|
|
||||||
|
// Group per pond → per page (issue #95): actors and counts, no bodies.
|
||||||
|
const groups = new Map<string, Map<string, PageGroup>>();
|
||||||
|
for (const notification of batch) {
|
||||||
|
const payload = notification.payload as unknown as NotificationPayload;
|
||||||
|
const pond = groups.get(payload.pondName) ?? new Map<string, PageGroup>();
|
||||||
|
const page = pond.get(payload.pageId) ?? {
|
||||||
|
pageTitle: payload.pageTitle,
|
||||||
|
pondName: payload.pondName,
|
||||||
|
changed: 0,
|
||||||
|
comments: 0,
|
||||||
|
actorNames: new Set<string>(),
|
||||||
|
};
|
||||||
|
if (notification.type === 'comment_added') page.comments += 1;
|
||||||
|
else page.changed += 1;
|
||||||
|
for (const name of payload.actorNames) page.actorNames.add(name);
|
||||||
|
pond.set(payload.pageId, page);
|
||||||
|
groups.set(payload.pondName, pond);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [t('intro', { count: batch.length })];
|
||||||
|
for (const [pondName, pages] of groups) {
|
||||||
|
lines.push('', `${pondName}:`);
|
||||||
|
for (const page of pages.values()) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (page.changed > 0) parts.push(t('changes', { count: page.changed }));
|
||||||
|
if (page.comments > 0) parts.push(t('comments', { count: page.comments }));
|
||||||
|
lines.push(
|
||||||
|
` - ${page.pageTitle}: ${parts.join(', ')} (${[...page.actorNames].join(', ')})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('', t('openApp', { link: this.config.env.APP_BASE_URL }));
|
||||||
|
|
||||||
|
const unsubscribeUrl = `${this.config.env.APP_BASE_URL}/api/v1/notifications/unsubscribe?token=${this.unsubscribeToken(batch[0]!.userId)}`;
|
||||||
|
lines.push('', t('unsubscribe', { link: unsubscribeUrl }));
|
||||||
|
|
||||||
|
const text = lines.join('\n');
|
||||||
|
const html = `<!doctype html><html><body style="font-family: system-ui, sans-serif; max-width: 32rem; margin: 0 auto; padding: 1.5rem;"><pre style="font-family: inherit; white-space: pre-wrap;">${escapeHtml(text)}</pre></body></html>`;
|
||||||
|
return { subject: t('subject', { count: batch.length }), text, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribeToken(userId: string): string {
|
||||||
|
return signUnsubscribeToken(userId, this.config.env.COLLAB_TOKEN_SECRET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
@ -1,4 +1,14 @@
|
|||||||
import { Controller, Get, HttpCode, Param, Post, Query, Req } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
notificationListQuerySchema,
|
notificationListQuerySchema,
|
||||||
type NotificationListQuery,
|
type NotificationListQuery,
|
||||||
@ -6,7 +16,15 @@ import {
|
|||||||
type NotificationView,
|
type NotificationView,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { htmlDocument } from '../public/html-shell';
|
||||||
|
import { apiI18n } from '../i18n/api-i18n';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SetupExempt } from '../setup/setup.guard';
|
||||||
|
import { verifyUnsubscribeToken } from './unsubscribe-token';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
@ -14,7 +32,42 @@ import { NotificationsService } from './notifications.service';
|
|||||||
/** The in-app notification center's API (issue #94). */
|
/** The in-app notification center's API (issue #94). */
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
constructor(private readonly notifications: NotificationsService) {}
|
constructor(
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Digest unsubscribe (issue #95): the signed link from the mail flips the
|
||||||
|
* user's digest setting to `off`. Deliberately session-free — the token's
|
||||||
|
* only power is this switch, and the response sets no cookie.
|
||||||
|
*/
|
||||||
|
@Get('unsubscribe')
|
||||||
|
@Public()
|
||||||
|
@SetupExempt()
|
||||||
|
async unsubscribe(@Query('token') token: string, @Res() res: Response): Promise<void> {
|
||||||
|
const userId = token
|
||||||
|
? verifyUnsubscribeToken(token, this.config.env.COLLAB_TOKEN_SECRET)
|
||||||
|
: null;
|
||||||
|
if (!userId) throw new BadRequestException({ code: 'bad_request' });
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) throw new BadRequestException({ code: 'bad_request' });
|
||||||
|
await this.prisma.user.update({ where: { id: userId }, data: { digestFrequency: 'off' } });
|
||||||
|
|
||||||
|
const lang = user.locale === 'de' ? 'de' : 'en';
|
||||||
|
const t = (key: string): string => apiI18n.t(`mails:digest.${key}`, { lng: lang });
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.type('text/html; charset=utf-8')
|
||||||
|
.send(
|
||||||
|
htmlDocument({
|
||||||
|
lang,
|
||||||
|
title: t('unsubscribedTitle'),
|
||||||
|
bodyHtml: `<h1>${t('unsubscribedTitle')}</h1><p>${t('unsubscribedBody')}</p>`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@AuthenticatedOnly()
|
@AuthenticatedOnly()
|
||||||
|
|||||||
@ -1,15 +1,35 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MailModule } from '../mail/mail.module';
|
||||||
import { PermissionsModule } from '../permissions/permissions.module';
|
import { PermissionsModule } from '../permissions/permissions.module';
|
||||||
|
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||||
|
import { SchedulerService } from '../scheduler/scheduler.service';
|
||||||
|
|
||||||
|
import { DigestService } from './digest.service';
|
||||||
import { NotificationsController } from './notifications.controller';
|
import { NotificationsController } from './notifications.controller';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import { VersionEventListener } from './version-event-listener.service';
|
import { VersionEventListener } from './version-event-listener.service';
|
||||||
|
|
||||||
|
/** Every 15 minutes the digest job looks for due batches (issue #95). */
|
||||||
|
const DIGEST_CADENCE_SECONDS = 15 * 60;
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PermissionsModule],
|
imports: [PermissionsModule, MailModule, SchedulerModule],
|
||||||
controllers: [NotificationsController],
|
controllers: [NotificationsController],
|
||||||
providers: [NotificationsService, VersionEventListener],
|
providers: [NotificationsService, DigestService, VersionEventListener],
|
||||||
exports: [NotificationsService],
|
exports: [NotificationsService, DigestService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule implements OnModuleInit {
|
||||||
|
constructor(
|
||||||
|
private readonly scheduler: SchedulerService,
|
||||||
|
private readonly digest: DigestService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
this.scheduler.register({
|
||||||
|
name: 'notification-digest',
|
||||||
|
cadenceSeconds: DIGEST_CADENCE_SECONDS,
|
||||||
|
run: () => this.digest.runOnce().then(() => undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
52
apps/api/src/notifications/unsubscribe-token.ts
Normal file
52
apps/api/src/notifications/unsubscribe-token.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-purpose unsubscribe tokens for the digest mails (issue #95): a
|
||||||
|
* signed `{userId, exp}` blob whose only power is flipping that user's
|
||||||
|
* digest setting to `off` — it never creates a session (ADR 0007's "signed
|
||||||
|
* tokens" pattern, purpose-bound so it cannot be replayed anywhere else).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PURPOSE = 'digest-unsubscribe';
|
||||||
|
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
function signature(body: string, secret: string): Buffer {
|
||||||
|
return createHmac('sha256', secret).update(`${PURPOSE}.${body}`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signUnsubscribeToken(userId: string, secret: string, now = Date.now()): string {
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url');
|
||||||
|
return `${body}.${signature(body, secret).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user id, or null for anything invalid or expired. Never throws. */
|
||||||
|
export function verifyUnsubscribeToken(
|
||||||
|
token: string,
|
||||||
|
secret: string,
|
||||||
|
now = Date.now(),
|
||||||
|
): string | null {
|
||||||
|
const [body, sig] = token.split('.');
|
||||||
|
if (!body || !sig) return null;
|
||||||
|
let provided: Buffer;
|
||||||
|
try {
|
||||||
|
provided = Buffer.from(sig, 'base64url');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expected = signature(body, secret);
|
||||||
|
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
|
||||||
|
userId?: string;
|
||||||
|
exp?: number;
|
||||||
|
};
|
||||||
|
if (typeof payload.userId !== 'string' || typeof payload.exp !== 'number') return null;
|
||||||
|
if (payload.exp * 1000 < now) return null;
|
||||||
|
return payload.userId;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,7 @@ export class UsersController {
|
|||||||
locale?: 'de' | 'en';
|
locale?: 'de' | 'en';
|
||||||
autoWatchOwnPages?: boolean;
|
autoWatchOwnPages?: boolean;
|
||||||
autoWatchOnComment?: boolean;
|
autoWatchOnComment?: boolean;
|
||||||
|
digestFrequency?: 'hourly' | 'daily' | 'off';
|
||||||
},
|
},
|
||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
): Promise<CurrentUserShape> {
|
): Promise<CurrentUserShape> {
|
||||||
|
|||||||
@ -99,6 +99,7 @@ export class UsersService {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
autoWatchOwnPages?: boolean;
|
autoWatchOwnPages?: boolean;
|
||||||
autoWatchOnComment?: boolean;
|
autoWatchOnComment?: boolean;
|
||||||
|
digestFrequency?: string;
|
||||||
},
|
},
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
return this.prisma.user.update({ where: { id: userId }, data });
|
return this.prisma.user.update({ where: { id: userId }, data });
|
||||||
|
|||||||
@ -90,6 +90,7 @@ function ProfileSection(): React.JSX.Element {
|
|||||||
locale?: 'de' | 'en';
|
locale?: 'de' | 'en';
|
||||||
autoWatchOwnPages?: boolean;
|
autoWatchOwnPages?: boolean;
|
||||||
autoWatchOnComment?: boolean;
|
autoWatchOnComment?: boolean;
|
||||||
|
digestFrequency?: 'hourly' | 'daily' | 'off';
|
||||||
}>({
|
}>({
|
||||||
resolver: zodResolver(updateProfileInputSchema),
|
resolver: zodResolver(updateProfileInputSchema),
|
||||||
values: {
|
values: {
|
||||||
@ -97,6 +98,7 @@ function ProfileSection(): React.JSX.Element {
|
|||||||
locale: user?.locale,
|
locale: user?.locale,
|
||||||
autoWatchOwnPages: user?.autoWatchOwnPages,
|
autoWatchOwnPages: user?.autoWatchOwnPages,
|
||||||
autoWatchOnComment: user?.autoWatchOnComment,
|
autoWatchOnComment: user?.autoWatchOnComment,
|
||||||
|
digestFrequency: user?.digestFrequency,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -143,6 +145,13 @@ function ProfileSection(): React.JSX.Element {
|
|||||||
<input type="checkbox" {...form.register('autoWatchOnComment')} />
|
<input type="checkbox" {...form.register('autoWatchOnComment')} />
|
||||||
{t('watches:prefs.autoWatchOnComment')}
|
{t('watches:prefs.autoWatchOnComment')}
|
||||||
</label>
|
</label>
|
||||||
|
<Field label={t('notifications:digest.label')}>
|
||||||
|
<select {...form.register('digestFrequency')}>
|
||||||
|
<option value="hourly">{t('notifications:digest.hourly')}</option>
|
||||||
|
<option value="daily">{t('notifications:digest.daily')}</option>
|
||||||
|
<option value="off">{t('notifications:digest.off')}</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||||
{t('settings:profile.save')}
|
{t('settings:profile.save')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -30,5 +30,17 @@
|
|||||||
"lastSuccess": "Letztes erfolgreiches Backup: {{finishedAt}}",
|
"lastSuccess": "Letztes erfolgreiches Backup: {{finishedAt}}",
|
||||||
"lastSuccessNever": "Letztes erfolgreiches Backup: noch keines",
|
"lastSuccessNever": "Letztes erfolgreiches Backup: noch keines",
|
||||||
"hint": "Prüfe die Sidecar-Logs (docker compose logs backup) und die status.json auf dem Backups-Volume."
|
"hint": "Prüfe die Sidecar-Logs (docker compose logs backup) und die status.json auf dem Backups-Volume."
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"subject": "Dorfteich: {{count}} Neuigkeiten für dich",
|
||||||
|
"intro": "Das ist auf von dir beobachteten Seiten passiert ({{count}} Neuigkeiten):",
|
||||||
|
"changes_one": "{{count}} Änderung",
|
||||||
|
"changes_other": "{{count}} Änderungen",
|
||||||
|
"comments_one": "{{count}} Kommentar",
|
||||||
|
"comments_other": "{{count}} Kommentare",
|
||||||
|
"openApp": "Dorfteich öffnen: {{link}}",
|
||||||
|
"unsubscribe": "Diese Digest-Mails abbestellen: {{link}}",
|
||||||
|
"unsubscribedTitle": "Digest-Mails deaktiviert",
|
||||||
|
"unsubscribedBody": "Du erhältst keine Benachrichtigungs-Digests mehr. In den Konto-Einstellungen kannst du sie nach der Anmeldung jederzeit wieder aktivieren."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,5 +6,11 @@
|
|||||||
"types": {
|
"types": {
|
||||||
"page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert",
|
"page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert",
|
||||||
"comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert"
|
"comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"label": "E-Mail-Digest",
|
||||||
|
"hourly": "Stündliche Zusammenfassung",
|
||||||
|
"daily": "Tägliche Zusammenfassung",
|
||||||
|
"off": "Keine Digest-Mails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,5 +30,17 @@
|
|||||||
"lastSuccess": "Last successful backup: {{finishedAt}}",
|
"lastSuccess": "Last successful backup: {{finishedAt}}",
|
||||||
"lastSuccessNever": "Last successful backup: none yet",
|
"lastSuccessNever": "Last successful backup: none yet",
|
||||||
"hint": "Check the sidecar logs (docker compose logs backup) and status.json on the backups volume."
|
"hint": "Check the sidecar logs (docker compose logs backup) and status.json on the backups volume."
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"subject": "Dorfteich: {{count}} updates for you",
|
||||||
|
"intro": "Here is what happened on pages you watch ({{count}} updates):",
|
||||||
|
"changes_one": "{{count}} change",
|
||||||
|
"changes_other": "{{count}} changes",
|
||||||
|
"comments_one": "{{count}} comment",
|
||||||
|
"comments_other": "{{count}} comments",
|
||||||
|
"openApp": "Open Dorfteich: {{link}}",
|
||||||
|
"unsubscribe": "Stop these digest mails: {{link}}",
|
||||||
|
"unsubscribedTitle": "Digest mails disabled",
|
||||||
|
"unsubscribedBody": "You will no longer receive notification digests. You can re-enable them anytime in your account settings after signing in."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,5 +6,11 @@
|
|||||||
"types": {
|
"types": {
|
||||||
"page_changed": "{{actor}} changed “{{page}}” in {{pond}}",
|
"page_changed": "{{actor}} changed “{{page}}” in {{pond}}",
|
||||||
"comment_added": "{{actor}} commented on “{{page}}” in {{pond}}"
|
"comment_added": "{{actor}} commented on “{{page}}” in {{pond}}"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"label": "E-mail digest",
|
||||||
|
"hourly": "Hourly summary",
|
||||||
|
"daily": "Daily summary",
|
||||||
|
"off": "No digest mails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,6 +74,8 @@ export const updateProfileInputSchema = z.object({
|
|||||||
/** Auto-watch preferences (issue #93). */
|
/** Auto-watch preferences (issue #93). */
|
||||||
autoWatchOwnPages: z.boolean().optional(),
|
autoWatchOwnPages: z.boolean().optional(),
|
||||||
autoWatchOnComment: z.boolean().optional(),
|
autoWatchOnComment: z.boolean().optional(),
|
||||||
|
/** E-mail digest cadence (issue #95). */
|
||||||
|
digestFrequency: z.enum(['hourly', 'daily', 'off']).optional(),
|
||||||
});
|
});
|
||||||
export const changePasswordInputSchema = z.object({
|
export const changePasswordInputSchema = z.object({
|
||||||
currentPassword: z.string().min(1, 'validation.required'),
|
currentPassword: z.string().min(1, 'validation.required'),
|
||||||
@ -90,4 +92,5 @@ export interface CurrentUser {
|
|||||||
isSiteAdmin: boolean;
|
isSiteAdmin: boolean;
|
||||||
autoWatchOwnPages: boolean;
|
autoWatchOwnPages: boolean;
|
||||||
autoWatchOnComment: boolean;
|
autoWatchOnComment: boolean;
|
||||||
|
digestFrequency: 'hourly' | 'daily' | 'off';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,3 +41,7 @@ export interface NotificationListView {
|
|||||||
page: number;
|
page: number;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** E-mail digest cadence (issue #95). */
|
||||||
|
export const DIGEST_FREQUENCIES = ['hourly', 'daily', 'off'] as const;
|
||||||
|
export type DigestFrequency = (typeof DIGEST_FREQUENCIES)[number];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user