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 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, email.toLowerCase(), ipHash, now); if (!row) return false; const token = followToken(kepr, row.id); const base = `/${repo}/issues/${issue.number}`; queueEmail(kepr, { to: email.toLowerCase(), 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; } /** 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 (!followTokenMatches(kepr, 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 (!followTokenMatches(kepr, 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. */ export function followsSince(kepr: Database, ipHash: string, since: number): number { const row = kepr .query<{ n: number }, [string, number]>( "SELECT COUNT(*) AS n FROM issue_followers WHERE ip_hash = ? AND created_at > ?", ) .get(ipHash, since); return row?.n ?? 0; } 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); 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} `, }); } })(); }