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 const token = followToken(kepr, row.id, address);37 const base = `/${repo}/issues/${issue.number}`;38 queueEmail(kepr, {39 to: address,40 subject: `Confirm email updates for ${repo} #${issue.number}`,41 href: base,42 text: `Someone asked for an email whenever "${issue.title}" (${repo} #${issue.number})43gets a reply or is closed.4445To start them, open:46${site}${base}/follow?f=${row.id}&t=${token}4748If that wasn't you, ignore this email and nothing more will come.49`,50 });51 return true;52}535455function followerEmail(kepr: Database, issueId: number, id: number): string | null {56 const row = kepr57 .query<{ email: string }, [number, number]>(58 "SELECT email FROM issue_followers WHERE id = ? AND issue_id = ?",59 )60 .get(id, issueId);61 return row?.email ?? null;62}636465666768export function followerLinkMatches(69 kepr: Database,70 issueId: number,71 id: number,72 token: string,73): boolean {74 const email = followerEmail(kepr, issueId, id);75 return email !== null && followTokenMatches(kepr, id, email, token);76}777879export function confirmFollower(kepr: Database, issueId: number, id: number, token: string) {80 if (!followerLinkMatches(kepr, issueId, id, token)) return false;81 const changed = kepr82 .query("UPDATE issue_followers SET confirmed = 1 WHERE id = ? AND issue_id = ?")83 .run(id, issueId).changes;84 return changed === 1;85}868788export function unfollow(kepr: Database, issueId: number, id: number, token: string): boolean {89 if (!followerLinkMatches(kepr, issueId, id, token)) return false;90 const changed = kepr91 .query("DELETE FROM issue_followers WHERE id = ? AND issue_id = ?")92 .run(id, issueId).changes;93 return changed === 1;94}959697export function followsSince(kepr: Database, ipHash: string, since: number): number {98 const row = kepr99 .query<{ n: number }, [string, number]>(100 "SELECT COUNT(*) AS n FROM issue_followers WHERE ip_hash = ? AND created_at > ?",101 )102 .get(ipHash, since);103 return row?.n ?? 0;104}105106export function followerCount(kepr: Database, issueId: number): number {107 const row = kepr108 .query<{ n: number }, [number]>(109 "SELECT COUNT(*) AS n FROM issue_followers WHERE issue_id = ? AND confirmed = 1",110 )111 .get(issueId);112 return row?.n ?? 0;113}114115116117118119export function tellFollowers(120 kepr: Database,121 site: string,122 repo: string,123 issue: Pick<Issue, "id" | "number" | "title">,124 news: { text: string; href: string },125 except = "",126): void {127 const followers = kepr128 .query<{ id: number; email: string }, [number, string]>(129 "SELECT id, email FROM issue_followers WHERE issue_id = ? AND confirmed = 1 AND email != ?",130 )131 .all(issue.id, except.toLowerCase());132 kepr.transaction(() => {133 for (const follower of followers) {134 const token = followToken(kepr, follower.id, follower.email);135 const stop = `/${repo}/issues/${issue.number}/unfollow?f=${follower.id}&t=${token}`;136 queueEmail(kepr, {137 to: follower.email,138 subject: `Re: ${issue.title} (${repo} #${issue.number})`,139 href: news.href,140 unsubscribe: stop,141 text: `${news.text}142143${site}${news.href}144145You follow this issue by email. To stop: ${site}${stop}146`,147 });148 }149 })();150}