import type { Database } from "bun:sqlite"; import { queueEmail } from "../server/db.ts"; import { followToken, followTokenMatches } from "./spam.ts"; import type { Issue } from "./store.ts"; // Enough to catch a typo or a stray name; the confirmation email is the real check. const EMAIL = /^[^\s@<>(),;:"]+@[^\s@<>(),;:"]+\.[^\s@<>(),;:"]+$/; export function isEmail(address: string): boolean { return address.length <= 254 && EMAIL.test(address); } /** * Asks an address to follow an issue (§23). A new one is sent one confirmation email and nothing * more until it is confirmed; one already asked is sent nothing. True when a confirmation went out. */ export function follow( kepr: Database, site: string, repo: string, issue: Issue, email: string, ipHash: string, now: number, ): boolean { const address = email.toLowerCase(); const row = kepr .query<{ id: number }, [number, string, string, number]>( `INSERT INTO issue_followers (issue_id, email, ip_hash, created_at) VALUES (?, ?, ?, ?) ON CONFLICT (issue_id, email) DO NOTHING RETURNING id`, ) .get(issue.id, address, ipHash, now); if (!row) return false; // Only a request that mails something counts, and it is logged here rather than in the follower // rows, so stopping the emails cannot free the hour's allowance (§23). recordFollowRequest(kepr, ipHash, now - 3600, now); const token = followToken(kepr, row.id, address); const base = `/${repo}/issues/${issue.number}`; queueEmail(kepr, { to: address, subject: `Confirm email updates for ${repo} #${issue.number}`, href: base, text: `Someone asked for an email whenever "${issue.title}" (${repo} #${issue.number}) gets a reply or is closed. To start them, open: ${site}${base}/follow?f=${row.id}&t=${token} If that wasn't you, ignore this email and nothing more will come. `, }); return true; } /** The address holding a follower link, or null when the id holds no follower of that issue. */ function followerEmail(kepr: Database, issueId: number, id: number): string | null { const row = kepr .query<{ email: string }, [number, number]>( "SELECT email FROM issue_followers WHERE id = ? AND issue_id = ?", ) .get(id, issueId); return row?.email ?? null; } /** * True when a link's token is the one this follower of this issue was sent (§23). Read through the * row, so a spent link fails once its row is gone rather than reaching whatever holds its id now. */ export function followerLinkMatches( kepr: Database, issueId: number, id: number, token: string, ): boolean { const email = followerEmail(kepr, issueId, id); return email !== null && followTokenMatches(kepr, id, email, token); } /** Confirms one follower of an issue when the link's token is theirs. */ export function confirmFollower(kepr: Database, issueId: number, id: number, token: string) { if (!followerLinkMatches(kepr, issueId, id, token)) return false; const changed = kepr .query("UPDATE issue_followers SET confirmed = 1 WHERE id = ? AND issue_id = ?") .run(id, issueId).changes; return changed === 1; } /** Stops one follower's emails about an issue when the link's token is theirs. */ export function unfollow(kepr: Database, issueId: number, id: number, token: string): boolean { if (!followerLinkMatches(kepr, issueId, id, token)) return false; const changed = kepr .query("DELETE FROM issue_followers WHERE id = ? AND issue_id = ?") .run(id, issueId).changes; return changed === 1; } /** * Follow requests from one hashed IP since a time; they count toward the reply limit (§23). Read * from the request log, not the follower rows, since stopping one's emails deletes its row. */ export function followsSince(kepr: Database, ipHash: string, since: number): number { const row = kepr .query<{ n: number }, [string, number]>( "SELECT COUNT(*) AS n FROM issue_follow_requests WHERE ip_hash = ? AND created_at > ?", ) .get(ipHash, since); return row?.n ?? 0; } /** Records one accepted follow request, and drops the ones too old to count. */ export function recordFollowRequest( kepr: Database, ipHash: string, since: number, now: number, ): void { kepr .query("DELETE FROM issue_follow_requests WHERE created_at <= ? AND ip_hash = ?") .run(since, ipHash); kepr .query("INSERT INTO issue_follow_requests (ip_hash, created_at) VALUES (?, ?)") .run(ipHash, now); } export function followerCount(kepr: Database, issueId: number): number { const row = kepr .query<{ n: number }, [number]>( "SELECT COUNT(*) AS n FROM issue_followers WHERE issue_id = ? AND confirmed = 1", ) .get(issueId); return row?.n ?? 0; } /** * Emails an issue's confirmed followers (§23), except `except`, the address that caused the news. * Each email ends with, and carries as its unsubscribe header, that follower's own stop link. */ export function tellFollowers( kepr: Database, site: string, repo: string, issue: Pick, news: { text: string; href: string }, except = "", ): void { const followers = kepr .query<{ id: number; email: string }, [number, string]>( "SELECT id, email FROM issue_followers WHERE issue_id = ? AND confirmed = 1 AND email != ?", ) .all(issue.id, except.toLowerCase()); kepr.transaction(() => { for (const follower of followers) { const token = followToken(kepr, follower.id, follower.email); const stop = `/${repo}/issues/${issue.number}/unfollow?f=${follower.id}&t=${token}`; queueEmail(kepr, { to: follower.email, subject: `Re: ${issue.title} (${repo} #${issue.number})`, href: news.href, unsubscribe: stop, text: `${news.text} ${site}${news.href} You follow this issue by email. To stop: ${site}${stop} `, }); } })(); }