1import type { Database } from "bun:sqlite";2import { queueEmail } from "../server/db.ts";3import { followToken, followTokenMatches } from "./spam.ts";4import type { Issue } from "./store.ts";567const EMAIL = /^[^\s@<>(),;:"]+@[^\s@<>(),;:"]+\.[^\s@<>(),;:"]+$/;89export function isEmail(address: string): boolean {10 return address.length <= 254 && EMAIL.test(address);11}121314151617export 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 address = email.toLowerCase();27 const row = kepr28 .query<{ id: number }, [number, string, string, number]>(29 `INSERT INTO issue_followers (issue_id, email, ip_hash, created_at)30VALUES (?, ?, ?, ?)31ON CONFLICT (issue_id, email) DO NOTHING32RETURNING id`,33 )34 .get(issue.id, address, ipHash, now);35 if (!row) return false;36 37 38 recordFollowRequest(kepr, ipHash, now - 3600, now);39 const token = followToken(kepr, row.id, address);40 const base = `/${repo}/issues/${issue.number}`;41 queueEmail(kepr, {42 to: address,43 subject: `Confirm email updates for ${repo} #${issue.number}`,44 href: base,45 text: `Someone asked for an email whenever "${issue.title}" (${repo} #${issue.number})46gets a reply or is closed.4748To start them, open:49${site}${base}/follow?f=${row.id}&t=${token}5051If that wasn't you, ignore this email and nothing more will come.52`,53 });54 return true;55}565758function followerEmail(kepr: Database, issueId: number, id: number): string | null {59 const row = kepr60 .query<{ email: string }, [number, number]>(61 "SELECT email FROM issue_followers WHERE id = ? AND issue_id = ?",62 )63 .get(id, issueId);64 return row?.email ?? null;65}666768697071export function followerLinkMatches(72 kepr: Database,73 issueId: number,74 id: number,75 token: string,76): boolean {77 const email = followerEmail(kepr, issueId, id);78 return email !== null && followTokenMatches(kepr, id, email, token);79}808182export function confirmFollower(kepr: Database, issueId: number, id: number, token: string) {83 if (!followerLinkMatches(kepr, issueId, id, token)) return false;84 const changed = kepr85 .query("UPDATE issue_followers SET confirmed = 1 WHERE id = ? AND issue_id = ?")86 .run(id, issueId).changes;87 return changed === 1;88}899091export function unfollow(kepr: Database, issueId: number, id: number, token: string): boolean {92 if (!followerLinkMatches(kepr, issueId, id, token)) return false;93 const changed = kepr94 .query("DELETE FROM issue_followers WHERE id = ? AND issue_id = ?")95 .run(id, issueId).changes;96 return changed === 1;97}9899100101102103export function followsSince(kepr: Database, ipHash: string, since: number): number {104 const row = kepr105 .query<{ n: number }, [string, number]>(106 "SELECT COUNT(*) AS n FROM issue_follow_requests WHERE ip_hash = ? AND created_at > ?",107 )108 .get(ipHash, since);109 return row?.n ?? 0;110}111112113export function recordFollowRequest(114 kepr: Database,115 ipHash: string,116 since: number,117 now: number,118): void {119 kepr120 .query("DELETE FROM issue_follow_requests WHERE created_at <= ? AND ip_hash = ?")121 .run(since, ipHash);122 kepr123 .query("INSERT INTO issue_follow_requests (ip_hash, created_at) VALUES (?, ?)")124 .run(ipHash, now);125}126127export function followerCount(kepr: Database, issueId: number): number {128 const row = kepr129 .query<{ n: number }, [number]>(130 "SELECT COUNT(*) AS n FROM issue_followers WHERE issue_id = ? AND confirmed = 1",131 )132 .get(issueId);133 return row?.n ?? 0;134}135136137138139140export function tellFollowers(141 kepr: Database,142 site: string,143 repo: string,144 issue: Pick<Issue, "id" | "number" | "title">,145 news: { text: string; href: string },146 except = "",147): void {148 const followers = kepr149 .query<{ id: number; email: string }, [number, string]>(150 "SELECT id, email FROM issue_followers WHERE issue_id = ? AND confirmed = 1 AND email != ?",151 )152 .all(issue.id, except.toLowerCase());153 kepr.transaction(() => {154 for (const follower of followers) {155 const token = followToken(kepr, follower.id, follower.email);156 const stop = `/${repo}/issues/${issue.number}/unfollow?f=${follower.id}&t=${token}`;157 queueEmail(kepr, {158 to: follower.email,159 subject: `Re: ${issue.title} (${repo} #${issue.number})`,160 href: news.href,161 unsubscribe: stop,162 text: `${news.text}163164${site}${news.href}165166You follow this issue by email. To stop: ${site}${stop}167`,168 });169 }170 })();171}