@novacraft-engineering/mailbox 0.1.0

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.
Files changed (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
@@ -0,0 +1,71 @@
1
+ import { brandSlug } from './brand'
2
+ import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
3
+ import { promisify } from 'node:util'
4
+
5
+ // promisify picks the 3-arg overload; this deployment passes scrypt options too.
6
+ const scryptAsync = promisify(scrypt) as (
7
+ password: string,
8
+ salt: Buffer,
9
+ keylen: number,
10
+ options: { N: number },
11
+ ) => Promise<Buffer>
12
+
13
+ const KEY_LENGTH = 64
14
+ const COST = 16384
15
+
16
+ /**
17
+ * Stored form is `scrypt$<cost>$<salt-hex>$<key-hex>`. Anything else is read as one of the
18
+ * bare SHA-256 digests this app wrote before, so existing passwords keep working and get
19
+ * rewritten to scrypt the next time their owner successfully signs in.
20
+ */
21
+ export async function hashPassword(plain: string): Promise<string> {
22
+ const salt = randomBytes(16)
23
+ const key = await scryptAsync(plain, salt, KEY_LENGTH, { N: COST })
24
+ return `scrypt$${COST}$${salt.toString('hex')}$${key.toString('hex')}`
25
+ }
26
+
27
+ export function isLegacyHash(stored: string): boolean {
28
+ return /^[a-f0-9]{64}$/i.test(stored)
29
+ }
30
+
31
+ function equals(left: Buffer, right: Buffer): boolean {
32
+ return left.length === right.length && timingSafeEqual(left, right)
33
+ }
34
+
35
+ export async function verifyPassword(plain: string, stored: string): Promise<boolean> {
36
+ if (!stored) return false
37
+
38
+ if (isLegacyHash(stored)) {
39
+ const digest = createHash('sha256').update(plain).digest()
40
+ return equals(digest, Buffer.from(stored, 'hex'))
41
+ }
42
+
43
+ const [scheme, cost, salt, key] = stored.split('$')
44
+ if (scheme !== 'scrypt' || !cost || !salt || !key) return false
45
+
46
+ try {
47
+ const derived = (await scryptAsync(plain, Buffer.from(salt, 'hex'), KEY_LENGTH, {
48
+ N: Number(cost),
49
+ }))
50
+ return equals(derived, Buffer.from(key, 'hex'))
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ /** Rejects the passwords an attacker tries first, without imposing character-class theatre. */
57
+ export function passwordProblem(password: string, email: string): string | null {
58
+ if (password.length < 10) return 'Password must be at least 10 characters'
59
+ if (password.length > 200) return 'Password must be under 200 characters'
60
+
61
+ const lowered = password.toLowerCase()
62
+ const localPart = email.trim().toLowerCase().split('@')[0]
63
+ if (lowered === email.trim().toLowerCase()) return 'Password cannot be your email address'
64
+ if (localPart && lowered.includes(localPart)) return 'Password cannot contain your address'
65
+ if (/^(.)\1+$/.test(password)) return 'Password cannot be a single repeated character'
66
+
67
+ const WEAK = ['password', '12345678', 'qwerty', 'letmein', 'changeme', brandSlug]
68
+ if (WEAK.some(entry => lowered.includes(entry))) return 'Password is too easy to guess'
69
+
70
+ return null
71
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The Host header is caller-controlled, so deriving a link from req.url lets an attacker
3
+ * point a password-reset email at their own domain. Anything that ends up in an email
4
+ * must use the configured origin; req.url is only a local-development fallback.
5
+ */
6
+ export function publicOrigin(req: Request): string {
7
+ const configured = process.env.MAIL_PUBLIC_URL?.trim().replace(/\/$/, '')
8
+ if (configured) return configured
9
+
10
+ const vercel = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim()
11
+ if (vercel) return `https://${vercel.replace(/^https?:\/\//, '').replace(/\/$/, '')}`
12
+
13
+ return new URL(req.url).origin
14
+ }
package/lib/push.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { BRAND } from './brand'
2
+ import webpush from 'web-push'
3
+ import { deletePushSubscription, listPushSubscriptions } from './mailbox'
4
+
5
+ export type PushPayload = { title: string; body: string; tag?: string; url?: string }
6
+
7
+ let configured = false
8
+ function configure(): boolean {
9
+ const publicKey = process.env.VAPID_PUBLIC_KEY
10
+ const privateKey = process.env.VAPID_PRIVATE_KEY
11
+ if (!publicKey || !privateKey) return false
12
+ if (!configured) {
13
+ webpush.setVapidDetails(BRAND.vapidSubject, publicKey, privateKey)
14
+ configured = true
15
+ }
16
+ return true
17
+ }
18
+
19
+ /** Pushes to every device the owner has registered; a device the browser has dropped is forgotten. */
20
+ export async function sendPush(owner: string, payload: PushPayload): Promise<number> {
21
+ if (!configure()) return 0
22
+ const subscriptions = await listPushSubscriptions(owner)
23
+ let delivered = 0
24
+ await Promise.all(
25
+ subscriptions.map(async subscription => {
26
+ try {
27
+ await webpush.sendNotification(subscription, JSON.stringify(payload), { TTL: 3600 })
28
+ delivered += 1
29
+ } catch (err) {
30
+ const status = (err as { statusCode?: number }).statusCode
31
+ if (status === 404 || status === 410) await deletePushSubscription(subscription.endpoint).catch(() => {})
32
+ else console.warn('[push] send failed', status ?? err)
33
+ }
34
+ }),
35
+ )
36
+ return delivered
37
+ }
package/lib/r2.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * R2 over the S3 API, signed with SigV4 by hand.
3
+ *
4
+ * No SDK: @aws-sdk/client-s3 is megabytes for the three operations this needs, and
5
+ * every dependency here is one more thing to vet. Node's crypto already has
6
+ * everything SigV4 wants.
7
+ *
8
+ * Objects are written private. A recipient never touches the bucket directly —
9
+ * they come through the app, which checks the password and then hands out a
10
+ * presigned URL that expires. A world-readable bucket with a password page in
11
+ * front of it protects nothing, because the object URL is the whole secret.
12
+ */
13
+
14
+ import { createHash, createHmac } from 'node:crypto'
15
+
16
+ const SERVICE = 's3'
17
+ // R2 ignores the region but SigV4 requires one in the scope; AWS S3 needs the real one.
18
+ const REGION = process.env.R2_REGION ?? process.env.S3_REGION ?? 'auto'
19
+
20
+ type Config = { endpoint: string; bucket: string; accessKeyId: string; secretAccessKey: string }
21
+
22
+ function config(): Config {
23
+ const endpoint = process.env.R2_S3_ENDPOINT
24
+ const bucket = process.env.R2_BUCKET
25
+ const accessKeyId = process.env.R2_ACCESS_KEY_ID
26
+ const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY
27
+ if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
28
+ throw new Error('R2_S3_ENDPOINT, R2_BUCKET, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY must be configured')
29
+ }
30
+ return { endpoint: endpoint.replace(/\/+$/, ''), bucket, accessKeyId, secretAccessKey }
31
+ }
32
+
33
+ const sha256 = (value: string) => createHash('sha256').update(value, 'utf8').digest('hex')
34
+ const hmac = (key: Buffer | string, value: string) => createHmac('sha256', key).update(value, 'utf8').digest()
35
+
36
+ /** Each path segment is encoded, but the slashes between them are not. */
37
+ function encodeKey(key: string): string {
38
+ return key.split('/').map(segment => encodeURIComponent(segment)).join('/')
39
+ }
40
+
41
+ function signingKey(secret: string, date: string): Buffer {
42
+ return hmac(hmac(hmac(hmac(`AWS4${secret}`, date), REGION), SERVICE), 'aws4_request')
43
+ }
44
+
45
+ /**
46
+ * A URL that carries its own authorisation and stops working when it expires.
47
+ * `method` is PUT for an upload, GET for a download.
48
+ */
49
+ export function presign(
50
+ key: string,
51
+ method: 'PUT' | 'GET' | 'DELETE',
52
+ expiresInSeconds = 900,
53
+ extraQuery: Record<string, string> = {},
54
+ ): string {
55
+ const { endpoint, bucket, accessKeyId, secretAccessKey } = config()
56
+ const url = new URL(`${endpoint}/${bucket}/${encodeKey(key)}`)
57
+
58
+ const now = new Date()
59
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
60
+ const dateStamp = amzDate.slice(0, 8)
61
+ const scope = `${dateStamp}/${REGION}/${SERVICE}/aws4_request`
62
+
63
+ const query: Record<string, string> = {
64
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
65
+ 'X-Amz-Credential': `${accessKeyId}/${scope}`,
66
+ 'X-Amz-Date': amzDate,
67
+ 'X-Amz-Expires': String(Math.min(Math.max(expiresInSeconds, 1), 604800)),
68
+ 'X-Amz-SignedHeaders': 'host',
69
+ ...extraQuery,
70
+ }
71
+
72
+ // SigV4 requires the query sorted by key, with both halves percent-encoded.
73
+ const canonicalQuery = Object.keys(query)
74
+ .sort()
75
+ .map(name => `${encodeURIComponent(name)}=${encodeURIComponent(query[name])}`)
76
+ .join('&')
77
+
78
+ const canonicalRequest = [
79
+ method,
80
+ `/${bucket}/${encodeKey(key)}`,
81
+ canonicalQuery,
82
+ `host:${url.host}\n`,
83
+ 'host',
84
+ 'UNSIGNED-PAYLOAD',
85
+ ].join('\n')
86
+
87
+ const stringToSign = [
88
+ 'AWS4-HMAC-SHA256',
89
+ amzDate,
90
+ scope,
91
+ sha256(canonicalRequest),
92
+ ].join('\n')
93
+
94
+ const signature = createHmac('sha256', signingKey(secretAccessKey, dateStamp))
95
+ .update(stringToSign, 'utf8')
96
+ .digest('hex')
97
+
98
+ return `${url.origin}${url.pathname}?${canonicalQuery}&X-Amz-Signature=${signature}`
99
+ }
100
+
101
+ /** Deletes an object. Used when an upload is abandoned or a share is revoked. */
102
+ export async function deleteObject(key: string): Promise<boolean> {
103
+ // The method is signed, so the URL must be presigned for DELETE itself.
104
+ const response = await fetch(presign(key, 'DELETE', 60), { method: 'DELETE' }).catch(() => null)
105
+ return Boolean(response?.ok)
106
+ }
107
+
108
+ /**
109
+ * Whether the object is actually in the bucket. A share row can outlive its object:
110
+ * an upload that failed after the record was written, or a key removed from the
111
+ * bucket directly. Without this the recipient is redirected to R2 and reads a raw
112
+ * NoSuchKey XML document, which tells them nothing and looks broken.
113
+ */
114
+ export async function objectExists(key: string): Promise<boolean> {
115
+ // A ranged GET rather than a HEAD: the method is part of the signature, so a
116
+ // URL signed for GET is refused when sent as HEAD. One byte is enough to tell
117
+ // a present object (206) from an absent one (404).
118
+ const response = await fetch(presign(key, 'GET', 60), {
119
+ headers: { range: 'bytes=0-0' },
120
+ }).catch(() => null)
121
+ return response?.status === 206 || response?.status === 200
122
+ }
123
+
124
+ /** Writes bytes to the bucket. Returns false rather than throwing so one failed file cannot lose a message. */
125
+ export async function putObject(key: string, body: Buffer | Uint8Array, contentType?: string): Promise<boolean> {
126
+ let reason = ''
127
+ for (let attempt = 0; attempt < 2; attempt++) {
128
+ try {
129
+ const response = await fetch(presign(key, 'PUT', 900), {
130
+ method: 'PUT',
131
+ headers: contentType ? { 'content-type': contentType } : {},
132
+ body: body as BodyInit,
133
+ })
134
+ if (response.ok) return true
135
+ reason = `HTTP ${response.status}`
136
+ if (response.status < 500) break
137
+ } catch (err) {
138
+ reason = String((err as { cause?: unknown })?.cause ?? err)
139
+ }
140
+ }
141
+ console.warn(`[r2] put failed for ${key}: ${reason}`)
142
+ return false
143
+ }
144
+
145
+ /** Streams an object back out, for serving through our own domain instead of a bucket URL. */
146
+ export async function getObject(key: string): Promise<Response | null> {
147
+ for (let attempt = 0; attempt < 2; attempt++) {
148
+ try {
149
+ const response = await fetch(presign(key, 'GET', 300))
150
+ if (response.ok) return response
151
+ if (response.status < 500) return null
152
+ } catch (err) {
153
+ if (attempt) console.warn(`[r2] get failed for ${key}: ${String((err as { cause?: unknown })?.cause ?? err)}`)
154
+ }
155
+ }
156
+ return null
157
+ }
@@ -0,0 +1,43 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ type Bucket = { count: number; resetAt: number }
4
+
5
+ // ponytail: per-instance memory, so a serverless fan-out multiplies the real ceiling by the
6
+ // number of warm lambdas. Enough to stop a single-host credential stuffer; move to Turso or
7
+ // Upstash if the limit ever needs to be exact.
8
+ const buckets = new Map<string, Bucket>()
9
+
10
+ export function clientKey(req: Request, scope: string): string {
11
+ const forwarded = req.headers.get('x-forwarded-for') ?? ''
12
+ const address = forwarded.split(',')[0].trim() || req.headers.get('x-real-ip') || 'unknown'
13
+ return `${scope}:${address}`
14
+ }
15
+
16
+ /** Returns a 429 when the caller is over budget, otherwise null. */
17
+ export function rateLimit(key: string, limit: number, windowMs: number): NextResponse | null {
18
+ const now = Date.now()
19
+
20
+ if (buckets.size > 5000) {
21
+ for (const [entry, bucket] of buckets) if (bucket.resetAt <= now) buckets.delete(entry)
22
+ }
23
+
24
+ const bucket = buckets.get(key)
25
+ if (!bucket || bucket.resetAt <= now) {
26
+ buckets.set(key, { count: 1, resetAt: now + windowMs })
27
+ return null
28
+ }
29
+
30
+ bucket.count += 1
31
+ if (bucket.count > limit) {
32
+ const retryAfter = Math.ceil((bucket.resetAt - now) / 1000)
33
+ return NextResponse.json(
34
+ { ok: false, error: 'Too many attempts. Try again shortly.' },
35
+ { status: 429, headers: { 'Retry-After': String(retryAfter) } },
36
+ )
37
+ }
38
+ return null
39
+ }
40
+
41
+ export function clearRateLimit(key: string): void {
42
+ buckets.delete(key)
43
+ }
@@ -0,0 +1,43 @@
1
+ import assert from 'node:assert/strict'
2
+ import { issueSession, readSession, passwordFingerprint, SESSION_COOKIE } from './session.ts'
3
+
4
+ const asRequest = (cookie: string) => new Request('https://mail.example.com/', { headers: { cookie } })
5
+ const withToken = (token: string) => asRequest(`${SESSION_COOKIE}=${token}`)
6
+
7
+ process.env.MAIL_SESSION_SECRET = 'test-secret-value'
8
+
9
+ const fingerprint = passwordFingerprint('scrypt$16384$abc$def')
10
+ const token = issueSession('Info@Example.com', fingerprint)
11
+ assert.ok(token, 'a secret is configured, so a token is issued')
12
+
13
+ const session = readSession(withToken(token!))
14
+ assert.equal(session?.email, 'info@example.com', 'the address round-trips, lowercased')
15
+ assert.equal(session?.fingerprint, fingerprint)
16
+
17
+ // A flipped character in the signature must not verify.
18
+ const tampered = token!.slice(0, -1) + (token!.endsWith('A') ? 'B' : 'A')
19
+ assert.equal(readSession(withToken(tampered)), null, 'a tampered signature is rejected')
20
+
21
+ // Re-signing a different address with the same length must not verify either.
22
+ const swapped = token!.split('.')
23
+ swapped[0] = Buffer.from('other@mailbox.com').toString('base64url')
24
+ assert.equal(readSession(withToken(swapped.join('.'))), null, 'a swapped payload is rejected')
25
+
26
+ // An expired token is rejected even though its signature is genuine.
27
+ const expired = (() => {
28
+ const parts = issueSession('info@example.com', fingerprint)!.split('.')
29
+ parts[1] = String(Date.now() - 1000)
30
+ return parts.join('.')
31
+ })()
32
+ assert.equal(readSession(withToken(expired)), null, 'an edited expiry breaks the signature')
33
+
34
+ // A password change moves the fingerprint, which is what retires old cookies.
35
+ assert.notEqual(passwordFingerprint('scrypt$16384$abc$def'), passwordFingerprint('scrypt$16384$abc$xyz'))
36
+
37
+ assert.equal(readSession(asRequest('other=1')), null, 'no cookie, no session')
38
+
39
+ delete process.env.MAIL_SESSION_SECRET
40
+ assert.equal(issueSession('info@example.com', fingerprint), null, 'no secret, no token')
41
+ assert.equal(readSession(withToken(token!)), null, 'no secret, nothing verifies')
42
+
43
+ console.log('session self-check passed')
package/lib/session.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
2
+ import type { NextResponse } from 'next/server'
3
+
4
+ export const SESSION_COOKIE = 'mp_mail_session'
5
+
6
+ const MAX_AGE_SECONDS = 14 * 24 * 60 * 60
7
+
8
+ function secret(): string | null {
9
+ return process.env.MAIL_SESSION_SECRET?.trim() || null
10
+ }
11
+
12
+ /**
13
+ * Binding the token to a digest of the stored password hash is what makes a password
14
+ * change revoke every session that was issued before it, without keeping a session table.
15
+ */
16
+ export function passwordFingerprint(passwordHash: string | undefined): string {
17
+ return createHash('sha256').update(passwordHash ?? '').digest('hex').slice(0, 16)
18
+ }
19
+
20
+ function sign(payload: string, key: string): string {
21
+ return createHmac('sha256', key).update(payload).digest('base64url')
22
+ }
23
+
24
+ export function issueSession(email: string, fingerprint: string): string | null {
25
+ const key = secret()
26
+ if (!key) return null
27
+ const expires = Date.now() + MAX_AGE_SECONDS * 1000
28
+ const payload = `${Buffer.from(email).toString('base64url')}.${expires}.${fingerprint}`
29
+ return `${payload}.${sign(payload, key)}`
30
+ }
31
+
32
+ export function readSession(req: Request): { email: string; fingerprint: string } | null {
33
+ const key = secret()
34
+ if (!key) return null
35
+
36
+ const cookies = req.headers.get('cookie') ?? ''
37
+ const raw = cookies
38
+ .split(';')
39
+ .map(entry => entry.trim())
40
+ .find(entry => entry.startsWith(`${SESSION_COOKIE}=`))
41
+ ?.slice(SESSION_COOKIE.length + 1)
42
+ if (!raw) return null
43
+
44
+ const parts = raw.split('.')
45
+ if (parts.length !== 4) return null
46
+ const [encodedEmail, expires, fingerprint, signature] = parts
47
+
48
+ const expected = sign(`${encodedEmail}.${expires}.${fingerprint}`, key)
49
+ if (signature.length !== expected.length) return null
50
+ try {
51
+ if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null
52
+ } catch {
53
+ return null
54
+ }
55
+
56
+ if (!Number(expires) || Number(expires) <= Date.now()) return null
57
+
58
+ const email = Buffer.from(encodedEmail, 'base64url').toString('utf8').trim().toLowerCase()
59
+ if (!email.includes('@')) return null
60
+ return { email, fingerprint }
61
+ }
62
+
63
+ export function attachSession(response: NextResponse, token: string | null): NextResponse {
64
+ if (!token) return response
65
+ response.cookies.set({
66
+ name: SESSION_COOKIE,
67
+ value: token,
68
+ httpOnly: true,
69
+ sameSite: 'lax',
70
+ secure: process.env.NODE_ENV === 'production' || Boolean(process.env.VERCEL),
71
+ path: '/',
72
+ maxAge: MAX_AGE_SECONDS,
73
+ })
74
+ return response
75
+ }
76
+
77
+ export function clearSession(response: NextResponse): NextResponse {
78
+ response.cookies.set({ name: SESSION_COOKIE, value: '', httpOnly: true, path: '/', maxAge: 0 })
79
+ return response
80
+ }
@@ -0,0 +1,28 @@
1
+ import { BRAND, signatureMarkStyle } from './brand'
2
+
3
+ export type SignatureIdentity = { name: string; email: string; mobile?: string }
4
+
5
+ export const SIGNATURE_LOGO_URL = BRAND.markUrl
6
+
7
+ const escape = (value: string) => value.replace(/[&<>"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[char] ?? char)
8
+
9
+ export function signatureLogoHtml(url: string): string {
10
+ return `<p><img src="${escape(url)}" alt="${escape(BRAND.name)}" width="200" style="${signatureMarkStyle(200)}"></p>`
11
+ }
12
+
13
+ /** The company signature, personalised. Editable in the signature editor, so plain paragraphs and spans only. */
14
+ export function companySignatureHtml(identity: SignatureIdentity, options: { logo?: boolean } = {}): string {
15
+ const { accent, link, muted } = BRAND.colors
16
+ const phones = [BRAND.tel ? `Tel: ${BRAND.tel}` : '', identity.mobile ? `Mobile: ${identity.mobile}` : ''].filter(Boolean).join(', ')
17
+ const line2 = [BRAND.address, phones].filter(Boolean).join(' | ')
18
+ return [
19
+ options.logo ? signatureLogoHtml(SIGNATURE_LOGO_URL) : '',
20
+ `<p><span style="font-size: 10pt; color: ${accent}"><strong>${escape(identity.name)} | ${escape(BRAND.legalName)}</strong></span></p>`,
21
+ line2 ? `<p><span style="font-size: 10pt; color: ${accent}">${escape(line2)}</span></p>` : '',
22
+ `<p><span style="font-size: 8pt; color: ${accent}">Email: <a href="mailto:${escape(identity.email)}"><span style="color: ${link}"><u>${escape(identity.email)}</u></span></a></span></p>`,
23
+ `<p><span style="font-size: 8pt; color: ${accent}">Website: <a href="${escape(BRAND.websiteUrl)}/"><span style="color: ${link}">${escape(BRAND.website)}</span></a></span></p>`,
24
+ BRAND.regulatory ? `<p><span style="font-size: 8pt; color: rgb(0, 0, 0)"><strong>${escape(BRAND.regulatory)}</strong></span></p>` : '',
25
+ '<hr>',
26
+ `<p><span style="font-size: 8pt; color: ${muted}">${escape(BRAND.disclaimer)}</span></p>`,
27
+ ].join('')
28
+ }
@@ -0,0 +1,96 @@
1
+ import assert from 'node:assert/strict'
2
+ import { applyThreadFlagDeltas } from './threads.ts'
3
+
4
+ type Counts = {
5
+ threadId: string
6
+ unreadCount: number
7
+ starredCount: number
8
+ inboxCount: number
9
+ archivedCount: number
10
+ trashedCount: number
11
+ }
12
+
13
+ type Message = {
14
+ id: string
15
+ threadId: string | null
16
+ read: boolean
17
+ starred: boolean
18
+ archived: boolean
19
+ trashed: boolean
20
+ }
21
+
22
+ function thread(over: Partial<Counts> = {}): Counts {
23
+ return {
24
+ threadId: 't1',
25
+ unreadCount: 6,
26
+ starredCount: 0,
27
+ inboxCount: 9,
28
+ archivedCount: 0,
29
+ trashedCount: 0,
30
+ ...over,
31
+ }
32
+ }
33
+
34
+ function message(id: string, over: Partial<Message> = {}): Message {
35
+ return {
36
+ id,
37
+ threadId: 't1',
38
+ read: false,
39
+ starred: false,
40
+ archived: false,
41
+ trashed: false,
42
+ ...over,
43
+ }
44
+ }
45
+
46
+ // Opening a conversation marks its unread members read: the row must stop being bold now,
47
+ // not on the next poll ninety seconds later.
48
+ const opened = applyThreadFlagDeltas(
49
+ [thread()],
50
+ [message('a'), message('b'), message('c', { read: true })],
51
+ ['a', 'b', 'c'],
52
+ { read: true },
53
+ )
54
+ assert.equal(opened[0].unreadCount, 4, 'only the two that were actually unread come off the count')
55
+
56
+ // Only the loaded members move the figure, and the ones not loaded are not invented: a
57
+ // recount would have reported this nine-message conversation as holding one.
58
+ const partial = applyThreadFlagDeltas([thread()], [message('a')], ['a'], { read: true })
59
+ assert.equal(partial[0].unreadCount, 5)
60
+ assert.equal(partial[0].inboxCount, 9, 'a bucket the change does not touch is left alone')
61
+
62
+ // Archiving takes the conversation out of the inbox list and into the archive one.
63
+ const archived = applyThreadFlagDeltas([thread({ inboxCount: 1 })], [message('a')], ['a'], { archived: true })
64
+ assert.equal(archived[0].inboxCount, 0)
65
+ assert.equal(archived[0].archivedCount, 1)
66
+
67
+ // Trashing pulls the message out of unread and starred too, because the server's summary
68
+ // counts both only outside the bin.
69
+ const trashed = applyThreadFlagDeltas(
70
+ [thread({ unreadCount: 1, starredCount: 1, inboxCount: 1 })],
71
+ [message('a', { starred: true })],
72
+ ['a'],
73
+ { trashed: true },
74
+ )
75
+ assert.deepEqual(
76
+ {
77
+ unread: trashed[0].unreadCount,
78
+ starred: trashed[0].starredCount,
79
+ inbox: trashed[0].inboxCount,
80
+ binned: trashed[0].trashedCount,
81
+ },
82
+ { unread: 0, starred: 0, inbox: 0, binned: 1 },
83
+ )
84
+
85
+ // A count can never be talked below zero by a message the summary did not know about.
86
+ const floored = applyThreadFlagDeltas([thread({ unreadCount: 0 })], [message('a')], ['a'], { read: true })
87
+ assert.equal(floored[0].unreadCount, 0)
88
+
89
+ // Another conversation's message, an unthreaded one, and an id outside the change all
90
+ // leave the row exactly as it was.
91
+ const rows = [thread()]
92
+ assert.equal(applyThreadFlagDeltas(rows, [message('a', { threadId: 't2' })], ['a'], { read: true })[0], rows[0])
93
+ assert.equal(applyThreadFlagDeltas(rows, [message('a', { threadId: null })], ['a'], { read: true })[0], rows[0])
94
+ assert.equal(applyThreadFlagDeltas(rows, [message('b')], ['a'], { read: true })[0], rows[0])
95
+
96
+ console.log('threads: ok')
package/lib/threads.ts ADDED
@@ -0,0 +1,72 @@
1
+ export function normalizeSubject(subject: string): string {
2
+ return subject
3
+ .replace(/^\s*((re|fwd?|fw)\s*:\s*)+/i, '')
4
+ .replace(/\s+/g, ' ')
5
+ .trim()
6
+ .toLowerCase()
7
+ }
8
+
9
+ export const THREAD_GAP_MS = 30 * 24 * 60 * 60 * 1000
10
+
11
+ export function subjectKey(subject: string): string {
12
+ return normalizeSubject(subject)
13
+ }
14
+
15
+ /** Readable and deterministic: the normalised subject plus the day the thread began. */
16
+ export function threadIdFor(key: string, firstAt: string, messageId: string): string {
17
+ return key ? `${key}#${firstAt.slice(0, 10)}` : `msg:${messageId}`
18
+ }
19
+
20
+ export type ThreadFlags = { read?: boolean; starred?: boolean; archived?: boolean; trashed?: boolean }
21
+
22
+ type FlaggedMessage = { id: string; threadId?: string | null } & Required<ThreadFlags>
23
+
24
+ type ThreadCounts = {
25
+ threadId: string
26
+ unreadCount: number
27
+ starredCount: number
28
+ inboxCount: number
29
+ archivedCount: number
30
+ trashedCount: number
31
+ }
32
+
33
+ const bucketsFor = (row: Required<ThreadFlags>) => ({
34
+ unreadCount: !row.read && !row.trashed ? 1 : 0,
35
+ starredCount: row.starred && !row.trashed ? 1 : 0,
36
+ inboxCount: !row.archived && !row.trashed ? 1 : 0,
37
+ archivedCount: row.archived && !row.trashed ? 1 : 0,
38
+ trashedCount: row.trashed ? 1 : 0,
39
+ })
40
+
41
+ const BUCKETS = ['unreadCount', 'starredCount', 'inboxCount', 'archivedCount', 'trashedCount'] as const
42
+
43
+ export function applyThreadFlagDeltas<Row extends ThreadCounts>(
44
+ threads: Row[],
45
+ messages: FlaggedMessage[],
46
+ ids: string[],
47
+ flags: ThreadFlags,
48
+ ): Row[] {
49
+ const changing = new Set(ids)
50
+ const deltas = new Map<string, Record<(typeof BUCKETS)[number], number>>()
51
+ for (const message of messages) {
52
+ if (!message.threadId || !changing.has(message.id)) continue
53
+ const before = bucketsFor(message)
54
+ const after = bucketsFor({ ...message, ...flags })
55
+ for (const bucket of BUCKETS) {
56
+ if (after[bucket] === before[bucket]) continue
57
+ const entry =
58
+ deltas.get(message.threadId) ??
59
+ { unreadCount: 0, starredCount: 0, inboxCount: 0, archivedCount: 0, trashedCount: 0 }
60
+ entry[bucket] += after[bucket] - before[bucket]
61
+ deltas.set(message.threadId, entry)
62
+ }
63
+ }
64
+ if (deltas.size === 0) return threads
65
+ return threads.map(thread => {
66
+ const delta = deltas.get(thread.threadId)
67
+ if (!delta) return thread
68
+ const next = { ...thread }
69
+ for (const bucket of BUCKETS) next[bucket] = Math.max(0, thread[bucket] + delta[bucket])
70
+ return next
71
+ })
72
+ }