@novacraft-engineering/mailbox 0.4.27 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ // Mail is styled inline, and only a stylesheet rule marked !important can override an inline
2
+ // colour, so the other scheme is a prefers-color-scheme block of per-element classes. Gmail
3
+ // drops that block and keeps inverting the inline styles itself.
4
+
5
+ type Rgb = [number, number, number]
6
+ type Hsl = [number, number, number]
7
+ export type SchemeBase = 'light' | 'dark'
8
+
9
+ const NAMED: Record<string, Rgb> = { white: [255, 255, 255], black: [0, 0, 0] }
10
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'])
11
+ const RAW_TEXT = new Set(['style', 'script', 'title', 'textarea'])
12
+ const COLOR_TOKEN = /#[0-9a-f]{6}\b|#[0-9a-f]{3}\b|rgba?\([^)]*\)|\b(?:white|black)\b/i
13
+ const SURFACE: Record<SchemeBase, Rgb> = { light: [28, 28, 30], dark: [255, 255, 255] }
14
+
15
+ function parseColor(value: string | undefined): Rgb | null {
16
+ if (!value) return null
17
+ const raw = value.trim().toLowerCase().replace(/\s*!important$/, '')
18
+ if (NAMED[raw]) return NAMED[raw]
19
+ const hex = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/)
20
+ if (hex) {
21
+ const full = hex[1].length === 3 ? [...hex[1]].map(digit => digit + digit).join('') : hex[1]
22
+ return [0, 2, 4].map(offset => parseInt(full.slice(offset, offset + 2), 16)) as Rgb
23
+ }
24
+ const rgb = raw.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/)
25
+ if (rgb && (rgb[4] === undefined || Number(rgb[4]) >= 0.9)) return [rgb[1], rgb[2], rgb[3]].map(Number) as Rgb
26
+ return null
27
+ }
28
+
29
+ const toHex = (color: Rgb) => `#${color.map(channel => Math.round(channel).toString(16).padStart(2, '0')).join('')}`
30
+
31
+ function luminance(color: Rgb): number {
32
+ const [red, green, blue] = color.map(channel => {
33
+ const scaled = channel / 255
34
+ return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4
35
+ })
36
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue
37
+ }
38
+
39
+ function contrast(first: Rgb, second: Rgb): number {
40
+ const [high, low] = [luminance(first), luminance(second)].sort((left, right) => right - left)
41
+ return (high + 0.05) / (low + 0.05)
42
+ }
43
+
44
+ function toHsl([red, green, blue]: Rgb): Hsl {
45
+ const [scaledRed, scaledGreen, scaledBlue] = [red / 255, green / 255, blue / 255]
46
+ const max = Math.max(scaledRed, scaledGreen, scaledBlue)
47
+ const min = Math.min(scaledRed, scaledGreen, scaledBlue)
48
+ const lightness = (max + min) / 2
49
+ if (max === min) return [0, 0, lightness]
50
+ const delta = max - min
51
+ const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)
52
+ const hue =
53
+ max === scaledRed ? (scaledGreen - scaledBlue) / delta + (scaledGreen < scaledBlue ? 6 : 0)
54
+ : max === scaledGreen ? (scaledBlue - scaledRed) / delta + 2
55
+ : (scaledRed - scaledGreen) / delta + 4
56
+ return [hue / 6, saturation, lightness]
57
+ }
58
+
59
+ function fromHsl([hue, saturation, lightness]: Hsl): Rgb {
60
+ if (!saturation) return [lightness * 255, lightness * 255, lightness * 255]
61
+ const upper = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation
62
+ const lower = 2 * lightness - upper
63
+ const channel = (offset: number) => {
64
+ let tint = hue + offset
65
+ if (tint < 0) tint += 1
66
+ if (tint > 1) tint -= 1
67
+ if (tint < 1 / 6) return lower + (upper - lower) * 6 * tint
68
+ if (tint < 1 / 2) return upper
69
+ if (tint < 2 / 3) return lower + (upper - lower) * (2 / 3 - tint) * 6
70
+ return lower
71
+ }
72
+ return [channel(1 / 3) * 255, channel(0) * 255, channel(-1 / 3) * 255]
73
+ }
74
+
75
+ const withLightness = (color: Rgb, lightness: number, saturationScale = 1): Rgb => {
76
+ const [hue, saturation] = toHsl(color)
77
+ return fromHsl([hue, saturation * saturationScale, Math.min(1, Math.max(0, lightness))])
78
+ }
79
+
80
+ function flipSurface(color: Rgb, base: SchemeBase, kind: 'fill' | 'border'): Rgb {
81
+ const lightness = toHsl(color)[2]
82
+ if (base === 'light') {
83
+ if (lightness <= 0.75) return color
84
+ return withLightness(color, (1 - lightness) * 0.6 + (kind === 'fill' ? 0.06 : 0.2), 0.7)
85
+ }
86
+ if (lightness >= 0.25) return color
87
+ return withLightness(color, kind === 'fill' ? 0.94 + lightness * 0.6 : 0.9 - lightness * 0.6)
88
+ }
89
+
90
+ function fitText(color: Rgb, surface: Rgb): Rgb {
91
+ if (contrast(color, surface) >= 4.5) return color
92
+ const towardLight = luminance(surface) < 0.18
93
+ let lightness = 1 - toHsl(color)[2]
94
+ let candidate = withLightness(color, lightness)
95
+ while (contrast(candidate, surface) < 4.5 && lightness > 0 && lightness < 1) {
96
+ lightness += towardLight ? 0.02 : -0.02
97
+ candidate = withLightness(color, lightness)
98
+ }
99
+ return candidate
100
+ }
101
+
102
+ const sameColor = (first: Rgb, second: Rgb) => toHex(first) === toHex(second)
103
+
104
+ function declarations(style: string): Map<string, string> {
105
+ const found = new Map<string, string>()
106
+ let depth = 0
107
+ let start = 0
108
+ const take = (end: number) => {
109
+ const part = style.slice(start, end)
110
+ const colon = part.indexOf(':')
111
+ if (colon > 0) found.set(part.slice(0, colon).trim().toLowerCase(), part.slice(colon + 1).trim())
112
+ start = end + 1
113
+ }
114
+ for (let index = 0; index < style.length; index++) {
115
+ const char = style[index]
116
+ if (char === '(') depth++
117
+ else if (char === ')') depth = Math.max(0, depth - 1)
118
+ else if (char === ';' && !depth) take(index)
119
+ }
120
+ take(style.length)
121
+ return found
122
+ }
123
+
124
+ function attribute(attrs: string, name: string): string | undefined {
125
+ const match = attrs.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'))
126
+ return match ? (match[1] ?? match[2] ?? match[3]) : undefined
127
+ }
128
+
129
+ function colorsOf(tag: string, attrs: string) {
130
+ const style = declarations((attribute(attrs, 'style') ?? '').replace(/&quot;/g, '"'))
131
+ const backgroundShorthand = style.get('background')
132
+ const fill =
133
+ parseColor(style.get('background-color')) ??
134
+ (backgroundShorthand && !/url\(/i.test(backgroundShorthand)
135
+ ? parseColor(backgroundShorthand) ?? parseColor(backgroundShorthand.match(COLOR_TOKEN)?.[0])
136
+ : null) ??
137
+ parseColor(attribute(attrs, 'bgcolor'))
138
+ const borderValue = ['border-color', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left']
139
+ .map(name => style.get(name))
140
+ .find(value => value && COLOR_TOKEN.test(value))
141
+ return {
142
+ text: parseColor(style.get('color')) ?? (tag === 'font' ? parseColor(attribute(attrs, 'color')) : null),
143
+ fill,
144
+ border: parseColor(borderValue?.match(COLOR_TOKEN)?.[0]),
145
+ }
146
+ }
147
+
148
+ type Frame = { tag: string; sourceText: Rgb; targetText: Rgb; targetFill: Rgb }
149
+
150
+ export function designsForDark(html: string): boolean {
151
+ return /prefers-color-scheme\s*:\s*dark/i.test(html)
152
+ }
153
+
154
+ export function adaptiveEmail(html: string, base: SchemeBase = 'light'): string {
155
+ if (!html.trim() || /prefers-color-scheme/i.test(html)) return html
156
+ const other = base === 'light' ? 'dark' : 'light'
157
+ const rules = new Map<string, string>()
158
+ const root: Frame = {
159
+ tag: '#root',
160
+ sourceText: [0, 0, 0],
161
+ targetText: fitText([0, 0, 0], SURFACE[base]),
162
+ targetFill: SURFACE[base],
163
+ }
164
+ const stack: Frame[] = [root]
165
+ const tagPattern = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?)\s*(\/?)>/g
166
+ let output = ''
167
+ let cursor = 0
168
+ let match: RegExpExecArray | null
169
+ while ((match = tagPattern.exec(html))) {
170
+ const [whole, closing, rawTag, attrs = '', selfClosing] = match
171
+ output += html.slice(cursor, match.index)
172
+ cursor = match.index + whole.length
173
+ if (!rawTag) {
174
+ output += whole
175
+ continue
176
+ }
177
+ const tag = rawTag.toLowerCase()
178
+ if (closing) {
179
+ const depth = stack.map(frame => frame.tag).lastIndexOf(tag)
180
+ if (depth > 0) stack.length = depth
181
+ output += whole
182
+ continue
183
+ }
184
+ if (RAW_TEXT.has(tag)) {
185
+ const end = html.toLowerCase().indexOf(`</${tag}`, cursor)
186
+ const stop = end === -1 ? html.length : end
187
+ output += whole + html.slice(cursor, stop)
188
+ cursor = stop
189
+ tagPattern.lastIndex = stop
190
+ continue
191
+ }
192
+ const parent = stack[stack.length - 1]
193
+ const own = colorsOf(tag, attrs)
194
+ const sourceText = own.text ?? parent.sourceText
195
+ const targetFill = own.fill ? flipSurface(own.fill, base, 'fill') : parent.targetFill
196
+ const targetText = fitText(sourceText, targetFill)
197
+ const shownWithoutRule = own.text ?? parent.targetText
198
+ const declarationsForOther = [
199
+ !sameColor(targetText, shownWithoutRule) && `color:${toHex(targetText)}!important`,
200
+ own.fill && !sameColor(targetFill, own.fill) && `background-color:${toHex(targetFill)}!important`,
201
+ own.border && !sameColor(flipSurface(own.border, base, 'border'), own.border) &&
202
+ `border-color:${toHex(flipSurface(own.border, base, 'border'))}!important`,
203
+ ].filter(Boolean).join(';')
204
+
205
+ let tagHtml = whole
206
+ if (declarationsForOther) {
207
+ const name = rules.get(declarationsForOther) ?? `nc-${other[0]}${rules.size}`
208
+ rules.set(declarationsForOther, name)
209
+ const existing = attrs.match(/\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i)
210
+ tagHtml = existing
211
+ ? whole.replace(existing[0], ` class="${`${existing[1] ?? existing[2] ?? ''} ${name}`.trim()}"`)
212
+ : whole.replace(/^<[a-zA-Z0-9]+/, opening => `${opening} class="${name}"`)
213
+ }
214
+ output += tagHtml
215
+ if (!selfClosing && !/\/\s*$/.test(attrs) && !VOID.has(tag)) stack.push({ tag, sourceText, targetText, targetFill })
216
+ }
217
+ output += html.slice(cursor)
218
+
219
+ const css = [...rules].map(([body, name]) => `.${name}{${body}}`).join('')
220
+ const head =
221
+ '<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">' +
222
+ `<style>:root{color-scheme:light dark;supported-color-schemes:light dark}@media (prefers-color-scheme: ${other}){${css}}</style>`
223
+ if (/<head[\s>]/i.test(output)) return output.replace(/<head([^>]*)>/i, `<head$1>${head}`)
224
+ if (/<html[\s>]/i.test(output)) return output.replace(/<html([^>]*)>/i, `<html$1><head>${head}</head>`)
225
+ return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${output}</body></html>`
226
+ }
227
+
228
+ // The reader's theme decides, not the device's: a dark app on a light device still gets the dark design.
229
+ export function pinColorScheme(html: string, dark: boolean): string {
230
+ return html.replace(/\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi, (_, scheme: string) =>
231
+ (scheme.toLowerCase() === 'dark') === dark ? '(min-width:0px)' : '(max-width:0px)',
232
+ )
233
+ }
@@ -1,4 +1,5 @@
1
1
  import { BRAND } from '../brand'
2
+ import { adaptiveEmail } from '../color-scheme'
2
3
  /**
3
4
  * Tiny email-template runtime.
4
5
  *
@@ -88,13 +89,13 @@ export function renderNotificationEmail(
88
89
  mode: EmailMode,
89
90
  fields: EmailField[],
90
91
  ): string {
91
- return render(TPL_NOTIFICATION, {
92
+ return adaptiveEmail(render(TPL_NOTIFICATION, {
92
93
  title:
93
94
  mode === 'academy' ? 'New Academy Registration' : 'New Project Inquiry',
94
95
  host: BRAND_HOST,
95
96
  date: new Date().toUTCString(),
96
97
  rows: renderRows(fields, 'notification'),
97
- })
98
+ }), 'dark')
98
99
  }
99
100
 
100
101
  export function notificationSubject(mode: EmailMode, email: string): string {
@@ -108,7 +109,7 @@ export function renderAutoReplyEmail(
108
109
  fields: EmailField[],
109
110
  ): string {
110
111
  const isAcademy = mode === 'academy'
111
- return render(TPL_AUTO_REPLY, {
112
+ return adaptiveEmail(render(TPL_AUTO_REPLY, {
112
113
  headline: isAcademy ? 'Your spot is reserved.' : 'We got your brief.',
113
114
  kicker: isAcademy ? 'Academy' : 'Studio',
114
115
  lead: isAcademy
@@ -117,7 +118,7 @@ export function renderAutoReplyEmail(
117
118
  rows: renderRows(fields, 'reply'),
118
119
  brandUrl: BRAND_URL,
119
120
  brandHost: BRAND_HOST,
120
- })
121
+ }), 'dark')
121
122
  }
122
123
 
123
124
  export function autoReplySubject(mode: EmailMode): string {
@@ -150,7 +151,7 @@ export type AcademyFollowupVars = {
150
151
  }
151
152
 
152
153
  export function renderAcademyFollowupEmail(vars: AcademyFollowupVars): string {
153
- return render(TPL_ACADEMY_FOLLOWUP, {
154
+ return adaptiveEmail(render(TPL_ACADEMY_FOLLOWUP, {
154
155
  logoUrl: vars.logoUrl ?? BRAND.logoUrl,
155
156
  headline: escapeHtml(vars.headline ?? 'Your cohort details are here.'),
156
157
  firstName: escapeHtml(vars.firstName),
@@ -188,7 +189,7 @@ export function renderAcademyFollowupEmail(vars: AcademyFollowupVars): string {
188
189
  signatureRole: escapeHtml(vars.signatureRole ?? `${BRAND.name} Academy`),
189
190
  brandUrl: BRAND_URL,
190
191
  brandHost: BRAND_HOST,
191
- })
192
+ }), 'dark')
192
193
  }
193
194
 
194
195
  export function academyFollowupSubject(courses: string): string {
@@ -239,7 +240,7 @@ function renderAcademySection(vars: ContactFollowupVars): string {
239
240
  }
240
241
 
241
242
  export function renderContactFollowupEmail(vars: ContactFollowupVars): string {
242
- return render(TPL_CONTACT_FOLLOWUP, {
243
+ return adaptiveEmail(render(TPL_CONTACT_FOLLOWUP, {
243
244
  logoUrl: vars.logoUrl ?? BRAND.logoUrl,
244
245
  headline: escapeHtml(vars.headline ?? 'A few quick questions about your project.'),
245
246
  firstName: escapeHtml(vars.firstName),
@@ -274,7 +275,7 @@ export function renderContactFollowupEmail(vars: ContactFollowupVars): string {
274
275
  signatureRole: escapeHtml(vars.signatureRole ?? 'Engineering Studio'),
275
276
  brandUrl: BRAND_URL,
276
277
  brandHost: BRAND_HOST,
277
- })
278
+ }), 'dark')
278
279
  }
279
280
 
280
281
  export function contactFollowupSubject(projectType: string): string {
@@ -294,7 +295,7 @@ export type ActionEmailVars = {
294
295
 
295
296
  /** One-button transactional mail — resets, invites — in the tenant's own accent. */
296
297
  export function renderActionEmail(vars: ActionEmailVars): string {
297
- return render(TPL_ACTION, {
298
+ return adaptiveEmail(render(TPL_ACTION, {
298
299
  eyebrow: escapeHtml(vars.eyebrow),
299
300
  accent: escapeHtml(vars.accent),
300
301
  title: escapeHtml(vars.title),
@@ -303,5 +304,5 @@ export function renderActionEmail(vars: ActionEmailVars): string {
303
304
  actionUrl: escapeHtml(vars.actionUrl),
304
305
  expiry: escapeHtml(vars.expiry),
305
306
  footer: escapeHtml(vars.footer),
306
- })
307
+ }), 'dark')
307
308
  }
@@ -0,0 +1,54 @@
1
+ import assert from 'node:assert/strict'
2
+ import { DatabaseSync } from 'node:sqlite'
3
+ import { DEFAULT_INBOX_FILTERS, inboxFiltersSql, matchesInboxFilters, normalizeInboxFilters, type InboxFilter } from './inbox-filters.ts'
4
+
5
+ assert.deepEqual(normalizeInboxFilters(undefined), DEFAULT_INBOX_FILTERS, 'untouched settings get the defaults')
6
+ assert.deepEqual(normalizeInboxFilters([]), [], 'an emptied list stays empty')
7
+ assert.deepEqual(
8
+ normalizeInboxFilters([
9
+ { kind: 'senderDomain', value: ' @Example.COM ' },
10
+ { kind: 'senderDomain', value: 'example.com' },
11
+ { kind: 'bogus', value: 'x' },
12
+ { kind: 'subjectPrefix', value: ' ' },
13
+ ]),
14
+ [{ kind: 'senderDomain', value: 'example.com' }],
15
+ 'sender values are lowercased, duplicates and junk dropped',
16
+ )
17
+
18
+ const threads = [
19
+ { id: 'dmarc', senders: ['DMARC <dmarcreport@microsoft.com>'], subject: '[Preview] Report Domain: example.com', snippet: 'This is a DMARC report' },
20
+ { id: 'dmarc-joined', senders: ['dmarcreport@microsoft.com', 'Ada <ada@example.org>'], subject: 'Re: report', snippet: 'Thanks' },
21
+ { id: 'newsletter', senders: ['news@mail.example.net'], subject: 'Weekly: what shipped', snippet: '\n Unsubscribe any time' },
22
+ { id: 'person', senders: ['Ada <ada@example.org>'], subject: 'Lunch 50% off?', snippet: 'Hi' },
23
+ ]
24
+
25
+ const db = new DatabaseSync(':memory:')
26
+ db.exec('CREATE TABLE mail_threads (thread_id TEXT, senders TEXT, subject TEXT, snippet TEXT)')
27
+ const insert = db.prepare('INSERT INTO mail_threads VALUES (?, ?, ?, ?)')
28
+ for (const thread of threads) insert.run(thread.id, JSON.stringify(thread.senders), thread.subject, thread.snippet)
29
+
30
+ const viaSql = (filters: InboxFilter[]) => {
31
+ const clause = inboxFiltersSql(filters)
32
+ if (!clause) return []
33
+ return db.prepare(`SELECT thread_id FROM mail_threads t WHERE ${clause.sql} ORDER BY thread_id`).all(...clause.args).map(row => String(row.thread_id))
34
+ }
35
+ const viaJs = (filters: InboxFilter[]) =>
36
+ threads.filter(thread => matchesInboxFilters(filters, { senders: thread.senders, subject: thread.subject, text: thread.snippet })).map(thread => thread.id).sort()
37
+
38
+ const cases: Array<[InboxFilter[], string[]]> = [
39
+ [DEFAULT_INBOX_FILTERS, ['dmarc']],
40
+ [[{ kind: 'senderDomain', value: 'example.net' }], ['newsletter']],
41
+ [[{ kind: 'senderDomain', value: 'microsoft.com' }], ['dmarc']],
42
+ [[{ kind: 'subjectPrefix', value: 'weekly:' }], ['newsletter']],
43
+ [[{ kind: 'subjectContains', value: '50%' }], ['person']],
44
+ [[{ kind: 'subjectContains', value: '0%' }], ['person']],
45
+ [[{ kind: 'messagePrefix', value: 'unsubscribe' }], ['newsletter']],
46
+ [[{ kind: 'senderPrefix', value: 'dmarcreport' }, { kind: 'senderPrefix', value: 'ada' }], ['dmarc', 'dmarc-joined', 'person']],
47
+ ]
48
+ for (const [filters, expected] of cases) {
49
+ assert.deepEqual(viaSql(filters), expected, `sql ${JSON.stringify(filters)}`)
50
+ assert.deepEqual(viaJs(filters), expected, `js ${JSON.stringify(filters)}`)
51
+ }
52
+ assert.equal(inboxFiltersSql([]), null)
53
+
54
+ console.log('inbox-filters: ok')
@@ -0,0 +1,111 @@
1
+ export type InboxFilterKind = 'senderPrefix' | 'senderDomain' | 'subjectPrefix' | 'subjectContains' | 'messagePrefix'
2
+ export type InboxFilter = { kind: InboxFilterKind; value: string }
3
+
4
+ export const INBOX_FILTER_KINDS: Array<{ kind: InboxFilterKind; label: string; placeholder: string }> = [
5
+ { kind: 'senderPrefix', label: 'Sender starts with', placeholder: 'dmarcreport' },
6
+ { kind: 'senderDomain', label: 'Sender domain', placeholder: 'example.com' },
7
+ { kind: 'subjectPrefix', label: 'Subject starts with', placeholder: 'Report Domain:' },
8
+ { kind: 'subjectContains', label: 'Subject contains', placeholder: 'newsletter' },
9
+ { kind: 'messagePrefix', label: 'Message starts with', placeholder: 'This is an automated' },
10
+ ]
11
+
12
+ export const DEFAULT_INBOX_FILTERS: InboxFilter[] = [{ kind: 'senderPrefix', value: 'dmarcreport' }]
13
+
14
+ const MAX_FILTERS = 40
15
+ const MAX_VALUE = 200
16
+ const SENDER_KINDS = new Set<InboxFilterKind>(['senderPrefix', 'senderDomain'])
17
+
18
+ /** A person who never touched the list gets the defaults; one who emptied it gets none. */
19
+ export function normalizeInboxFilters(raw: unknown): InboxFilter[] {
20
+ if (!Array.isArray(raw)) return DEFAULT_INBOX_FILTERS
21
+ const seen = new Set<string>()
22
+ const filters: InboxFilter[] = []
23
+ for (const entry of raw) {
24
+ const kind = (entry as InboxFilter)?.kind
25
+ if (!INBOX_FILTER_KINDS.some(option => option.kind === kind)) continue
26
+ let value = String((entry as InboxFilter).value ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_VALUE)
27
+ if (SENDER_KINDS.has(kind)) value = value.toLowerCase().replace(/^@/, '')
28
+ const key = `${kind}:${value.toLowerCase()}`
29
+ if (!value || seen.has(key)) continue
30
+ seen.add(key)
31
+ filters.push({ kind, value })
32
+ if (filters.length === MAX_FILTERS) break
33
+ }
34
+ return filters
35
+ }
36
+
37
+ const likeEscape = (value: string) => value.replace(/[\\%_]/g, match => `\\${match}`)
38
+
39
+ function senderAddress(raw: string): string {
40
+ const angled = raw.match(/<([^>]*)>?/)
41
+ return (angled ? angled[1] : raw).trim().toLowerCase()
42
+ }
43
+
44
+ const ADDRESS_SQL = "lower(trim(rtrim(CASE WHEN instr(value, '<') > 0 THEN substr(value, instr(value, '<') + 1) ELSE value END, '> ')))"
45
+ const SNIPPET_SQL = "ltrim(coalesce(t.snippet, ''), ' ' || char(9, 10, 13))"
46
+
47
+ /**
48
+ * The rules as one SQL condition over a mail_threads row aliased `t`. Sender rules hold only
49
+ * when every sender in the conversation matches, so a person replying into a filtered
50
+ * thread brings it back; subject and message rules read the latest message.
51
+ */
52
+ export function inboxFiltersSql(filters: InboxFilter[]): { sql: string; args: string[] } | null {
53
+ if (!filters.length) return null
54
+ const senderRules: string[] = []
55
+ const senderArgs: string[] = []
56
+ const rules: string[] = []
57
+ const args: string[] = []
58
+ for (const filter of filters) {
59
+ const value = likeEscape(filter.value)
60
+ if (filter.kind === 'senderPrefix') {
61
+ senderRules.push(`${ADDRESS_SQL} LIKE ? ESCAPE '\\'`)
62
+ senderArgs.push(`${value.toLowerCase()}%`)
63
+ } else if (filter.kind === 'senderDomain') {
64
+ senderRules.push(`(${ADDRESS_SQL} LIKE ? ESCAPE '\\' OR ${ADDRESS_SQL} LIKE ? ESCAPE '\\')`)
65
+ senderArgs.push(`%@${value.toLowerCase()}`, `%.${value.toLowerCase()}`)
66
+ } else if (filter.kind === 'subjectPrefix') {
67
+ rules.push(`coalesce(t.subject, '') LIKE ? ESCAPE '\\'`)
68
+ args.push(`${value}%`)
69
+ } else if (filter.kind === 'subjectContains') {
70
+ rules.push(`coalesce(t.subject, '') LIKE ? ESCAPE '\\'`)
71
+ args.push(`%${value}%`)
72
+ } else {
73
+ rules.push(`${SNIPPET_SQL} LIKE ? ESCAPE '\\'`)
74
+ args.push(`${value}%`)
75
+ }
76
+ }
77
+ const parts = [...rules]
78
+ if (senderRules.length) {
79
+ parts.unshift(
80
+ `(json_array_length(coalesce(t.senders, '[]')) > 0 AND NOT EXISTS (SELECT 1 FROM json_each(coalesce(t.senders, '[]')) WHERE NOT (${senderRules.join(' OR ')})))`,
81
+ )
82
+ }
83
+ return { sql: `(${parts.join(' OR ')})`, args: [...senderArgs, ...args] }
84
+ }
85
+
86
+ /** The same rules for one message, for the paths that never reach SQL — a push, say. */
87
+ export function matchesInboxFilters(
88
+ filters: InboxFilter[],
89
+ message: { senders: string[]; subject: string | null | undefined; text: string | null | undefined },
90
+ ): boolean {
91
+ const addresses = message.senders.filter(Boolean).map(senderAddress)
92
+ const subject = (message.subject ?? '').toLowerCase()
93
+ const text = (message.text ?? '').trimStart().toLowerCase()
94
+ const senderMatches = (address: string) =>
95
+ filters.some(filter => {
96
+ if (filter.kind === 'senderPrefix') return address.startsWith(filter.value.toLowerCase())
97
+ if (filter.kind === 'senderDomain') {
98
+ const domain = filter.value.toLowerCase()
99
+ return address.endsWith(`@${domain}`) || address.endsWith(`.${domain}`)
100
+ }
101
+ return false
102
+ })
103
+ if (filters.some(filter => SENDER_KINDS.has(filter.kind)) && addresses.length && addresses.every(senderMatches)) return true
104
+ return filters.some(filter => {
105
+ const value = filter.value.toLowerCase()
106
+ if (filter.kind === 'subjectPrefix') return subject.startsWith(value)
107
+ if (filter.kind === 'subjectContains') return subject.includes(value)
108
+ if (filter.kind === 'messagePrefix') return text.startsWith(value)
109
+ return false
110
+ })
111
+ }
@@ -176,8 +176,10 @@ async function sendViaSes(payload: SendPayload): Promise<SendResult> {
176
176
  * here ever sees it. Skipped when the archive is already a recipient, so a message never
177
177
  * arrives twice, and skipped for anything a person wrote — their Sent folder already has it.
178
178
  */
179
+ export const archiveAddress = () => (process.env.MAIL_ARCHIVE_BCC ?? '').trim().toLowerCase()
180
+
179
181
  function withArchive(payload: SendPayload): SendPayload {
180
- const archive = (process.env.MAIL_ARCHIVE_BCC ?? '').trim().toLowerCase()
182
+ const archive = archiveAddress()
181
183
  if (!archive || payload.skipArchive) return payload
182
184
  const already = [...payload.to, ...(payload.cc ?? []), ...(payload.bcc ?? [])]
183
185
  .some(entry => entry.toLowerCase().includes(archive))
package/lib/mailbox.ts CHANGED
@@ -13,6 +13,7 @@ import { hashPassword } from './password'
13
13
  import { duration, type ParsedQuery } from '@/app/mail/search'
14
14
  import { turso, tursoBatch, tursoQuery } from './turso'
15
15
  import { subjectKey, threadIdFor, THREAD_GAP_MS } from './threads'
16
+ import { inboxFiltersSql, normalizeInboxFilters, type InboxFilter } from './inbox-filters'
16
17
 
17
18
  export type InboundAttachment = { filename: string; contentType?: string; size?: number; shareId?: string }
18
19
 
@@ -1183,13 +1184,23 @@ async function rethreadAfterChange(id: string, previousOwner?: string | null, pr
1183
1184
  }
1184
1185
  }
1185
1186
 
1186
- export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
1187
+ export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam' | 'filtered'
1187
1188
 
1188
1189
  /** The newest conversations in a folder: one row each, already summarised. */
1189
1190
  export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
1190
1191
 
1191
- export async function listThreads(ownerRaw: string | null, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
1192
- await ensureMailSchema()
1192
+ export async function listThreads(
1193
+ ownerRaw: string | null,
1194
+ folder: ThreadFolder,
1195
+ limit: number,
1196
+ cursorRaw?: string | null,
1197
+ filters: InboxFilter[] = [],
1198
+ ): Promise<ThreadPage> {
1199
+ await ensureMailSchema()
1200
+ const filterSql = folder === 'inbox' || folder === 'filtered' ? inboxFiltersSql(filters) : null
1201
+ if (folder === 'filtered' && !filterSql) return { rows: [], nextCursor: null }
1202
+ const filterClause = filterSql ? (folder === 'filtered' ? filterSql.sql : `NOT ${filterSql.sql}`) : ''
1203
+ const filterArgs = filterSql?.args ?? []
1193
1204
  const owner = ownerRaw === null ? null : ownerRaw.toLowerCase()
1194
1205
  const nowIso = new Date().toISOString()
1195
1206
  const predicate =
@@ -1201,7 +1212,7 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
1201
1212
  : 'inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?)'
1202
1213
  // Both snooze predicates carry one bound timestamp; the others carry none, and the
1203
1214
  // cursor's arguments have to follow whatever the predicate used.
1204
- const folderArgs = folder === 'snoozed' || folder === 'inbox' ? [nowIso] : []
1215
+ const folderArgs = folder === 'snoozed' || folder === 'inbox' || folder === 'filtered' ? [nowIso] : []
1205
1216
  const cursor = decodeCursor(cursorRaw)
1206
1217
  const cursorClause = cursor ? '(latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''
1207
1218
  const cursorArgs = cursor ? [cursor.receivedAt, cursor.receivedAt, cursor.id] : []
@@ -1209,25 +1220,28 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
1209
1220
  // conversation back together. SQLite fills a bare column from whichever row matched the
1210
1221
  // MAX in the same select, which is how subject, snippet and senders come from the latest
1211
1222
  // message rather than an arbitrary one.
1223
+ const outer = [filterClause, cursorClause].filter(Boolean).join(' AND ')
1212
1224
  const rows = owner === null
1213
1225
  ? await tagged(db(), `
1214
- SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
1215
- SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
1216
- SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
1217
- SUM(trashed_count) AS trashed_count, SUM(spam_count) AS spam_count, SUM(attach_count) AS attach_count,
1218
- senders, snippet, labels, snoozed_until, addressed, risk
1219
- FROM mail_threads
1220
- GROUP BY thread_id
1221
- HAVING ${predicate}${cursorClause ? ` AND ${cursorClause}` : ''}
1226
+ SELECT * FROM (
1227
+ SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
1228
+ SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
1229
+ SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
1230
+ SUM(trashed_count) AS trashed_count, SUM(spam_count) AS spam_count, SUM(attach_count) AS attach_count,
1231
+ senders, snippet, labels, snoozed_until, addressed, risk
1232
+ FROM mail_threads
1233
+ GROUP BY thread_id
1234
+ HAVING ${predicate}
1235
+ ) t${outer ? ` WHERE ${outer}` : ''}
1222
1236
  ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
1223
- [...folderArgs, ...cursorArgs, limit])
1237
+ [...folderArgs, ...filterArgs, ...cursorArgs, limit])
1224
1238
  : await tagged(db(), `
1225
1239
  SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
1226
1240
  inbox_count, archived_count, trashed_count, spam_count, attach_count, senders, snippet, labels, snoozed_until, addressed, risk
1227
- FROM mail_threads WHERE owner = ? AND ${predicate}
1228
- ${cursorClause ? `AND ${cursorClause}` : ''}
1241
+ FROM mail_threads t WHERE owner = ? AND ${predicate}
1242
+ ${outer ? `AND ${outer}` : ''}
1229
1243
  ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
1230
- [owner, ...folderArgs, ...cursorArgs, limit])
1244
+ [owner, ...folderArgs, ...filterArgs, ...cursorArgs, limit])
1231
1245
  const last = rows[rows.length - 1]
1232
1246
  const nextCursor = rows.length === limit && last ? encodeCursor(String(last.latest_at), String(last.thread_id)) : null
1233
1247
  const mapped = rows.map(row => ({
@@ -1254,6 +1268,27 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
1254
1268
  return { rows: mapped, nextCursor }
1255
1269
  }
1256
1270
 
1271
+ /** Inbox conversations the rules hold out, and how many of those have unread mail. */
1272
+ export async function countFilteredThreads(ownerRaw: string | null, filters: InboxFilter[]): Promise<{ total: number; unread: number }> {
1273
+ const filterSql = inboxFiltersSql(filters)
1274
+ if (!filterSql) return { total: 0, unread: 0 }
1275
+ await ensureMailSchema()
1276
+ const nowIso = new Date().toISOString()
1277
+ const inbox = 'inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?)'
1278
+ const rows = ownerRaw === null
1279
+ ? await tagged(db(), `
1280
+ SELECT COUNT(*) AS total, SUM(CASE WHEN unread_count > 0 THEN 1 ELSE 0 END) AS unread FROM (
1281
+ SELECT thread_id, SUM(unread_count) AS unread_count, SUM(inbox_count) AS inbox_count, snoozed_until, senders, subject, snippet, MAX(latest_at)
1282
+ FROM mail_threads GROUP BY thread_id HAVING ${inbox}
1283
+ ) t WHERE ${filterSql.sql}`,
1284
+ [nowIso, ...filterSql.args])
1285
+ : await tagged(db(), `
1286
+ SELECT COUNT(*) AS total, SUM(CASE WHEN unread_count > 0 THEN 1 ELSE 0 END) AS unread
1287
+ FROM mail_threads t WHERE owner = ? AND ${inbox} AND ${filterSql.sql}`,
1288
+ [ownerRaw.toLowerCase(), nowIso, ...filterSql.args])
1289
+ return { total: Number(rows[0]?.total ?? 0), unread: Number(rows[0]?.unread ?? 0) }
1290
+ }
1291
+
1257
1292
  /**
1258
1293
  * Backfill, phase one: give every message a thread id, oldest first so the thirty-day
1259
1294
  * rule holds for history. Summaries are left for the second phase, so each thread is
@@ -1786,7 +1821,8 @@ export async function inboundExists(ids: string[]): Promise<Set<string>> {
1786
1821
  for (let i = 0; i < ids.length; i += 200) {
1787
1822
  const chunk = ids.slice(i, i + 200)
1788
1823
  if (!chunk.length) break
1789
- const rows = await tagged(db(), `SELECT id FROM mail_inbox WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk)
1824
+ const marks = chunk.map(() => '?').join(',')
1825
+ const rows = await tagged(db(), `SELECT id FROM mail_inbox WHERE id IN (${marks}) UNION SELECT id FROM mail_sent WHERE id IN (${marks})`, [...chunk, ...chunk])
1790
1826
  for (const row of rows) found.add(String(row.id))
1791
1827
  }
1792
1828
  return found
@@ -1905,6 +1941,10 @@ export async function getSettings(owner: string): Promise<Record<string, unknown
1905
1941
  return parseJson<Record<string, unknown>>(rows[0]?.data, {})
1906
1942
  }
1907
1943
 
1944
+ export async function inboxFiltersFor(login: string): Promise<InboxFilter[]> {
1945
+ return normalizeInboxFilters((await getSettings(login)).inboxFilters)
1946
+ }
1947
+
1908
1948
  export async function setSettings(owner: string, data: Record<string, unknown>): Promise<void> {
1909
1949
  await ensureMailSchema()
1910
1950
  const sql = db()