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 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}525354export 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}616263export 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}707172export 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}899091929394export 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}