@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.
- package/app/api/mail/inbox/counts/route.ts +13 -3
- package/app/api/mail/send/route.ts +2 -1
- package/app/api/mail/threads/route.ts +4 -3
- package/app/layout.tsx +19 -4
- package/app/mail/InboxFilters.tsx +154 -0
- package/app/mail/page.module.css +170 -0
- package/app/mail/page.tsx +67 -25
- package/app/manifest.ts +2 -0
- package/app/robots.ts +2 -0
- package/lib/brand.client.ts +46 -25
- package/lib/color-scheme.test.ts +51 -0
- package/lib/color-scheme.ts +233 -0
- package/lib/emails/index.ts +11 -10
- package/lib/inbox-filters.test.ts +54 -0
- package/lib/inbox-filters.ts +111 -0
- package/lib/mail-provider.ts +3 -1
- package/lib/mailbox.ts +57 -17
- package/lib/receive.ts +35 -3
- package/package.json +1 -1
|
@@ -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(/"/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
|
+
}
|
package/lib/emails/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/mail-provider.ts
CHANGED
|
@@ -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 = (
|
|
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(
|
|
1192
|
-
|
|
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
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
${
|
|
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
|
|
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()
|