followers.ts

4.0 KB · raw

1import type { Database } from "bun:sqlite";2import { queueEmail } from "../server/db.ts";3import { followToken, followTokenMatches } from "./spam.ts";4import type { Issue } from "./store.ts";56// Enough to catch a typo or a stray name; the confirmation email is the real check.7const EMAIL = /^[^\s@<>(),;:"]+@[^\s@<>(),;:"]+\.[^\s@<>(),;:"]+$/;89export function isEmail(address: string): boolean {10	return address.length <= 254 && EMAIL.test(address);11}1213/**14 * Asks an address to follow an issue (§23). A new one is sent one confirmation email and nothing15 * more until it is confirmed; one already asked is sent nothing. True when a confirmation went out.16 */17export function follow(18	kepr: Database,19	site: string,20	repo: string,21	issue: Issue,22	email: string,23	ipHash: string,24	now: number,25): boolean {26	const row = kepr27		.query<{ id: number }, [number, string, string, number]>(28			`INSERT INTO issue_followers (issue_id, email, ip_hash, created_at)29VALUES (?, ?, ?, ?)30ON CONFLICT (issue_id, email) DO NOTHING31RETURNING id`,32		)33		.get(issue.id, email.toLowerCase(), ipHash, now);34	if (!row) return false;35	const token = followToken(kepr, row.id);36	const base = `/${repo}/issues/${issue.number}`;37	queueEmail(kepr, {38		to: email.toLowerCase(),39		subject: `Confirm email updates for ${repo} #${issue.number}`,40		href: base,41		text: `Someone asked for an email whenever "${issue.title}" (${repo} #${issue.number})42gets a reply or is closed.4344To start them, open:45${site}${base}/follow?f=${row.id}&t=${token}4647If that wasn't you, ignore this email and nothing more will come.48`,49	});50	return true;51}5253/** Confirms one follower of an issue when the link's token is theirs. */54export function confirmFollower(kepr: Database, issueId: number, id: number, token: string) {55	if (!followTokenMatches(kepr, id, token)) return false;56	const changed = kepr57		.query("UPDATE issue_followers SET confirmed = 1 WHERE id = ? AND issue_id = ?")58		.run(id, issueId).changes;59	return changed === 1;60}6162/** Stops one follower's emails about an issue when the link's token is theirs. */63export function unfollow(kepr: Database, issueId: number, id: number, token: string): boolean {64	if (!followTokenMatches(kepr, id, token)) return false;65	const changed = kepr66		.query("DELETE FROM issue_followers WHERE id = ? AND issue_id = ?")67		.run(id, issueId).changes;68	return changed === 1;69}7071/** Follow requests from one hashed IP since a time; they count toward the reply limit. */72export function followsSince(kepr: Database, ipHash: string, since: number): number {73	const row = kepr74		.query<{ n: number }, [string, number]>(75			"SELECT COUNT(*) AS n FROM issue_followers WHERE ip_hash = ? AND created_at > ?",76		)77		.get(ipHash, since);78	return row?.n ?? 0;79}8081export function followerCount(kepr: Database, issueId: number): number {82	const row = kepr83		.query<{ n: number }, [number]>(84			"SELECT COUNT(*) AS n FROM issue_followers WHERE issue_id = ? AND confirmed = 1",85		)86		.get(issueId);87	return row?.n ?? 0;88}8990/**91 * Emails an issue's confirmed followers (§23), except `except`, the address that caused the news.92 * Each email ends with, and carries as its unsubscribe header, that follower's own stop link.93 */94export function tellFollowers(95	kepr: Database,96	site: string,97	repo: string,98	issue: Pick<Issue, "id" | "number" | "title">,99	news: { text: string; href: string },100	except = "",101): void {102	const followers = kepr103		.query<{ id: number; email: string }, [number, string]>(104			"SELECT id, email FROM issue_followers WHERE issue_id = ? AND confirmed = 1 AND email != ?",105		)106		.all(issue.id, except.toLowerCase());107	kepr.transaction(() => {108		for (const follower of followers) {109			const token = followToken(kepr, follower.id);110			const stop = `/${repo}/issues/${issue.number}/unfollow?f=${follower.id}&t=${token}`;111			queueEmail(kepr, {112				to: follower.email,113				subject: `Re: ${issue.title} (${repo} #${issue.number})`,114				href: news.href,115				unsubscribe: stop,116				text: `${news.text}117118${site}${news.href}119120You follow this issue by email. To stop: ${site}${stop}121`,122			});123		}124	})();125}