diff --git a/Dockerfile b/Dockerfile index c24e7df9..f6054c02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,9 @@ COPY index.html ./ COPY public/ ./public/ COPY src/ ./src/ COPY shared/ ./shared/ +# The build extracts and compiles the catalogs, the emails' messages included. +COPY locales/ ./locales/ +COPY api/mail/ ./api/mail/ # VITE_API_* are only needed when the API lives on a different host than the frontend. ARG VITE_API_PROTOCOL @@ -35,6 +38,7 @@ RUN deno install COPY api/ ./api/ COPY shared/ ./shared/ COPY --from=builder /app/dist/ ./dist/ +COPY --from=builder /app/locales/ ./locales/ # Persistent data: database and uploaded/generated files must be mounted as volumes. VOLUME ["/app/api/sql", "/app/api/uploads"] diff --git a/README.md b/README.md index 1ea42382..199c4ca8 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote, comment, follow each other, build playlists, and search content. A real-time -WebSocket layer handles live presence, vote counts, and notifications. The UI is -localized (English and French) and ships multiple visual themes. +WebSocket layer handles live presence, vote counts, and notifications, and +weekly and monthly email digests sum up what happened. The UI is localized +(English and French) and ships multiple visual themes. ## Stack @@ -125,6 +126,7 @@ example.com { ``` shared/ # Imported by both halves: API and socket types, limits, permissions test/ # Tests of shared/ (Deno): test/.test.ts +locales/ # Lingui catalogues (en, fr): the interface's and the emails' messages api/ main.ts # Entry point — Oak application, middleware, routes config.ts # Environment variables @@ -155,7 +157,6 @@ src/ # React frontend (Vite) hooks/ # Every hook (useFoo.ts) utils/ # Every non-React module: pure helpers, storage, API glue test/ # Frontend tests (Vitest): test///.test.tsx - locales/ # Lingui message catalogues (en, fr) themes/ # Per-theme CSS files i18n.ts # Lingui runtime setup public/ # Static assets (favicon, manifest, service worker) @@ -249,16 +250,16 @@ types are used through the global `React` namespace rather than a default import of `react`, which `deno doc` can't follow. `deno task doc` builds the HTML reference into `docs/` from every module of -`api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`). -Its index page is the module doc of `mod.ts` at the root, which introduces the -repository's three parts and their layers in prose; `deno doc` builds the list -of every module itself, grouped by path. The `docs` workflow publishes it on -every push to `main` that touches them. `deno task doc:lint` checks the rules -the linter can see, and that no summary ends in a colon, through `doc-lint.ts`. -Two kinds of diagnostic are let through. A field without a doc passes, since a -field gets one only when it adds to its type's. `deno doc` doesn't document npm -packages, so it takes a type imported from one (react-hook-form's `FieldValues`, -say) for a private one, and those references pass too. +`api/`, `shared/` and `src/` but the tests. Its index page is the module doc of +`mod.ts` at the root, which introduces the repository's three parts and their +layers in prose; `deno doc` builds the list of every module itself, grouped by +path. The `docs` workflow publishes it on every push to `main` that touches +them. `deno task doc:lint` checks the rules the linter can see, and that no +summary ends in a colon, through `doc-lint.ts`. Two kinds of diagnostic are let +through. A field without a doc passes, since a field gets one only when it adds +to its type's. `deno doc` doesn't document npm packages, so it takes a type +imported from one (react-hook-form's `FieldValues`, say) for a private one, and +those references pass too. Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is split into the same domain folders — `auth`, `category`, `chat`, `comment`, diff --git a/api/config.ts b/api/config.ts index a007adf9..89d9dc60 100644 --- a/api/config.ts +++ b/api/config.ts @@ -7,7 +7,8 @@ * @module */ -import { isStyleName, type StyleName } from "../shared/theme.ts"; +import { BASE_STYLE, isStyleName, type StyleName } from "../shared/theme.ts"; +import { isLocale, type Locale, SOURCE_LOCALE } from "../shared/locale.ts"; /** The port the server listens on (`GERBEUR_PORT`, default 8000). */ export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000; @@ -18,6 +19,12 @@ export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000; */ export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || ""; +/** + * Whether the instance sends email, which is whether {@link SMTPS_URL} is + * set. `email-service.ts` opens its transport by it, and index.html tells the + * client, whose settings show the digest switches disabled without it. + */ +export const EMAIL_ENABLED: boolean = SMTPS_URL.length > 0; /** * The `From` address of every email (`GERBEUR_FROM_EMAIL`), as an address or * as `Name
`. `email-service.ts` refuses to load without it when @@ -33,24 +40,25 @@ export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || */ export const WELCOME_EMAIL_BODY: string | null = Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || null; -/** A language the emails are written in. */ -export type EmailLocale = "en" | "fr"; /** * The language of every email (`GERBEUR_EMAIL_LOCALE`). The interface picks * each visitor's language in their browser, which the server never learns, so - * the emails take the instance's. Any value but `"fr"` means `"en"`. + * the emails take the instance's. A value that isn't a locale means the + * source one, English. */ -export const EMAIL_LOCALE: EmailLocale = - Deno.env.get("GERBEUR_EMAIL_LOCALE")?.trim() === "fr" ? "fr" : "en"; +export const EMAIL_LOCALE: Locale = (() => { + const locale = Deno.env.get("GERBEUR_EMAIL_LOCALE")?.trim(); + return isLocale(locale) ? locale : SOURCE_LOCALE; +})(); /** * The instance's style (`GERBEUR_DEFAULT_STYLE`). The site shows it until a * visitor or their account picks one, through index.html, and the emails that * aren't a user's own (the welcome and the password reset) are drawn in it. A - * value that isn't a style means "smooth". + * value that isn't a style means {@link BASE_STYLE}. */ export const DEFAULT_STYLE: StyleName = (() => { const style = Deno.env.get("GERBEUR_DEFAULT_STYLE")?.trim(); - return isStyleName(style) ? style : "smooth"; + return isStyleName(style) ? style : BASE_STYLE; })(); /** * The key lib/jwt.ts signs and verifies tokens with (`GERBEUR_JWT_SECRET`). diff --git a/api/db/migrate.ts b/api/db/migrate.ts index c8edc0e9..75e08cae 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -26,6 +26,7 @@ import { up as up0016PerformanceIndexes } from "./migrations/0016_performance_in import { up as up0017CaseInsensitiveUsers } from "./migrations/0017_case_insensitive_users.ts"; import { up as up0018AttachmentResourceType } from "./migrations/0018_attachment_resource_type.ts"; import { up as up0019UserSettings } from "./migrations/0019_user_settings.ts"; +import { up as up0020DigestSchedule } from "./migrations/0020_digest_schedule.ts"; interface Migration { name: string; @@ -64,6 +65,7 @@ const MIGRATIONS: Migration[] = [ up: up0018AttachmentResourceType, }, { name: "0019_user_settings", up: up0019UserSettings }, + { name: "0020_digest_schedule", up: up0020DigestSchedule }, ]; /** diff --git a/api/db/migrations/0020_digest_schedule.ts b/api/db/migrations/0020_digest_schedule.ts new file mode 100644 index 00000000..ee3a507b --- /dev/null +++ b/api/db/migrations/0020_digest_schedule.ts @@ -0,0 +1,52 @@ +/** + * Lets `digest-service.ts` know when each user's next digest is due. + * `digest_schedule` holds, for each user and cadence they receive, the start + * of the period their next digest covers; it is due once a full period has + * passed. + * + * @module + */ + +import type { DatabaseSync } from "node:sqlite"; + +const DAY_MS = 86_400_000; +// A frozen copy of digest-service's periods: a migration must keep doing +// what it did when it shipped, whatever the service later becomes. +const PERIOD_DAYS = { weekly: 7, monthly: 30 } as const; + +/** Spreads `userId` over `[0, days)` days, the same way each run. */ +function offsetOf(userId: string, days: number): number { + let hash = 2166136261; + for (const ch of userId) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619); + return ((hash >>> 0) % (days * 24)) * 3_600_000; +} + +/** + * Run once by `runMigrations`. Every user present gets both cadences, with a + * first period that ends at some hour of the coming week or month, picked + * from their id: the digests start trickling out after the deploy instead of + * all leaving a week later. Users created later are scheduled by the service + * when it first sees them. + */ +export function up(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS digest_schedule ( + user_id TEXT NOT NULL, + cadence TEXT NOT NULL, + window_start TEXT NOT NULL, + PRIMARY KEY (user_id, cadence), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + const users = db.prepare(`SELECT id FROM users;`).all() as { id: string }[]; + const insert = db.prepare( + `INSERT OR IGNORE INTO digest_schedule (user_id, cadence, window_start) VALUES (?, ?, ?);`, + ); + const now = Date.now(); + for (const { id } of users) { + for (const [cadence, days] of Object.entries(PERIOD_DAYS)) { + const start = now - days * DAY_MS + offsetOf(id, days); + insert.run(id, cadence, new Date(start).toISOString()); + } + } +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 9b744672..c9a701a2 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -223,6 +223,14 @@ CREATE TABLE user_settings ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE digest_schedule ( + user_id TEXT NOT NULL, + cadence TEXT NOT NULL, + window_start TEXT NOT NULL, + PRIMARY KEY (user_id, cadence), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE chat_messages ( id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL, diff --git a/api/lib/index-html.ts b/api/lib/index-html.ts index 65fda03d..c211554e 100644 --- a/api/lib/index-html.ts +++ b/api/lib/index-html.ts @@ -9,9 +9,9 @@ import { BANDCAMP_PLAYER, DEFAULT_STYLE, + EMAIL_ENABLED, OG_SITE_NAME, SITE_EMOJI, - SMTPS_URL, } from "../config.ts"; import { emojiToCodepoint } from "./site-icons.ts"; @@ -43,7 +43,7 @@ export async function loadIndexHtml(root: string): Promise { .replaceAll("__ICON_VERSION__", ICON_VERSION) .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER) .replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE) - .replaceAll("__EMAIL_ENABLED__", SMTPS_URL ? "on" : "off"); + .replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off"); indexHtml.set(root, { mtime, html }); return html; } diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index b89a7509..35e248ec 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -1,6 +1,6 @@ /** - * Signs and verifies the server's three kinds of token (session, media and - * password reset), all HS256 with the one key. Only a token's signature and + * Signs and verifies the server's four kinds of token (session, media, + * password reset and unsubscribe), all HS256 with the one key. Only a token's signature and * expiry are checked here: `lib/auth.ts` checks a session or media token * against its user. * @@ -18,6 +18,7 @@ import { type PasswordResetPayload, } from "../model/auth.ts"; import { JWT_SECRET } from "../config.ts"; +import { type DigestCadence, isDigestCadence } from "../../shared/settings.ts"; if (!JWT_SECRET) { throw new Error( @@ -128,3 +129,39 @@ export async function verifyJWT(token: string): Promise { return null; } } + +/** + * Signs the token of a digest's unsubscribe link, which turns off `cadence` + * for `userId` without signing in. It never expires, so the link in an old + * email still works; turning the digest back on doesn't revoke it, which is + * harmless since all it can do is turn it off again. + */ +export async function createUnsubscribeToken( + userId: string, + cadence: DigestCadence, +): Promise { + return await new SignJWT({ purpose: "unsubscribe", userId, cadence }) + .setProtectedHeader({ alg: "HS256" }) + .sign(JWT_KEY); +} + +/** + * Returns the user and cadence of an unsubscribe token, or null when it is + * invalid or another kind of token. + */ +export async function verifyUnsubscribeToken( + token: string, +): Promise<{ userId: string; cadence: DigestCadence } | null> { + try { + const { payload } = await jwtVerify(token, JWT_KEY); + if ( + payload.purpose !== "unsubscribe" || typeof payload.userId !== "string" || + !isDigestCadence(payload.cadence) + ) { + return null; + } + return { userId: payload.userId, cadence: payload.cadence }; + } catch { + return null; + } +} diff --git a/api/lib/reserved-slugs.ts b/api/lib/reserved-slugs.ts index effeb18a..30b91a8b 100644 --- a/api/lib/reserved-slugs.ts +++ b/api/lib/reserved-slugs.ts @@ -23,6 +23,7 @@ export const RESERVED_SLUGS: readonly string[] = [ "register", "notifications", "reset-password", + "unsubscribe", "manage", "api", "ws", diff --git a/api/mail/digest.ts b/api/mail/digest.ts new file mode 100644 index 00000000..6b25e7b5 --- /dev/null +++ b/api/mail/digest.ts @@ -0,0 +1,622 @@ +/** + * Renders the weekly digest and the monthly recap, as the drafts approved + * them: the weekly one has four sections (what happened to the reader, what + * the people and playlists they follow posted, the period's top dumps, and + * the community), the monthly one only the last two. `digest-service.ts` + * gathers a {@link DigestContent} and sends what this returns, drawn in the + * reader's own style and scheme. + * + * @module + */ + +import { EMAIL_LOCALE, OG_SITE_NAME } from "../config.ts"; +import type { DigestCadence } from "../../shared/settings.ts"; +import { i18n } from "./i18n.ts"; +import { + EmailKit, + type EmailLook, + escMd, + mdLink, + type RenderedEmail, +} from "./layout.ts"; + +/** The kinds of activity a weekly digest counts. */ +export type ActivityKind = + | "upvotes" + | "followers" + | "comments" + | "mentions" + | "likes" + | "playlistFollows"; + +/** What a highlight of the weekly digest says happened. */ +export type HighlightKind = + | "mentionComment" + | "mentionDump" + | "mentionPlaylist" + | "mentionChat" + | "comment" + | "playlistFollow" + | "userFollow" + | "commentLike"; + +/** A dump in a digest's lists, with its links made absolute. */ +export interface DigestDump { + title: string; + url: string; + poster: string; + /** The link's host, or null for an upload. */ + domain: string | null; + /** An absolute image URL, or null to show no image. */ + thumbnail: string | null; + votes: number; + comments: number; + /** Posted by the reader, which the lists mark. */ + mine: boolean; + /** The followed playlist it was added to, when that's why it's listed. */ + addedTo?: string; +} + +/** One line of the weekly digest's "For you", made from a notification. */ +export interface DigestHighlight { + kind: HighlightKind; + /** Who did it. */ + who: string; + /** What they did it on, absent for a follow of the reader or the chat. */ + target?: { title: string; url: string }; + /** The comment or message, flattened and shortened. */ + quote?: string; + at: Date; +} + +/** + * Passed to {@link renderDigestEmail} by `digest-service.ts`. Each section is + * null when it has nothing to show, and is left out; the service sends + * nothing when all of them are. + */ +export interface DigestContent { + cadence: DigestCadence; + /** The period covered, from `from` to `to`. */ + from: Date; + to: Date; + reader: string; + /** Weekly only. */ + activity: { + counts: Partial>; + highlights: DigestHighlight[]; + /** Every notification of the period, which the link to them counts. */ + total: number; + } | null; + /** Weekly only. */ + network: { dumps: DigestDump[]; more: number } | null; + top: DigestDump[]; + community: { + members: { username: string; invitedBy: string | null }[]; + membersMore: number; + playlists: { title: string; url: string; owner: string; dumps: number }[]; + playlistsMore: number; + chatMessages: number; + totals: { dumps: number; upvotes: number; comments: number }; + } | null; + links: { + notifications: string; + followed: string; + chat: string; + settings: string; + /** The one-click page that turns this cadence off. */ + unsubscribe: string; + }; +} + +const ACTIVITY_ORDER: ActivityKind[] = [ + "upvotes", + "followers", + "comments", + "mentions", + "likes", + "playlistFollows", +]; + +/** Returns the label of `n` of a kind of activity, as in "12 upvotes". */ +function countLabel(kind: ActivityKind, n: number): string { + switch (kind) { + case "upvotes": + return i18n._({ + id: "email.digest.count.upvotes", + message: "{n, plural, one {upvote} other {upvotes}}", + values: { n }, + }); + case "followers": + return i18n._({ + id: "email.digest.count.followers", + message: "{n, plural, one {new follower} other {new followers}}", + values: { n }, + }); + case "comments": + return i18n._({ + id: "email.digest.count.comments", + message: "{n, plural, one {comment} other {comments}}", + values: { n }, + }); + case "mentions": + return i18n._({ + id: "email.digest.count.mentions", + message: "{n, plural, one {mention} other {mentions}}", + values: { n }, + }); + case "likes": + return i18n._({ + id: "email.digest.count.likes", + message: "{n, plural, one {comment like} other {comment likes}}", + values: { n }, + }); + case "playlistFollows": + return i18n._({ + id: "email.digest.count.playlistFollows", + message: "{n, plural, one {playlist follow} other {playlist follows}}", + values: { n }, + }); + } +} + +/** + * Returns the sentence of a highlight, `who` and `target` already formatted + * for the part it goes in (HTML or Markdown). + */ +function highlightSentence( + kind: HighlightKind, + who: string, + target: string, +): string { + const values = { who, target }; + switch (kind) { + case "mentionComment": + return i18n._({ + id: "email.digest.hl.mentionComment", + message: "{who} mentioned you in a comment on {target}", + values, + }); + case "mentionDump": + case "mentionChat": + return i18n._({ + id: "email.digest.hl.mentionIn", + message: "{who} mentioned you in {target}", + values, + }); + case "mentionPlaylist": + return i18n._({ + id: "email.digest.hl.mentionPlaylist", + message: "{who} mentioned you in the playlist {target}", + values, + }); + case "comment": + return i18n._({ + id: "email.digest.hl.comment", + message: "{who} commented on your dump {target}", + values, + }); + case "playlistFollow": + return i18n._({ + id: "email.digest.hl.playlistFollow", + message: "{who} followed your playlist {target}", + values, + }); + case "userFollow": + return i18n._({ + id: "email.digest.hl.userFollow", + message: "{who} started following you", + values, + }); + case "commentLike": + return i18n._({ + id: "email.digest.hl.commentLike", + message: "{who} liked your comment on {target}", + values, + }); + } +} + +const byLine = (username: string) => + i18n._({ + id: "email.digest.by", + message: "by @{username}", + values: { username }, + }); +const addedToLine = (playlist: string) => + i18n._({ + id: "email.digest.addedTo", + message: "added to {playlist}", + values: { playlist }, + }); +const uploadLabel = () => + i18n._({ id: "email.digest.upload", message: "upload" }); +const invitedLine = (inviter: string, reader: string) => + inviter === reader + ? i18n._({ id: "email.digest.invitedByYou", message: "invited by you" }) + : i18n._({ + id: "email.digest.invitedBy", + message: "invited by @{username}", + values: { username: inviter }, + }); +const andMoreLine = (n: number) => + i18n._({ + id: "email.digest.andMore", + message: "and {n} more", + values: { n }, + }); +const playlistMeta = (owner: string, n: number) => + i18n._({ + id: "email.digest.playlistMeta", + message: "by @{username} · {n, plural, one {# dump} other {# dumps}}", + values: { username: owner, n }, + }); + +/** + * Returns the digest `content` describes, written as Markdown and drawn in + * `look`, with its subject and hidden preheader. + */ +export function renderDigestEmail( + content: DigestContent, + look: EmailLook, +): RenderedEmail { + const monthly = content.cadence === "monthly"; + const nf = new Intl.NumberFormat(EMAIL_LOCALE); + const range = (year: boolean) => + new Intl.DateTimeFormat( + EMAIL_LOCALE, + year + ? { month: "short", day: "numeric", year: "numeric" } + : { month: "short", day: "numeric" }, + ).formatRange(content.from, content.to); + const day = (d: Date) => + new Intl.DateTimeFormat(EMAIL_LOCALE, { weekday: "short" }).format(d); + const figure = (n: number, label: string) => + `**${nf.format(n)}** ${escMd(label)}`; + const yours = i18n._({ id: "email.digest.yours", message: "yours" }); + /** A dump as a list item: its thumbnail, title, pill and detail. */ + const dumpItem = (d: DigestDump, bullet: string, image = true) => + `${bullet} ${image && d.thumbnail ? `![](${d.thumbnail}) ` : ""}${ + mdLink(d.title, d.url) + }${d.mine ? ` *${escMd(yours)}*` : ""} · ${ + escMd( + `${d.addedTo ? addedToLine(d.addedTo) : byLine(d.poster)}${ + d.addedTo ? "" : ` · ${d.domain ?? uploadLabel()}` + } · ${nf.format(d.votes)} ${countLabel("upvotes", d.votes)} · ${ + nf.format(d.comments) + } ${countLabel("comments", d.comments)}`, + ) + }`; + + const md: string[] = [ + `*${ + escMd( + `${ + monthly + ? i18n._({ + id: "digest.monthly", + message: "Monthly recap", + }) + : i18n._({ + id: "digest.weekly", + message: "Weekly digest", + }) + } · ${range(true)}`, + ) + }*`, + `# ${ + escMd( + monthly + ? i18n._({ + id: "email.digest.monthly.title", + message: "The month on {site}", + values: { site: OG_SITE_NAME }, + }) + : i18n._({ + id: "email.digest.weekly.title", + message: "Your week, {username}", + values: { username: content.reader }, + }), + ) + }`, + ]; + + const { activity, network, community } = content; + if (activity) { + md.push( + `## ${escMd(i18n._({ id: "email.digest.forYou", message: "For you" }))}`, + ACTIVITY_ORDER.filter((k) => (activity.counts[k] ?? 0) > 0) + .map((k) => + figure(activity.counts[k]!, countLabel(k, activity.counts[k]!)) + ) + .join(" · "), + activity.highlights.map((h) => { + const target = h.kind === "mentionChat" + ? mdLink( + i18n._({ id: "email.digest.theChat", message: "the chat" }), + content.links.chat, + ) + : h.target + ? mdLink(h.target.title, h.target.url) + : ""; + const line = `- ${ + highlightSentence(h.kind, `**@${escMd(h.who)}**`, target) + } *${escMd(day(h.at))}*`; + return h.quote ? `${line}\n > ${escMd(h.quote)}` : line; + }).join("\n"), + `${ + mdLink( + i18n._({ + id: "email.digest.seeAll", + message: + "{n, plural, one {See the notification} other {See all # notifications}}", + values: { n: activity.total }, + }), + content.links.notifications, + ) + } →`, + ); + } + + if (network) { + md.push( + `## ${ + escMd(i18n._({ + id: "email.digest.network", + message: "From people you follow", + })) + }`, + network.dumps.map((d) => dumpItem(d, "-")).join("\n"), + ); + if (network.more > 0) { + md.push(`${ + mdLink( + i18n._({ + id: "email.digest.more", + message: "{n} more on your Followed feed", + values: { n: network.more }, + }), + content.links.followed, + ) + } →`); + } + } + + if (content.top.length > 0) { + const [first] = content.top; + md.push( + `## ${ + escMd( + monthly + ? i18n._({ id: "email.digest.topMonth", message: "Top this month" }) + : i18n._({ id: "email.digest.topWeek", message: "Top this week" }), + ) + }`, + ); + // The recap gives its first dump a wide image and the next two a thumbnail. + if (monthly && first.thumbnail) md.push(`![](${first.thumbnail})`); + md.push( + content.top.map((d, i) => + dumpItem(d, `${i + 1}.`, monthly ? i === 1 || i === 2 : true) + ).join("\n"), + ); + } + + if (community) { + const { dumps, upvotes, comments } = community.totals; + md.push( + `## ${ + escMd(i18n._({ id: "email.digest.community", message: "Community" })) + }`, + ); + if (monthly) { + md.push( + [ + figure( + dumps, + i18n._({ + id: "email.digest.stat.dumps", + message: "{n, plural, one {dump} other {dumps}}", + values: { n: dumps }, + }), + ), + figure(upvotes, countLabel("upvotes", upvotes)), + figure(comments, countLabel("comments", comments)), + figure( + community.chatMessages, + i18n._({ + id: "email.digest.stat.chat", + message: "{n, plural, one {chat message} other {chat messages}}", + values: { n: community.chatMessages }, + }), + ), + ].join(" · "), + ); + } + if (community.members.length > 0) { + md.push( + `### ${ + escMd( + i18n._({ id: "email.digest.newMembers", message: "New members" }), + ) + }`, + [ + ...community.members.map((m) => + `- **@${escMd(m.username)}**${ + m.invitedBy + ? ` ${escMd(invitedLine(m.invitedBy, content.reader))}` + : "" + }` + ), + ...(community.membersMore > 0 + ? [`- ${escMd(andMoreLine(community.membersMore))}`] + : []), + ].join("\n"), + ); + } + if (community.playlists.length > 0) { + md.push( + `### ${ + escMd(i18n._({ + id: "email.digest.newPlaylists", + message: "New public playlists", + })) + }`, + [ + ...community.playlists.map((p) => + `- ${mdLink(p.title, p.url)} · ${ + escMd(playlistMeta(p.owner, p.dumps)) + }` + ), + ...(community.playlistsMore > 0 + ? [`- ${escMd(andMoreLine(community.playlistsMore))}`] + : []), + ].join("\n"), + ); + } + if (!monthly && community.chatMessages > 0) { + md.push( + `### ${ + escMd(i18n._({ id: "email.digest.inChat", message: "In the chat" })) + }`, + `${ + escMd(i18n._({ + id: "email.digest.chatWeek", + message: + "{n, plural, one {# message this week} other {# messages this week}}", + values: { n: community.chatMessages }, + })) + } · ${ + mdLink( + i18n._({ id: "email.digest.openChat", message: "Open the chat" }), + content.links.chat, + ) + }`, + ); + } + if (!monthly && dumps + upvotes + comments > 0) { + md.push(`*${ + escMd(i18n._({ + id: "email.digest.totals", + message: + "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}.", + values: { dumps, upvotes, comments }, + })) + }*`); + } + } + + md.push( + "---", + escMd( + monthly + ? i18n._({ + id: "email.digest.monthly.reason", + message: + "You're getting the monthly recap because it's on in your settings.", + }) + : i18n._({ + id: "email.digest.weekly.reason", + message: + "You're getting the weekly digest because it's on in your settings.", + }), + ), + `${ + mdLink( + monthly + ? i18n._({ + id: "digest.monthly.off", + message: "Turn off monthly recaps", + }) + : i18n._({ + id: "digest.weekly.off", + message: "Turn off weekly digests", + }), + content.links.unsubscribe, + ) + } · ${ + mdLink( + i18n._({ id: "email.settings", message: "Settings" }), + content.links.settings, + ) + }`, + ); + + return new EmailKit(look).render({ + subject: monthly + ? i18n._({ + id: "email.digest.monthly.subject", + message: "The month on {site}, {range}", + values: { site: OG_SITE_NAME, range: range(false) }, + }) + : i18n._({ + id: "email.digest.weekly.subject", + message: "Your week on {site}, {range}", + values: { site: OG_SITE_NAME, range: range(false) }, + }), + preheader: preheader(content), + markdown: md.join("\n\n"), + }); +} + +/** + * Returns the line an inbox shows beside the subject: the counts that make + * the digest worth opening, at most three of them. + */ +function preheader(content: DigestContent): string { + const parts: string[] = []; + if (content.cadence === "weekly") { + for (const k of ["upvotes", "followers", "comments"] as const) { + const n = content.activity?.counts[k] ?? 0; + if (n > 0) parts.push(`${n} ${countLabel(k, n)}`); + } + const n = (content.network?.dumps.length ?? 0) + + (content.network?.more ?? 0); + if (n > 0) { + parts.push(i18n._({ + id: "email.digest.pre.network", + message: + "{n, plural, one {# new dump from people you follow} other {# new dumps from people you follow}}", + values: { n }, + })); + } + } else { + const members = (content.community?.members.length ?? 0) + + (content.community?.membersMore ?? 0); + const playlists = (content.community?.playlists.length ?? 0) + + (content.community?.playlistsMore ?? 0); + if (content.top.length > 0) { + parts.push(i18n._({ + id: "email.digest.pre.topMonth", + message: + "{n, plural, one {the most upvoted dump of the month} other {the # most upvoted dumps of the month}}", + values: { n: content.top.length }, + })); + } + if (members > 0) { + parts.push(i18n._({ + id: "email.digest.pre.members", + message: "{n, plural, one {# new member} other {# new members}}", + values: { n: members }, + })); + } + if (playlists > 0) { + parts.push(i18n._({ + id: "email.digest.pre.playlists", + message: "{n, plural, one {# new playlist} other {# new playlists}}", + values: { n: playlists }, + })); + } + } + if (parts.length === 0 && content.top.length > 0) { + parts.push(i18n._({ + id: "email.digest.pre.topWeek", + message: + "{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}", + values: { n: content.top.length }, + })); + } + if (parts.length === 0) return ""; + const line = new Intl.ListFormat(EMAIL_LOCALE, { type: "conjunction" }) + .format(parts.slice(0, 3)); + return `${line.charAt(0).toUpperCase()}${line.slice(1)}.`; +} diff --git a/api/mail/i18n.ts b/api/mail/i18n.ts new file mode 100644 index 00000000..b9c0da9d --- /dev/null +++ b/api/mail/i18n.ts @@ -0,0 +1,33 @@ +/** + * Lets the emails speak the instance's language from the same catalogs as + * the interface. Their messages are written in `api/mail/` as + * `i18n._({ id, message }, values)`, which `lingui extract` collects into + * `locales/.po` beside the interface's and `lingui compile` turns into + * the `locales/.mjs` imported here. The ids are spelled out, since the + * server runs no Lingui macro to hash them. + * + * @module + */ + +import { type I18n, type Messages, setupI18n } from "@lingui/core"; + +import { EMAIL_LOCALE } from "../config.ts"; +import { LOCALES } from "../../shared/locale.ts"; + +const catalogs: [string, Messages][] = await Promise.all( + LOCALES.map(async (locale) => + [ + locale, + (await import(`../../locales/${locale}.mjs`)).messages as Messages, + ] as [string, Messages] + ), +); + +/** + * Translates the emails' messages, in {@link EMAIL_LOCALE}. Every catalog is + * loaded, so a test can activate another language. + */ +export const i18n: I18n = setupI18n({ + locale: EMAIL_LOCALE, + messages: Object.fromEntries(catalogs), +}); diff --git a/api/mail/layout.ts b/api/mail/layout.ts index c7806383..9e9f83eb 100644 --- a/api/mail/layout.ts +++ b/api/mail/layout.ts @@ -13,8 +13,20 @@ * @module */ -import { type EmailLocale, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; -import type { ColorScheme, StyleName } from "../../shared/theme.ts"; +import { Marked, Parser, type Token, type Tokens } from "marked"; + +import { + DEFAULT_STYLE, + EMAIL_LOCALE, + OG_SITE_NAME, + SITE_EMOJI, +} from "../config.ts"; +import { + type ColorScheme, + DEFAULT_COLOR_SCHEME, + type StyleName, +} from "../../shared/theme.ts"; +import type { UserSettings } from "../../shared/settings.ts"; import { EMAIL_STYLES, type EmailPalette, @@ -29,19 +41,36 @@ import { export interface RenderedEmail { subject: string; html: string; - /** The plain-text part, for clients that show no HTML. */ + /** + * The plain-text part, for clients that show no HTML. It is Markdown, so it + * reads as text and renders as the email's outline. + */ text: string; } /** - * Passed to {@link EmailKit}. The instance's style draws the emails that - * aren't a user's own; a digest takes its reader's settings. + * Passed to {@link EmailKit}, made by {@link lookOf}: the instance's style for + * the emails that aren't a user's own, their settings for a digest. */ export interface EmailLook { style: StyleName; scheme: ColorScheme; } +/** + * Returns the look of a reader's settings. An unset style is the instance's, + * as on the site, and an unset scheme the system's; with no settings at all, + * as for the welcome and reset emails, both are. + */ +export function lookOf( + settings: Pick | null = null, +): EmailLook { + return { + style: settings?.style ?? DEFAULT_STYLE, + scheme: settings?.colorScheme ?? DEFAULT_COLOR_SCHEME, + }; +} + /** * Names a color of an {@link EmailPalette}, as {@link EmailKit.paint} takes * it, so that the dark block can recolor the element by the same name. @@ -79,14 +108,31 @@ export function esc(value: string): string { ); } +/** + * Escapes `value` for Markdown, so that user text in a plain-text part (a + * title with `*` or `[`, say) stays text. + */ +export function escMd(value: string): string { + return value.replace(/[\\`*_[\]<>#!|~]/g, "\\$&"); +} + +/** Returns a Markdown link to `url` labelled with `text`, escaped. */ +export function mdLink(text: string, url: string): string { + const safeUrl = url.replace( + /[()\s]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`, + ); + return `[${escMd(text)}](${safeUrl})`; +} + const gradient = (colors: string[]) => `linear-gradient(90deg,${colors.join(",")})`; /** - * Draws the blocks of an email in one {@link EmailLook}. A template creates - * one, builds its body with the block methods, and wraps it with - * {@link EmailKit.document}. The blocks return HTML: text passed to them must - * already be escaped with {@link esc}. + * Draws an email in one {@link EmailLook}. A template writes the email as + * Markdown and hands it to {@link EmailKit.render}, which draws each block in + * the style. The block methods return HTML: text passed to them must already + * be escaped with {@link esc}. */ export class EmailKit { /** The style being drawn. */ @@ -98,10 +144,12 @@ export class EmailKit { /** The font stack, for templates that set their own blocks. */ readonly font: string; - constructor(look: EmailLook, readonly locale: EmailLocale) { + constructor(look: EmailLook) { this.style = EMAIL_STYLES[look.style]; this.palette = look.scheme === "dark" ? this.style.dark : this.style.light; - this.darkPalette = look.scheme === "auto" ? this.style.dark : null; + this.darkPalette = look.scheme === DEFAULT_COLOR_SCHEME + ? this.style.dark + : null; this.font = this.style.font; } @@ -140,7 +188,9 @@ export class EmailKit { style += `border-color:${p[roles.bd] ?? p.rule};`; } const cls = classes.length ? ` class="${classes.join(" ")}"` : ""; - return `${cls} style="${style}${css}"`; + // The colors come last: a shorthand such as `border-top` in `css` would + // otherwise reset them. + return `${cls} style="${css}${style}"`; } /** Returns a link to `href` around `html`, in the link color. */ @@ -156,21 +206,6 @@ export class EmailKit { }>${html}`; } - /** Returns `@username`, set off as a name. */ - name(username: string): string { - return `@${esc(username)}`; - } - - /** Returns `html` in the muted color, inline. */ - muted(html: string): string { - return `${html}`; - } - /** Returns the email's heading, in the style's title type. */ heading(html: string, marginBottom = 24): string { const t = this.style.title; @@ -281,30 +316,6 @@ export class EmailKit { }>${html}`; } - /** Returns `lines`, one per line, in the secondary color. */ - lines(lines: string[]): string { - return `${lines.join("
")}`; - } - - /** Returns a block set off from what precedes it by a hairline. */ - aside(html: string): string { - return `${html}`; - } - /** * Returns a section of the card: a divider in the style's rule (none before * the first), an uppercase title in the accent color, and `html`. @@ -343,16 +354,327 @@ export class EmailKit { } /** - * Returns the whole HTML document: the brand, the card with the optional - * `eyebrow` and `title` over `body`, and the `footer`. `preheader` is the - * hidden first line inboxes show beside the subject. + * Returns the email written as `markdown`: the Markdown itself is the + * plain-text part, and its blocks, drawn in this kit's style, are the HTML. + * Each template writes its email once this way. The blocks map as follows. + * + * - `# Title` is the heading; an `*emphasised*` paragraph above it, the + * eyebrow. Elsewhere an emphasised paragraph is a note. + * - `## Section` starts a section, with its divider; `### Name` a subhead. + * - A link alone in a paragraph is a button, an image alone a wide image, + * and a fenced block a box for a raw link. + * - A list item whose text starts with an image, then a link, is a row + * with that thumbnail and the link as its title; what follows is its + * detail, and an emphasised word right after the link a pill. Other items + * are sentences, where emphasis marks the date. A numbered list ranks its + * rows, and a quote under an item is set as one. + * - A bold number is a figure. + * - What follows the last `---` is the footer. */ - document( - { subject, preheader, eyebrow, title, body, footer }: { + render( + { subject, preheader, markdown }: { + subject: string; + preheader: string; + markdown: string; + }, + ): RenderedEmail { + const cut = markdown.lastIndexOf("\n---\n"); + const body = cut === -1 ? markdown : markdown.slice(0, cut); + const foot = cut === -1 ? "" : markdown.slice(cut + 5); + return { + subject, + html: this.document({ + subject, + preheader, + body: this.blocks(this.marked.lexer(body)), + footer: this.marked.lexer(foot) + .filter((t): t is Tokens.Paragraph => t.type === "paragraph") + .map((t) => this.inline(t.tokens, "footer")).join("
"), + }), + text: markdown, + }; + } + + /** How {@link inline} draws emphasis and links, by where they sit. */ + private mode: "text" | "footer" | "row" | "sentence" = "text"; + + /** Draws Markdown's inline tokens with the kit's colors. */ + private readonly marked: Marked = (() => { + // deno-lint-ignore no-this-alias + const kit = this; + return new Marked({ + renderer: { + link({ href, tokens }) { + const html = this.parser.parseInline(tokens); + return kit.mode === "footer" + ? `${html}` + : kit.link(html, href); + }, + strong({ tokens }) { + const html = this.parser.parseInline(tokens); + const figure = /^[\d\s.,  ]+$/.test(html); + return `${html}`; + }, + em({ tokens }) { + const html = this.parser.parseInline(tokens); + if (kit.mode === "row") return kit.pill(html); + if (kit.mode === "sentence") { + return `${html}`; + } + return `${html}`; + }, + codespan({ text }) { + return `${text}`; + }, + image({ href, text }) { + return kit.image(href, text, 104, 78); + }, + }, + }); + })(); + + private inline(tokens: Token[], mode: EmailKit["mode"]): string { + this.mode = mode; + try { + return Parser.parseInline(tokens, this.marked.defaults); + } finally { + this.mode = "text"; + } + } + + /** Draws the body's blocks, gathering each `##` section with its content. */ + private blocks(tokens: Token[]): string { + const out: string[] = []; + let section: { title: string; parts: string[] } | null = null; + let first = true; + let headed = false; + const push = (html: string) => + section ? section.parts.push(html) : out.push(html); + const close = () => { + if (!section) return; + out.push(this.section(section.title, section.parts.join(""), first)); + first = false; + section = null; + }; + for (const token of tokens) { + switch (token.type) { + case "heading": { + const html = this.inline(token.tokens ?? [], "text"); + if (token.depth === 1) { + headed = true; + push(this.heading(html)); + } else if (token.depth === 2) { + close(); + section = { title: html, parts: [] }; + } else { + push(this.subhead(html)); + } + break; + } + case "paragraph": { + const kids = (token.tokens ?? []).filter((t) => + !(t.type === "text" && !t.raw.trim()) + ); + const only = kids.length === 1 ? kids[0] : null; + if (only?.type === "link") { + push( + this.button(this.inline(only.tokens ?? [], "text"), only.href), + ); + } else if (only?.type === "image") { + push( + `${ + this.image(only.href, only.text, 528, 264) + }
 
`, + ); + } else if (only?.type === "em") { + const html = this.inline(only.tokens ?? [], "text"); + push(headed ? this.note(html) : this.eyebrow(html)); + } else { + push(this.para(this.inline(token.tokens ?? [], "text"))); + } + break; + } + case "list": + push(this.list(token as Tokens.List)); + break; + case "blockquote": + push(this.quote(this.inline( + (token.tokens ?? []).flatMap((t) => + "tokens" in t ? t.tokens ?? [] : [] + ), + "text", + ))); + break; + case "code": + push(this.linkBox(esc(token.text))); + break; + case "html": + push(token.text); + break; + } + } + close(); + return out.join(""); + } + + /** Draws a list as rows, ranked when it is numbered. */ + private list(list: Tokens.List): string { + const start = typeof list.start === "number" ? list.start : 1; + return list.items.map((item, i) => { + const text = item.tokens.find((t) => + t.type === "text" || t.type === "paragraph" + ) as Tokens.Text | Tokens.Paragraph | undefined; + const quote = item.tokens.find((t) => t.type === "blockquote") as + | Tokens.Blockquote + | undefined; + let rest: Token[] = [...(text?.tokens ?? [])]; + const skipSpace = () => { + while (rest[0]?.type === "text" && !rest[0].raw.trim()) { + rest = rest.slice(1); + } + }; + skipSpace(); + const image = rest[0]?.type === "image" + ? rest.shift() as Tokens.Image + : null; + skipSpace(); + const link = rest[0]?.type === "link" + ? rest.shift() as Tokens.Link + : null; + + let content: string; + if (link) { + const pills: Token[] = []; + skipSpace(); + while (rest[0]?.type === "em") { + pills.push(rest.shift()!); + skipSpace(); + } + // The detail follows the title after a " · ". + if (rest[0]?.type === "text") { + const t = rest[0] as Tokens.Text; + const trimmed = t.text.replace(/^\s*·\s*/, ""); + rest[0] = { ...t, text: trimmed, raw: trimmed, tokens: undefined }; + } + content = `${this.inline(link.tokens, "text")}${this.inline(pills, "row")}${ + rest.length + ? `${this.inline(rest, "text")}` + : "" + }`; + } else { + content = `${this.inline(rest, "sentence")}`; + } + if (quote) { + content += this.quote(this.inline( + quote.tokens.flatMap((t) => "tokens" in t ? t.tokens ?? [] : []), + "text", + )); + } + + const size = list.ordered ? [64, 48] : [104, 78]; + return `${ + list.ordered + ? `` + : "" + }${ + image + ? `` + : "" + }
${start + i}${ + this.image(image.href, image.text, size[0], size[1], link?.href) + }${content}
`; + }).join(""); + } + + /** Returns the small uppercase line above the heading. */ + private eyebrow(html: string): string { + return `${html}`; + } + + /** Returns a quoted comment or message. */ + private quote(html: string): string { + return `“${html}”`; + } + + /** Returns a raw link set in a box, for clients that break buttons. */ + private linkBox(html: string): string { + return `${html}`; + } + + /** + * Returns the whole HTML document: the brand, the card holding `body`, and + * the `footer`. `preheader` is the hidden first line inboxes show beside the + * subject. + */ + private document( + { subject, preheader, body, footer }: { subject: string; preheader: string; - eyebrow?: string; - title?: string; body: string; footer: string; }, @@ -390,7 +712,7 @@ export class EmailKit { : "light"; return ` - + @@ -453,19 +775,6 @@ ${ };${frame}padding:34px 36px 12px;`, ) }> -${ - eyebrow - ? `${eyebrow}` - : "" - } -${title ? this.heading(title) : ""} ${body} `; } - /** - * Returns a footer: `reason` (why the reader gets this email), then - * `links`, each `[label, href]`, joined on one line. - */ - footer(reason: string, links: [string, string][] = []): string { - const linkHtml = links.map(([label, href]) => - `${label}` - ).join(" · "); - return `${reason}${linkHtml ? `
${linkHtml}` : ""}`; - } - /** * Returns the CSS that recolors every painted element in dark mode, or * nothing off the "auto" scheme. diff --git a/api/mail/password-reset.ts b/api/mail/password-reset.ts index e9278a5a..a4889bb5 100644 --- a/api/mail/password-reset.ts +++ b/api/mail/password-reset.ts @@ -6,9 +6,15 @@ * @module */ -import { DEFAULT_STYLE, EMAIL_LOCALE, OG_SITE_NAME } from "../config.ts"; -import { EmailKit, esc, type RenderedEmail } from "./layout.ts"; -import { STRINGS } from "./strings.ts"; +import { OG_SITE_NAME } from "../config.ts"; +import { i18n } from "./i18n.ts"; +import { + EmailKit, + escMd, + lookOf, + mdLink, + type RenderedEmail, +} from "./layout.ts"; /** * Returns the reset email of `username`, whose button opens `resetUrl`. The @@ -19,49 +25,60 @@ export function renderPasswordResetEmail( resetUrl: string, hours: number, ): RenderedEmail { - const t = STRINGS[EMAIL_LOCALE].reset; - const kit = new EmailKit( - { style: DEFAULT_STYLE, scheme: "auto" }, - EMAIL_LOCALE, - ); - const subject = t.subject(OG_SITE_NAME); - const urlBox = `${esc(resetUrl)}`; - const body = kit.para(esc(t.hi(username))) + - kit.para(esc(t.body(OG_SITE_NAME, hours))) + - kit.button(esc(t.button), resetUrl) + - kit.note(esc(t.fallback), "margin-bottom:8px;") + - urlBox + - kit.note(esc(t.ignore)); - - return { - subject, - html: kit.document({ - subject, - preheader: t.preheader(hours), - title: esc(t.title), - body, - footer: kit.footer(esc(t.reason)), - }), - text: [ - t.title, - "", - t.hi(username), - "", - t.body(OG_SITE_NAME, hours), - "", + const markdown = [ + `# ${ + escMd(i18n._({ id: "email.reset.title", message: "Reset your password" })) + }`, + escMd(i18n._({ + id: "email.reset.hi", + message: "Hi {username},", + values: { username }, + })), + escMd(i18n._({ + id: "email.reset.body", + message: + "Someone asked to reset the password of your {site} account. The button below lets you choose a new one. It works once, for {hours, plural, one {# hour} other {# hours}}.", + values: { site: OG_SITE_NAME, hours }, + })), + mdLink( + i18n._({ id: "email.reset.button", message: "Reset password" }), resetUrl, - "", - t.ignore, - "", - "--", - t.reason, - ].join("\n"), - }; + ), + `*${ + escMd(i18n._({ + id: "email.reset.fallback", + message: + "If the button doesn't work, paste this link into your browser:", + })) + }*`, + "```\n" + resetUrl + "\n```", + `*${ + escMd(i18n._({ + id: "email.reset.ignore", + message: + "If you didn't ask for this, ignore this email. Your password stays the same.", + })) + }*`, + "---", + escMd(i18n._({ + id: "email.reset.reason", + message: + "You're getting this email because a password reset was requested for this address.", + })), + ].join("\n\n"); + + return new EmailKit(lookOf()).render({ + subject: i18n._({ + id: "email.reset.subject", + message: "Reset your {site} password", + values: { site: OG_SITE_NAME }, + }), + preheader: i18n._({ + id: "email.reset.preheader", + message: + "The link works once, for {hours, plural, one {# hour} other {# hours}}.", + values: { hours }, + }), + markdown, + }); } diff --git a/api/mail/strings.ts b/api/mail/strings.ts deleted file mode 100644 index 263479d9..00000000 --- a/api/mail/strings.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * The words of every email, in each language an instance can pick with - * `GERBEUR_EMAIL_LOCALE`. The templates beside this module read - * `STRINGS[EMAIL_LOCALE]`. French follows the interface's own words (a dump is - * a "reco", a playlist a "collection") and puts a no-break space before - * `: ! ?`. - * - * @module - */ - -import type { EmailLocale } from "../config.ts"; - -/** A no-break space, which French typography puts before `: ! ?`. */ -const NB = " "; - -/** - * Read by the templates for one language. A function takes what varies, the - * site's name first; none escapes what it is given. - */ -export interface MailStrings { - /** The footer's link to the settings. */ - settings: string; - welcome: { - subject: (site: string) => string; - preheader: string; - /** The Markdown body, unless `GERBEUR_WELCOME_EMAIL_BODY` replaces it. */ - body: string; - button: (site: string) => string; - reason: (site: string) => string; - }; - reset: { - subject: (site: string) => string; - preheader: (hours: number) => string; - title: string; - hi: (username: string) => string; - body: (site: string, hours: number) => string; - button: string; - fallback: string; - ignore: string; - reason: string; - }; -} - -/** Every email's words, by language. */ -export const STRINGS: Record = { - en: { - settings: "Settings", - welcome: { - subject: (site) => `Welcome to ${site}`, - preheader: "Your account is ready.", - body: - "# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!", - button: (site) => `Open ${site}`, - reason: (site) => - `You're getting this email because an account was created on ${site} with this address.`, - }, - reset: { - subject: (site) => `Reset your ${site} password`, - preheader: (hours) => - `The link works once, for ${hours} ${hours === 1 ? "hour" : "hours"}.`, - title: "Reset your password", - hi: (username) => `Hi ${username},`, - body: (site, hours) => - `Someone asked to reset the password of your ${site} account. The button below lets you choose a new one. It works once, for ${hours} ${ - hours === 1 ? "hour" : "hours" - }.`, - button: "Reset password", - fallback: - "If the button doesn't work, paste this link into your browser:", - ignore: - "If you didn't ask for this, ignore this email. Your password stays the same.", - reason: - "You're getting this email because a password reset was requested for this address.", - }, - }, - fr: { - settings: "Paramètres", - welcome: { - subject: (site) => `Bienvenue sur ${site}`, - preheader: "Votre compte est prêt.", - body: - `# Bienvenue sur {{site_name}}${NB}!\n\nBonjour **{{username}}**,\n\nVotre compte a bien été créé. Bienvenue à bord${NB}!`, - button: (site) => `Ouvrir ${site}`, - reason: (site) => - `Vous recevez cet e-mail car un compte ${site} a été créé avec cette adresse.`, - }, - reset: { - subject: (site) => `Réinitialisez votre mot de passe ${site}`, - preheader: (hours) => - `Le lien ne fonctionne qu'une fois, pendant ${hours}${NB}${ - hours < 2 ? "heure" : "heures" - }.`, - title: "Réinitialiser votre mot de passe", - hi: (username) => `Bonjour ${username},`, - body: (site, hours) => - `Quelqu'un a demandé à réinitialiser le mot de passe de votre compte ${site}. Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant ${hours}${NB}${ - hours < 2 ? "heure" : "heures" - }.`, - button: "Choisir un nouveau mot de passe", - fallback: - `Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur${NB}:`, - ignore: - "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas.", - reason: - "Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse.", - }, - }, -}; diff --git a/api/mail/styles.ts b/api/mail/styles.ts index f5770f80..6bb5efe7 100644 --- a/api/mail/styles.ts +++ b/api/mail/styles.ts @@ -2,8 +2,9 @@ * Carries each site style into email, so a digest looks like the site its * reader chose and the other emails like the instance's style. A mail client * takes no stylesheet of ours, so each style is spelled out here: its palettes, - * copied from its theme under `src/themes/`, and the few touches that make it - * recognisable once flattened into inline styles. `layout.ts` reads them. + * copied from its theme under `src/themes/` and flattened to plain colors, and + * the few touches that make it recognisable once inlined. `layout.ts` reads + * them. A test fails when a copied color no longer matches its theme's. * * @module */ @@ -227,10 +228,10 @@ export const EMAIL_STYLES: Record = { outer: "#ffffff", card: "#ffffff", text: "#121212", - sec: "#363636", + sec: "#5a5a5a", muted: "#767676", accent: "#121212", - link: "#121212", + link: "#1a1a1a", onAccent: "#ffffff", rule: "#e2e2e2", soft: "#f3f3f3", @@ -240,7 +241,7 @@ export const EMAIL_STYLES: Record = { outer: "#121212", card: "#1e1e1e", text: "#e6e6e6", - sec: "#c4c4c4", + sec: "#ababab", muted: "#8a8a8a", accent: "#ececec", link: "#ececec", diff --git a/api/mail/welcome.ts b/api/mail/welcome.ts index 210067ba..573ea613 100644 --- a/api/mail/welcome.ts +++ b/api/mail/welcome.ts @@ -1,78 +1,76 @@ /** * Renders the email that greets a new account. `sendWelcomeEmail` * (`user-service.ts`) sends it after sign-up, in the instance's style and - * language. Its body is Markdown: the instance's `GERBEUR_WELCOME_EMAIL_BODY`, - * or the built-in one. + * language. It opens with the instance's `GERBEUR_WELCOME_EMAIL_BODY`, or the + * built-in body, then says the digests are on, since they are by default, and + * where to turn them off. * * @module */ -import { Marked } from "marked"; - +import { FRONTEND_URL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts"; +import { i18n } from "./i18n.ts"; import { - DEFAULT_STYLE, - EMAIL_LOCALE, - FRONTEND_URL, - OG_SITE_NAME, - WELCOME_EMAIL_BODY, -} from "../config.ts"; -import { EmailKit, esc, type RenderedEmail } from "./layout.ts"; -import { STRINGS } from "./strings.ts"; + EmailKit, + escMd, + lookOf, + mdLink, + type RenderedEmail, +} from "./layout.ts"; /** - * Returns a Markdown renderer whose blocks are `kit`'s, so the body takes the - * email's style. Raw HTML in the Markdown passes through: the body comes from - * whoever runs the instance, never from a user. - */ -function markdownIn(kit: EmailKit): Marked { - return new Marked({ - renderer: { - heading({ tokens, depth }) { - const html = this.parser.parseInline(tokens); - return depth === 1 ? kit.heading(html, 20) : kit.subhead(html); - }, - paragraph({ tokens }) { - return kit.para(this.parser.parseInline(tokens)); - }, - link({ href, tokens }) { - return kit.link(this.parser.parseInline(tokens), href); - }, - strong({ tokens }) { - return `${this.parser.parseInline(tokens)}`; - }, - }, - }); -} - -/** - * Returns the welcome email of `username`, with `{{username}}` and - * `{{site_name}}` filled into the body. + * Returns the welcome email of `username`. The instance's body has + * `{{username}}` and `{{site_name}}` filled in; raw HTML in it passes + * through, since it comes from whoever runs the instance, never from a user. */ export function renderWelcomeEmail(username: string): RenderedEmail { - const t = STRINGS[EMAIL_LOCALE].welcome; - const kit = new EmailKit( - { style: DEFAULT_STYLE, scheme: "auto" }, - EMAIL_LOCALE, - ); - const markdown = (WELCOME_EMAIL_BODY ?? t.body) - .replaceAll("{{username}}", username) - .replaceAll("{{site_name}}", OG_SITE_NAME); - const subject = t.subject(OG_SITE_NAME); - const body = markdownIn(kit).parse(markdown, { async: false }) + - kit.button(esc(t.button(OG_SITE_NAME)), FRONTEND_URL); + // A username may hold a `_`, which Markdown would read as emphasis. + const body = WELCOME_EMAIL_BODY + ? WELCOME_EMAIL_BODY + .replaceAll("{{username}}", escMd(username)) + .replaceAll("{{site_name}}", escMd(OG_SITE_NAME)) + : i18n._({ + id: "email.welcome.body", + message: + "# Welcome to {site}!\n\nHi **{username}**,\n\nYour account has been created successfully. Welcome aboard!", + values: { site: escMd(OG_SITE_NAME), username: escMd(username) }, + }); + const markdown = [ + body, + mdLink( + i18n._({ + id: "email.welcome.button", + message: "Open {site}", + values: { site: OG_SITE_NAME }, + }), + FRONTEND_URL, + ), + `*${ + escMd(i18n._({ + id: "email.welcome.digests", + message: + "Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.", + })) + }*`, + "---", + escMd(i18n._({ + id: "email.welcome.reason", + message: + "You're getting this email because an account was created on {site} with this address.", + values: { site: OG_SITE_NAME }, + })), + ].join("\n\n"); - return { - subject, - html: kit.document({ - subject, - preheader: t.preheader, - body, - footer: kit.footer(esc(t.reason(OG_SITE_NAME))), + return new EmailKit(lookOf()).render({ + subject: i18n._({ + id: "email.welcome.subject", + message: "Welcome to {site}", + values: { site: OG_SITE_NAME }, }), - text: `${markdown}\n\n${t.button(OG_SITE_NAME)}: ${FRONTEND_URL}\n\n--\n${ - t.reason(OG_SITE_NAME) - }`, - }; + preheader: i18n._({ + id: "email.welcome.preheader", + message: "Your account is ready.", + }), + markdown, + }); } diff --git a/api/main.ts b/api/main.ts index e00a6426..d02c082f 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,6 +1,6 @@ /** - * Builds the Oak application and, when run directly, listens on - * {@link LISTEN_HOST}:{@link PORT}. A request goes through the error + * Builds the Oak application and, when run directly, starts the digests' + * scheduler and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes through the error * middleware, the OG tags and CORS, then the routers, and last the built * frontend, which answers every path no router did. * @@ -33,6 +33,7 @@ import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; import { ogMiddleware } from "./middleware/og.ts"; import { routeStaticFilesFrom } from "./lib/static.ts"; +import { startDigestScheduler } from "./services/digest-service.ts"; /** * The whole server, exported so the tests' helpers can send it requests @@ -142,6 +143,7 @@ app.addEventListener( ); if (import.meta.main) { + startDigestScheduler(); await app.listen({ hostname: LISTEN_HOST, port: PORT }); } diff --git a/api/model/settings.ts b/api/model/settings.ts index dc92a34e..933f60fb 100644 --- a/api/model/settings.ts +++ b/api/model/settings.ts @@ -26,6 +26,17 @@ export interface UserSettingsRow { [key: string]: SQLOutputValue; } +/** + * Names the column of each setting, for the queries that pick users by one, + * as `digest-service.ts` does by its digest's switch. + */ +export const SETTINGS_COLUMNS: Record = { + style: "style", + colorScheme: "color_scheme", + digestWeekly: "digest_weekly", + digestMonthly: "digest_monthly", +}; + /** * Checks whether `obj`, a row `getSettings` selected, is a * {@link UserSettingsRow}. diff --git a/api/routes/users.ts b/api/routes/users.ts index 332c80f0..75b4c060 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -22,6 +22,10 @@ * - `GET /api/users/me/settings` — signed in. Answers the caller's settings. * - `PATCH /api/users/me/settings` — signed in. Changes some of the caller's * settings, and answers them all. + * - `POST /api/users/unsubscribe?token=` — public, with a digest's unsubscribe + * token. Turns that digest off and answers its cadence. Mail clients post + * here for a one-click unsubscribe (RFC 8058), and so does the client's + * `/unsubscribe` page. * - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another * user's role. * - `GET /api/users/search?q=` — public. Answers up to 8 users whose name @@ -69,7 +73,11 @@ import { import type { Playlist } from "../../shared/playlist.ts"; import { toPublicUser, toSelfUser, type User } from "../model/user.ts"; -import { createJWT, createMediaToken } from "../lib/jwt.ts"; +import { + createJWT, + createMediaToken, + verifyUnsubscribeToken, +} from "../lib/jwt.ts"; import { hashPassword, verifyPassword } from "../lib/password.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; import { @@ -94,7 +102,11 @@ import { resetPassword, } from "../services/password-reset-service.ts"; import { VALIDATION } from "../../shared/validation.ts"; -import type { UserSettings } from "../../shared/settings.ts"; +import { + DIGEST_SETTING, + type DigestCadence, + type UserSettings, +} from "../../shared/settings.ts"; import { isUpdateSettingsRequest } from "../validators/settings.ts"; import { getSettings, updateSettings } from "../services/settings-service.ts"; import { broadcastUserUpdated } from "../services/ws-service.ts"; @@ -327,6 +339,22 @@ router.patch("/me/settings", authMiddleware, async (ctx: AuthContext) => { ctx.response.body = responseBody; }); +// The body is ignored: a mail client's one-click post sends a form saying +// "List-Unsubscribe=One-Click", the client's page sends nothing. +router.post("/unsubscribe", async (ctx) => { + const token = ctx.request.url.searchParams.get("token") ?? ""; + const payload = await verifyUnsubscribeToken(token); + if (!payload) throw new APIException(400, "Invalid unsubscribe link"); + // Throws a 404 once the account is gone. + getUserById(payload.userId); + updateSettings(payload.userId, { [DIGEST_SETTING[payload.cadence]]: false }); + const responseBody: APIResponse<{ cadence: DigestCadence }> = { + success: true, + data: { cadence: payload.cadence }, + }; + ctx.response.body = responseBody; +}); + router.patch( "/:userId/role", authMiddleware, diff --git a/api/services/digest-service.ts b/api/services/digest-service.ts new file mode 100644 index 00000000..2b2aefad --- /dev/null +++ b/api/services/digest-service.ts @@ -0,0 +1,602 @@ +/** + * Sends each user their weekly digest and monthly recap, unless they turned + * it off. `main.ts` starts {@link startDigestScheduler}, which every quarter + * of an hour sends the digests that are due; `digest_schedule` remembers, for + * each user and cadence, the start of the period their next one covers. + * + * A period is 7 days or 30, rolling from when the user's schedule began, so + * the digests spread over the week instead of leaving together. A digest + * covers the period just ended, or the last full period when the server was + * down longer than one; a digest with nothing to show isn't sent, and either + * way the next period begins. + * + * @module + */ + +import { db, transaction } from "../db/connection.ts"; +import { FRONTEND_URL, PUBLIC_URL } from "../config.ts"; +import { + DEFAULT_SETTINGS, + DIGEST_CADENCES, + DIGEST_SETTING, + type DigestCadence, +} from "../../shared/settings.ts"; +import { SETTINGS_COLUMNS } from "../model/settings.ts"; +import type { NotificationType } from "../../shared/notification.ts"; +import { + type ActivityKind, + type DigestContent, + type DigestDump, + type DigestHighlight, + renderDigestEmail, +} from "../mail/digest.ts"; +import { lookOf } from "../mail/layout.ts"; +import { createUnsubscribeToken } from "../lib/jwt.ts"; +import { isEmailEnabled, sendEmail } from "./email-service.ts"; +import { getSettings } from "./settings-service.ts"; +import { dumpFileVersion } from "./dump-service.ts"; + +const DAY_MS = 86_400_000; +/** How long each cadence's period lasts. */ +export const PERIOD_MS: Record = { + weekly: 7 * DAY_MS, + monthly: 30 * DAY_MS, +}; +const TICK_MS = 15 * 60_000; + +// What one digest shows at most, as the drafts settled it. +const HIGHLIGHTS_MAX = 5; +const NETWORK_MAX = 6; +const TOP_MAX: Record = { weekly: 5, monthly: 10 }; +const MEMBERS_MAX = 10; +const PLAYLISTS_MAX = 5; +const QUOTE_MAX = 140; + +const COUNTED: Partial> = { + dump_upvoted: "upvotes", + user_followed: "followers", + dump_commented: "comments", + user_mentioned: "mentions", + comment_liked: "likes", + playlist_followed: "playlistFollows", +}; + +/** A period as the ISO strings dates are stored in, for comparing as text. */ +interface Window { + from: string; + to: string; +} + +const dumpUrl = (row: { id: string; slug: string | null }) => + `${FRONTEND_URL}/dumps/${encodeURIComponent(row.slug ?? row.id)}`; +const playlistUrl = (row: { id: string; slug: string | null }) => + `${FRONTEND_URL}/playlists/${encodeURIComponent(row.slug ?? row.id)}`; + +/** Flattens Markdown into a short line of text for a quote. */ +function quoteOf(markdown: string | undefined | null): string | undefined { + if (!markdown) return undefined; + const flat = markdown + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[`*_>#~]/g, "") + .replace(/\s+/g, " ") + .trim(); + if (!flat) return undefined; + return flat.length > QUOTE_MAX + ? `${flat.slice(0, QUOTE_MAX - 1).trimEnd()}…` + : flat; +} + +/** The columns {@link toDigestDump} reads, selected from `dumps d`. */ +const DUMP_COLS = `d.id, d.slug, d.title, d.kind, d.url, d.user_id, + d.created_at, d.updated_at, d.file_mime, d.file_size, d.rich_content, + d.custom_thumbnail_mime, d.vote_count, u.username AS poster, + (SELECT COUNT(*) FROM comments c WHERE c.dump_id = d.id AND c.deleted = 0) AS comment_count`; + +interface DumpRow { + id: string; + slug: string | null; + title: string; + kind: string; + url: string | null; + user_id: string; + created_at: string; + updated_at: string | null; + file_mime: string | null; + file_size: number | null; + rich_content: string | null; + custom_thumbnail_mime: string | null; + vote_count: number; + poster: string; + comment_count: number; +} + +/** + * Returns the image a mail client can load for `row`, which must be public: + * the thumbnails route for a custom thumbnail or an image or video upload, + * else the link preview's image, through the image proxy when it is plain + * http, which a mail client may refuse. + */ +function thumbnailOf(row: DumpRow): string | null { + const isMedia = row.kind === "file" && + /^(image|video)\//.test(row.file_mime ?? ""); + if (row.custom_thumbnail_mime || isMedia) { + const version = dumpFileVersion({ + fileSize: row.file_size ?? undefined, + createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, + }); + return `${PUBLIC_URL}/api/thumbnails/${row.id}?v=${version}`; + } + let thumbnailUrl: unknown; + try { + thumbnailUrl = row.rich_content + ? JSON.parse(row.rich_content)?.thumbnailUrl + : undefined; + } catch { + return null; + } + if (typeof thumbnailUrl !== "string") return null; + if (thumbnailUrl.startsWith("https://")) return thumbnailUrl; + if (thumbnailUrl.startsWith("http://")) { + return `${PUBLIC_URL}/api/proxy-image?url=${ + encodeURIComponent(thumbnailUrl) + }`; + } + return null; +} + +function toDigestDump(row: DumpRow, readerId: string): DigestDump { + let domain: string | null = null; + if (row.kind === "url" && row.url) { + try { + domain = new URL(row.url).hostname.replace(/^www\./, ""); + } catch { + domain = null; + } + } + return { + title: row.title, + url: dumpUrl(row), + poster: row.poster, + domain, + thumbnail: thumbnailOf(row), + votes: row.vote_count, + comments: row.comment_count, + mine: row.user_id === readerId, + }; +} + +/** + * Returns what happened to the reader: their notifications of the period, + * counted by kind, and the few worth reading, mentions and comments first. + */ +function activityOf( + readerId: string, + w: Window, +): DigestContent["activity"] { + const byType = db.prepare( + `SELECT type, COUNT(*) AS n FROM notifications + WHERE user_id = ? AND created_at >= ? AND created_at < ? + GROUP BY type;`, + ).all(readerId, w.from, w.to) as { type: NotificationType; n: number }[]; + const counts: Partial> = {}; + let total = 0; + for (const { type, n } of byType) { + total += n; + const kind = COUNTED[type]; + if (kind) counts[kind] = n; + } + + const rows = db.prepare( + `SELECT type, data, created_at FROM notifications + WHERE user_id = ? AND created_at >= ? AND created_at < ? + AND type IN ('user_mentioned', 'dump_commented', 'playlist_followed', + 'user_followed', 'comment_liked') + ORDER BY CASE type + WHEN 'user_mentioned' THEN 0 WHEN 'dump_commented' THEN 0 + WHEN 'comment_liked' THEN 2 ELSE 1 END, + created_at DESC + LIMIT ?;`, + ).all(readerId, w.from, w.to, HIGHLIGHTS_MAX) as { + type: NotificationType; + data: string; + created_at: string; + }[]; + const highlights = rows.flatMap((row) => { + try { + const h = highlightOf(row.type, JSON.parse(row.data)); + return h ? [{ ...h, at: new Date(row.created_at) }] : []; + } catch { + return []; + } + }); + + if (Object.keys(counts).length === 0 && highlights.length === 0) return null; + return { counts, highlights, total }; +} + +const commentBody = (id: string) => + (db.prepare(`SELECT body FROM comments WHERE id = ? AND deleted = 0;`) + .get(id) as { body: string } | undefined)?.body; +const dumpTarget = (id: string, fallbackTitle: string) => { + const row = db.prepare(`SELECT id, slug, title FROM dumps WHERE id = ?;`) + .get(id) as { id: string; slug: string | null; title: string } | undefined; + return row ? { title: row.title, url: dumpUrl(row) } : { + title: fallbackTitle, + url: `${FRONTEND_URL}/dumps/${encodeURIComponent(id)}`, + }; +}; +const playlistTarget = (id: string, fallbackTitle: string) => { + const row = db.prepare(`SELECT id, slug, title FROM playlists WHERE id = ?;`) + .get(id) as { id: string; slug: string | null; title: string } | undefined; + return row ? { title: row.title, url: playlistUrl(row) } : { + title: fallbackTitle, + url: `${FRONTEND_URL}/playlists/${encodeURIComponent(id)}`, + }; +}; + +/** Returns the highlight a notification makes, or null for another kind. */ +function highlightOf( + type: NotificationType, + // deno-lint-ignore no-explicit-any + data: any, +): Omit | null { + switch (type) { + case "user_mentioned": + switch (data.contextType) { + case "comment": + return { + kind: "mentionComment", + who: data.mentionerUsername, + target: dumpTarget(data.dumpId, data.contextTitle), + quote: quoteOf(commentBody(data.contextId)), + }; + case "dump": + return { + kind: "mentionDump", + who: data.mentionerUsername, + target: dumpTarget(data.contextId, data.contextTitle), + }; + case "playlist": + return { + kind: "mentionPlaylist", + who: data.mentionerUsername, + target: playlistTarget(data.contextId, data.contextTitle), + }; + case "chat": + return { + kind: "mentionChat", + who: data.mentionerUsername, + quote: quoteOf( + (db.prepare(`SELECT body FROM chat_messages WHERE id = ?;`) + .get(data.contextId) as { body: string } | undefined)?.body, + ), + }; + } + return null; + case "dump_commented": + return { + kind: "comment", + who: data.commenterUsername, + target: dumpTarget(data.dumpId, data.dumpTitle), + quote: quoteOf(commentBody(data.commentId)), + }; + case "playlist_followed": + return { + kind: "playlistFollow", + who: data.followerUsername, + target: playlistTarget(data.playlistId, data.playlistTitle), + }; + case "user_followed": + return { kind: "userFollow", who: data.followerUsername }; + case "comment_liked": + return { + kind: "commentLike", + who: data.likerUsername, + target: dumpTarget(data.dumpId, data.dumpTitle), + quote: quoteOf(commentBody(data.commentId)), + }; + } + return null; +} + +/** + * Returns the public dumps of the period from the users and playlists the + * reader follows, the most upvoted first, without their own. + */ +function networkOf(readerId: string, w: Window): DigestContent["network"] { + const rows = db.prepare( + `SELECT ${DUMP_COLS}, NULL AS playlist_title, d.created_at AS at + FROM dumps d JOIN users u ON u.id = d.user_id + WHERE d.is_private = 0 AND d.user_id != :reader + AND d.created_at >= :from AND d.created_at < :to + AND d.user_id IN (SELECT followed_user_id FROM follows + WHERE follower_id = :reader AND followed_user_id IS NOT NULL) + UNION ALL + SELECT ${DUMP_COLS}, p.title AS playlist_title, pd.added_at AS at + FROM playlist_dumps pd + JOIN playlists p ON p.id = pd.playlist_id + JOIN dumps d ON d.id = pd.dump_id + JOIN users u ON u.id = d.user_id + WHERE p.is_public = 1 AND d.is_private = 0 AND d.user_id != :reader + AND pd.added_at >= :from AND pd.added_at < :to + AND pd.playlist_id IN (SELECT followed_playlist_id FROM follows + WHERE follower_id = :reader AND followed_playlist_id IS NOT NULL);`, + ).all({ reader: readerId, from: w.from, to: w.to }) as unknown as (DumpRow & { + playlist_title: string | null; + at: string; + })[]; + // A dump both posted by someone followed and added to a followed playlist + // is listed once, as the post. + const byId = new Map(); + for (const row of rows) { + const seen = byId.get(row.id); + if (!seen || (seen.playlist_title && !row.playlist_title)) { + byId.set(row.id, row); + } + } + const sorted = [...byId.values()].sort((a, b) => + b.vote_count - a.vote_count || b.at.localeCompare(a.at) + ); + if (sorted.length === 0) return null; + return { + dumps: sorted.slice(0, NETWORK_MAX).map((row) => ({ + ...toDigestDump(row, readerId), + ...(row.playlist_title ? { addedTo: row.playlist_title } : {}), + })), + more: Math.max(0, sorted.length - NETWORK_MAX), + }; +} + +/** + * Returns the period's most upvoted public dumps among those posted in it, + * the newer first on a tie, as the "hot" tab would. + */ +function topOf( + readerId: string, + cadence: DigestCadence, + w: Window, +): DigestDump[] { + const rows = db.prepare( + `SELECT ${DUMP_COLS} FROM dumps d JOIN users u ON u.id = d.user_id + WHERE d.is_private = 0 AND d.vote_count > 0 + AND d.created_at >= ? AND d.created_at < ? + ORDER BY d.vote_count DESC, d.created_at DESC + LIMIT ?;`, + ).all(w.from, w.to, TOP_MAX[cadence]) as unknown as DumpRow[]; + return rows.map((row) => toDigestDump(row, readerId)); +} + +const countOf = (sql: string, ...params: string[]) => + (db.prepare(sql).get(...params) as { n: number }).n; + +/** + * Returns what the whole site did in the period: who joined, the new public + * playlists, the chat's activity and the totals. + */ +function communityOf(readerId: string, w: Window): DigestContent["community"] { + const members = db.prepare( + `SELECT u.username, i.username AS invited_by + FROM users u LEFT JOIN users i ON i.id = u.invited_by + WHERE u.created_at >= ? AND u.created_at < ? AND u.id != ? + ORDER BY u.created_at;`, + ).all(w.from, w.to, readerId) as { + username: string; + invited_by: string | null; + }[]; + const playlists = db.prepare( + `SELECT p.id, p.slug, p.title, u.username AS owner, + (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) AS dumps + FROM playlists p JOIN users u ON u.id = p.user_id + WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ? + ORDER BY dumps DESC, p.created_at DESC;`, + ).all(w.from, w.to) as { + id: string; + slug: string | null; + title: string; + owner: string; + dumps: number; + }[]; + const chatMessages = countOf( + `SELECT COUNT(*) AS n FROM chat_messages WHERE created_at >= ? AND created_at < ?;`, + w.from, + w.to, + ); + const totals = { + dumps: countOf( + `SELECT COUNT(*) AS n FROM dumps WHERE is_private = 0 AND created_at >= ? AND created_at < ?;`, + w.from, + w.to, + ), + upvotes: countOf( + `SELECT COUNT(*) AS n FROM votes WHERE created_at >= ? AND created_at < ?;`, + w.from, + w.to, + ), + comments: countOf( + `SELECT COUNT(*) AS n FROM comments WHERE deleted = 0 AND created_at >= ? AND created_at < ?;`, + w.from, + w.to, + ), + }; + if ( + members.length + playlists.length + chatMessages + totals.dumps + + totals.upvotes + totals.comments === 0 + ) return null; + return { + members: members.slice(0, MEMBERS_MAX).map((m) => ({ + username: m.username, + invitedBy: m.invited_by, + })), + membersMore: Math.max(0, members.length - MEMBERS_MAX), + playlists: playlists.slice(0, PLAYLISTS_MAX).map((p) => ({ + title: p.title, + url: playlistUrl(p), + owner: p.owner, + dumps: p.dumps, + })), + playlistsMore: Math.max(0, playlists.length - PLAYLISTS_MAX), + chatMessages, + totals, + }; +} + +/** + * Returns the digest of user `readerId` for the period from `from` to `to`, + * or null when it would show nothing. The links are absolute; the + * unsubscribe one carries `token`, which turns off `cadence`. + */ +export function buildDigest( + readerId: string, + reader: string, + cadence: DigestCadence, + from: Date, + to: Date, + token: string, +): DigestContent | null { + const w = { from: from.toISOString(), to: to.toISOString() }; + const weekly = cadence === "weekly"; + const activity = weekly ? activityOf(readerId, w) : null; + const network = weekly ? networkOf(readerId, w) : null; + const top = topOf(readerId, cadence, w); + const community = communityOf(readerId, w); + if (!activity && !network && top.length === 0 && !community) return null; + return { + cadence, + from, + to, + reader, + activity, + network, + top, + community, + links: { + notifications: `${FRONTEND_URL}/notifications`, + followed: `${FRONTEND_URL}/~/followed`, + chat: `${FRONTEND_URL}/?chat=open`, + settings: `${FRONTEND_URL}/users/${ + encodeURIComponent(reader) + }/~/settings`, + unsubscribe: `${FRONTEND_URL}/unsubscribe?token=${ + encodeURIComponent(token) + }`, + }, + }; +} + +/** + * Brings `digest_schedule` in line with the settings: a user who turned a + * cadence off loses its row, and one who receives it without a row, being + * new or having turned it back on, starts a period at `now`. + */ +export function syncDigestSchedules(now: Date): void { + transaction(() => { + for (const cadence of DIGEST_CADENCES) { + const setting = DIGEST_SETTING[cadence]; + const column = SETTINGS_COLUMNS[setting]; + const byDefault = DEFAULT_SETTINGS[setting] ? 1 : 0; + db.prepare( + `DELETE FROM digest_schedule WHERE cadence = ? AND user_id IN + (SELECT user_id FROM user_settings WHERE ${column} = 0);`, + ).run(cadence); + db.prepare( + `INSERT INTO digest_schedule (user_id, cadence, window_start) + SELECT u.id, ?, ? FROM users u + LEFT JOIN user_settings s ON s.user_id = u.id + WHERE COALESCE(s.${column}, ${byDefault}) = 1 + AND NOT EXISTS (SELECT 1 FROM digest_schedule ds + WHERE ds.user_id = u.id AND ds.cadence = ?);`, + ).run(cadence, now.toISOString(), cadence); + } + }); +} + +/** + * Sends every digest due at `now`, one after the other, and returns how many + * went out. A digest that fails to send is logged and not retried: its + * period is over either way, and the next one starts. + */ +export async function sendDueDigests(now: Date = new Date()): Promise { + if (!isEmailEnabled()) return 0; + syncDigestSchedules(now); + let sent = 0; + for (const cadence of DIGEST_CADENCES) { + const period = PERIOD_MS[cadence]; + const due = db.prepare( + `SELECT ds.user_id, ds.window_start, u.username, u.email + FROM digest_schedule ds JOIN users u ON u.id = ds.user_id + WHERE ds.cadence = ? AND ds.window_start <= ?;`, + ).all(cadence, new Date(now.getTime() - period).toISOString()) as { + user_id: string; + window_start: string; + username: string; + email: string; + }[]; + for (const row of due) { + let from = new Date(row.window_start); + let to = new Date(from.getTime() + period); + // More than a period late (the server was down): cover the last full + // period rather than one long gone. + if (to.getTime() + period <= now.getTime()) { + from = new Date(now.getTime() - period); + to = now; + } + db.prepare( + `UPDATE digest_schedule SET window_start = ? WHERE user_id = ? AND cadence = ?;`, + ).run(to.toISOString(), row.user_id, cadence); + // An address with no dot in its domain, such as the first admin's + // admin@localhost, can't receive mail. + if (!/@[^@]+\.[^@]+$/.test(row.email)) continue; + const token = await createUnsubscribeToken(row.user_id, cadence); + const content = buildDigest( + row.user_id, + row.username, + cadence, + from, + to, + token, + ); + if (!content) continue; + const email = renderDigestEmail( + content, + lookOf(getSettings(row.user_id)), + ); + try { + await sendEmail({ + to: row.email, + ...email, + headers: { + "List-Unsubscribe": `<${PUBLIC_URL}/api/users/unsubscribe?token=${ + encodeURIComponent(token) + }>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + }); + sent++; + } catch (err) { + console.error(`[digest] ${cadence} to ${row.username} failed:`, err); + } + } + } + return sent; +} + +/** + * Sends the due digests a minute after the server starts, then every quarter + * of an hour. A run still sending when the next is due makes that one skip. + * `main.ts` calls it when run directly, never in the tests. + */ +export function startDigestScheduler(): void { + let running = false; + const tick = () => { + if (running) return; + running = true; + sendDueDigests() + .then((n) => n > 0 && console.log(`[digest] sent ${n}`)) + .catch((err) => console.error("[digest] run failed:", err)) + .finally(() => running = false); + }; + setTimeout(tick, 60_000); + setInterval(tick, TICK_MS); +} diff --git a/api/services/email-service.ts b/api/services/email-service.ts index 4c4e6150..5b1a170e 100644 --- a/api/services/email-service.ts +++ b/api/services/email-service.ts @@ -13,10 +13,10 @@ import nodemailer from "nodemailer"; -import { FROM_EMAIL, SMTPS_URL } from "../config.ts"; +import { EMAIL_ENABLED, FROM_EMAIL, SMTPS_URL } from "../config.ts"; import type { RenderedEmail } from "../mail/layout.ts"; -if (SMTPS_URL && !FROM_EMAIL) { +if (EMAIL_ENABLED && !FROM_EMAIL) { throw new Error( "GERBEUR_FROM_EMAIL environment variable is required when GERBEUR_SMTPS_URL is set.", ); @@ -39,7 +39,7 @@ export interface EmailTransport { sendMail(message: OutgoingEmail & { from: string }): Promise; } -let transport: EmailTransport | null = SMTPS_URL +let transport: EmailTransport | null = EMAIL_ENABLED ? nodemailer.createTransport(SMTPS_URL) : null; diff --git a/api/test/db/migrate.test.ts b/api/test/db/migrate.test.ts index 838e860e..280049b8 100644 --- a/api/test/db/migrate.test.ts +++ b/api/test/db/migrate.test.ts @@ -28,14 +28,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => .get() as { n: number; }; - assertEquals(applied.n, 19); + assertEquals(applied.n, 20); // Running again applies nothing. runMigrations(db); assertEquals( (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { n: number; }).n, - 19, + 20, ); db.close(); }); diff --git a/api/test/db/migrations/0020_digest_schedule.test.ts b/api/test/db/migrations/0020_digest_schedule.test.ts new file mode 100644 index 00000000..28b3b6c6 --- /dev/null +++ b/api/test/db/migrations/0020_digest_schedule.test.ts @@ -0,0 +1,35 @@ +import "../../setup.ts"; +import { assert, assertEquals } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { up as up0020 } from "../../../db/migrations/0020_digest_schedule.ts"; + +Deno.test("migration 0020 spreads existing users' first digests over the coming period", () => { + const db = new DatabaseSync(":memory:"); + db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`); + const ids = Array.from({ length: 40 }, () => crypto.randomUUID()); + for (const id of ids) { + db.prepare(`INSERT INTO users (id) VALUES (?);`).run(id); + } + const before = Date.now(); + up0020(db); + up0020(db); // idempotent + const rows = db.prepare( + `SELECT user_id, cadence, window_start FROM digest_schedule;`, + ).all() as { user_id: string; cadence: string; window_start: string }[]; + assertEquals(rows.length, 80); + const due = (cadence: string, days: number) => + rows.filter((r) => r.cadence === cadence).map((r) => + new Date(r.window_start).getTime() + days * 86_400_000 + ); + for (const [cadence, days] of [["weekly", 7], ["monthly", 30]] as const) { + const dues = due(cadence, days); + // Each first digest falls due within the coming period, not all at once. + assert( + dues.every((t) => + t >= before - 1000 && t <= Date.now() + days * 86_400_000 + ), + ); + assert(new Set(dues.map((t) => Math.floor(t / 3_600_000))).size > 10); + } + db.close(); +}); diff --git a/api/test/helpers.ts b/api/test/helpers.ts index 23d3453f..0fb82085 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -35,6 +35,7 @@ const TABLES = [ "attachments", "chat_messages", "user_settings", + "digest_schedule", "categories", "playlists", "dumps", diff --git a/api/test/lib/jwt.test.ts b/api/test/lib/jwt.test.ts index c729c36e..fe27ba6b 100644 --- a/api/test/lib/jwt.test.ts +++ b/api/test/lib/jwt.test.ts @@ -5,9 +5,11 @@ import { createJWT, createMediaToken, createPasswordResetToken, + createUnsubscribeToken, verifyJWT, verifyMediaToken, verifyPasswordResetToken, + verifyUnsubscribeToken, } from "../../lib/jwt.ts"; const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret"); @@ -60,3 +62,24 @@ Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async assertEquals(await verifyJWT("not.a.jwt"), null); }); + +Deno.test("an unsubscribe token names its user and cadence, and is nothing else", async () => { + const userId = crypto.randomUUID(); + const token = await createUnsubscribeToken(userId, "monthly"); + assertEquals(await verifyUnsubscribeToken(token), { + userId, + cadence: "monthly", + }); + assertEquals(await verifyJWT(token), null); + assertEquals(await verifyMediaToken(token), null); + assertEquals( + await verifyUnsubscribeToken(await createMediaToken(userId)), + null, + ); + const yearly = await new SignJWT({ + purpose: "unsubscribe", + userId, + cadence: "yearly", + }).setProtectedHeader({ alg: "HS256" }).sign(KEY); + assertEquals(await verifyUnsubscribeToken(yearly), null); +}); diff --git a/api/test/mail/digest.test.ts b/api/test/mail/digest.test.ts new file mode 100644 index 00000000..3068e0f7 --- /dev/null +++ b/api/test/mail/digest.test.ts @@ -0,0 +1,104 @@ +import "../setup.ts"; +import { assert, assertFalse, assertStringIncludes } from "@std/assert"; +import { type DigestContent, renderDigestEmail } from "../../mail/digest.ts"; + +const links = { + notifications: "https://g.test/notifications", + followed: "https://g.test/~/followed", + chat: "https://g.test/?chat=open", + settings: "https://g.test/users/ann/~/settings", + unsubscribe: "https://g.test/unsubscribe?token=t", +}; +const dump = (title: string, over = {}) => ({ + title, + url: "https://g.test/dumps/x", + poster: "bob", + domain: "example.com", + thumbnail: "https://g.test/api/thumbnails/x?v=1", + votes: 1, + comments: 0, + mine: false, + ...over, +}); + +Deno.test("renderDigestEmail: the weekly digest escapes what users wrote and links to unsubscribe", () => { + const content: DigestContent = { + cadence: "weekly", + from: new Date("2026-09-27T08:00:00Z"), + to: new Date("2026-10-04T08:00:00Z"), + reader: "ann", + activity: { + counts: { upvotes: 12, followers: 1 }, + highlights: [{ + kind: "comment", + who: "bob", + target: { title: "", url: "https://g.test/dumps/y" }, + quote: "a bold claim", + at: new Date("2026-09-30T10:00:00Z"), + }], + total: 13, + }, + network: { dumps: [dump("Net & work")], more: 2 }, + top: [dump("Mine", { mine: true, votes: 4 })], + community: null, + links, + }; + const email = renderDigestEmail(content, { style: "smooth", scheme: "auto" }); + assertStringIncludes(email.subject, "Your week on gerbeur, Sep 27"); + assertFalse(email.html.includes("