Skip to content

src/plus-alias.ts

185 lines · 5.5 KB · TypeScript
Raw
export const INBOX_DOMAINS = new Set([
	'inbox.kody.codes',
	'inbox.heykody.app',
	'inbox.heykody.dev',
])

export const INBOX_LOCAL = 'kentcdodds'
export const PRIMARY_INBOX_DOMAIN = 'inbox.kody.codes'

export const KENT_FROM_ADDRESSES = new Set([
	'kentcdodds@gmail.com',
	'kent.c.dodds@gmail.com',
	'me@kentcdodds.com',
])

export type PlusAliasHit = {
	address: string
	tag: string
	domain: string
}

export function extractEmail(raw: unknown): string {
	if (typeof raw !== 'string') return ''
	const trimmed = raw.trim()
	if (!trimmed) return ''
	const angle = trimmed.match(/<([^>]+)>/)
	return (angle?.[1] ?? trimmed).trim().toLowerCase()
}

export function isKentAddress(raw: unknown): boolean {
	const address = extractEmail(raw)
	return Boolean(address) && KENT_FROM_ADDRESSES.has(address)
}

export function isKentFrom(message: {
	from_address?: unknown
	envelope_from?: unknown
}): boolean {
	return isKentAddress(message.from_address) || isKentAddress(message.envelope_from)
}

function listEmails(value: unknown): string[] {
	if (typeof value === 'string') return [extractEmail(value)].filter(Boolean)
	if (!Array.isArray(value)) return []
	return value.map((item) => extractEmail(item)).filter(Boolean)
}

/** Kent is on this message as a participant, not only as the plus-alias inbox. */
export function kentOnThisMessage(message: {
	to_addresses?: unknown
	cc_addresses?: unknown
	bcc_addresses?: unknown
	reply_to_addresses?: unknown
}): boolean {
	const addresses = [
		...listEmails(message.to_addresses),
		...listEmails(message.cc_addresses),
		...listEmails(message.bcc_addresses),
		...listEmails(message.reply_to_addresses),
	]
	return addresses.some((address) => KENT_FROM_ADDRESSES.has(address))
}

export function plusAddressFor(tag: string): string {
	const normalized = tag.trim().toLowerCase()
	return `${INBOX_LOCAL}+${normalized}@${PRIMARY_INBOX_DOMAIN}`
}

export function plusAliasFromAddress(raw: unknown): PlusAliasHit | null {
	const email = extractEmail(raw)
	const at = email.lastIndexOf('@')
	if (at <= 0) return null
	const local = email.slice(0, at)
	const domain = email.slice(at + 1)
	if (!INBOX_DOMAINS.has(domain)) return null
	const prefix = `${INBOX_LOCAL}+`
	if (!local.startsWith(prefix)) return null
	const tag = local.slice(prefix.length).trim()
	if (!tag) return null
	return { address: email, tag, domain }
}

export function plusAliasesFromAddresses(addresses: unknown): PlusAliasHit[] {
	const list = Array.isArray(addresses) ? addresses : []
	const hits: PlusAliasHit[] = []
	const seen = new Set<string>()
	for (const item of list) {
		const hit = plusAliasFromAddress(item)
		if (!hit) continue
		const key = `${hit.tag}@${hit.domain}`
		if (seen.has(key)) continue
		seen.add(key)
		hits.push(hit)
	}
	return hits
}

export function plusAliasesFromMessage(message: {
	to_addresses?: unknown
	cc_addresses?: unknown
	bcc_addresses?: unknown
}): PlusAliasHit[] {
	return plusAliasesFromAddresses([
		...(Array.isArray(message.to_addresses) ? message.to_addresses : []),
		...(Array.isArray(message.cc_addresses) ? message.cc_addresses : []),
		...(Array.isArray(message.bcc_addresses) ? message.bcc_addresses : []),
	])
}

export function aliasCandidates(tag: string): string[] {
	const normalized = tag.trim().toLowerCase()
	if (!normalized) return []
	const first = normalized.split('+')[0]?.trim() ?? ''
	const unique = [normalized]
	if (first && first !== normalized) unique.push(first)
	return unique
}

export function threadOpenKey(threadId: string): string {
	return `email-thread-open:${threadId}`
}

export function threadRateKey(threadId: string): string {
	return `email-wake-rate:${threadId}`
}

export const NON_KENT_WAKE_WINDOW_MS = 10 * 60 * 1000
export const NON_KENT_WAKE_MAX = 3
export const NON_KENT_WAKE_LOCKOUT_MS = 30 * 60 * 1000

export type ThreadRateState = {
	times?: string[]
	lockedUntil?: string
}

export type ThrottleResult =
	| { ok: true; state: ThreadRateState }
	| { ok: false; reason: 'ignored-rate-limit'; lockedUntil: string; state: ThreadRateState }

function asRateState(value: unknown): ThreadRateState {
	let parsed: unknown = value
	if (typeof parsed === 'string') {
		try {
			parsed = JSON.parse(parsed)
		} catch {
			return {}
		}
	}
	if (!parsed || typeof parsed !== 'object') return {}
	const row = parsed as ThreadRateState & { at?: unknown }
	const times = Array.isArray(row.times)
		? row.times.filter((item): item is string => typeof item === 'string')
		: typeof row.at === 'string'
			? [row.at]
			: []
	return {
		times,
		lockedUntil: typeof row.lockedUntil === 'string' ? row.lockedUntil : undefined,
	}
}

/** Allow a few quick non-Kent follow-ups, then lock the thread out. */
export function consumeNonKentThreadThrottle(
	stored: unknown,
	now = Date.now(),
): ThrottleResult {
	const state = asRateState(stored)
	const lockedUntilMs = state.lockedUntil ? Date.parse(state.lockedUntil) : NaN
	if (Number.isFinite(lockedUntilMs) && lockedUntilMs > now) {
		return { ok: false, reason: 'ignored-rate-limit', lockedUntil: state.lockedUntil as string, state }
	}
	const cutoff = now - NON_KENT_WAKE_WINDOW_MS
	const times = (state.times || []).filter((stamp) => {
		const ms = Date.parse(stamp)
		return Number.isFinite(ms) && ms > cutoff
	})
	if (times.length >= NON_KENT_WAKE_MAX) {
		const lockedUntil = new Date(now + NON_KENT_WAKE_LOCKOUT_MS).toISOString()
		const next = { times, lockedUntil }
		return { ok: false, reason: 'ignored-rate-limit', lockedUntil, state: next }
	}
	times.push(new Date(now).toISOString())
	return { ok: true, state: { times, lockedUntil: undefined } }
}