@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.
- package/.env.example +110 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app/api/mail/accessors/route.ts +197 -0
- package/app/api/mail/attachments/route.ts +54 -0
- package/app/api/mail/company-signature/route.ts +36 -0
- package/app/api/mail/contacts/route.ts +13 -0
- package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
- package/app/api/mail/emails/[id]/route.ts +127 -0
- package/app/api/mail/emails/route.ts +116 -0
- package/app/api/mail/events/route.ts +12 -0
- package/app/api/mail/fonts/route.ts +44 -0
- package/app/api/mail/inbox/attachments/download/route.ts +50 -0
- package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
- package/app/api/mail/inbox/attachments/route.ts +99 -0
- package/app/api/mail/inbox/body/route.ts +14 -0
- package/app/api/mail/inbox/counts/route.ts +25 -0
- package/app/api/mail/inbox/route.ts +506 -0
- package/app/api/mail/link-check/route.ts +95 -0
- package/app/api/mail/login/route.ts +38 -0
- package/app/api/mail/logout/route.ts +9 -0
- package/app/api/mail/maintenance/attachments/route.ts +26 -0
- package/app/api/mail/maintenance/bodies/route.ts +30 -0
- package/app/api/mail/maintenance/list-columns/route.ts +25 -0
- package/app/api/mail/maintenance/threads/route.ts +32 -0
- package/app/api/mail/me/route.ts +13 -0
- package/app/api/mail/outgoing-upload/route.ts +37 -0
- package/app/api/mail/password/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +20 -0
- package/app/api/mail/push/route.ts +42 -0
- package/app/api/mail/render-template/route.ts +58 -0
- package/app/api/mail/request-reset/route.ts +65 -0
- package/app/api/mail/reset/route.ts +46 -0
- package/app/api/mail/send/route.ts +205 -0
- package/app/api/mail/settings/route.ts +35 -0
- package/app/api/mail/share/attachment/route.ts +91 -0
- package/app/api/mail/share/route.ts +130 -0
- package/app/api/mail/signature-logo/route.ts +67 -0
- package/app/api/mail/stash/route.ts +55 -0
- package/app/api/mail/threads/route.ts +18 -0
- package/app/api/mail/upload/route.ts +43 -0
- package/app/api/share/[id]/route.ts +85 -0
- package/app/apple-icon.png +0 -0
- package/app/brand/[file]/route.ts +37 -0
- package/app/globals.css +14 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -0
- package/app/mail/AccessCheck.tsx +45 -0
- package/app/mail/AttachmentLightbox.tsx +241 -0
- package/app/mail/ConfirmDialog.tsx +88 -0
- package/app/mail/MailSelect.tsx +138 -0
- package/app/mail/RichEditor.tsx +859 -0
- package/app/mail/layout.tsx +20 -0
- package/app/mail/page.module.css +6320 -0
- package/app/mail/page.tsx +7971 -0
- package/app/mail/pwa.ts +191 -0
- package/app/mail/reset/page.tsx +132 -0
- package/app/mail/search.ts +172 -0
- package/app/manifest.ts +21 -0
- package/app/page.tsx +5 -0
- package/app/robots.ts +22 -0
- package/app/share/[id]/page.tsx +166 -0
- package/app/share/[id]/share.module.css +148 -0
- package/eslint.config.mjs +9 -0
- package/lib/accent-ramp.ts +54 -0
- package/lib/attachments.ts +52 -0
- package/lib/brand.client.ts +52 -0
- package/lib/brand.ts +87 -0
- package/lib/d1.ts +64 -0
- package/lib/default-signature.ts +68 -0
- package/lib/dev-auth.ts +222 -0
- package/lib/email-html.test.ts +74 -0
- package/lib/email-html.ts +169 -0
- package/lib/emails/index.ts +281 -0
- package/lib/emails/templates/academy-followup.html +68 -0
- package/lib/emails/templates/auto-reply.html +33 -0
- package/lib/emails/templates/contact-followup.html +58 -0
- package/lib/emails/templates/field-row.html +4 -0
- package/lib/emails/templates/notification.html +23 -0
- package/lib/fonts.ts +23 -0
- package/lib/link-safety.ts +81 -0
- package/lib/mail-provider.ts +183 -0
- package/lib/mailbox.ts +1888 -0
- package/lib/password.ts +71 -0
- package/lib/public-url.ts +14 -0
- package/lib/push.ts +37 -0
- package/lib/r2.ts +157 -0
- package/lib/rate-limit.ts +43 -0
- package/lib/session.test.ts +43 -0
- package/lib/session.ts +80 -0
- package/lib/signature.ts +28 -0
- package/lib/threads.test.ts +96 -0
- package/lib/threads.ts +72 -0
- package/lib/turso.ts +53 -0
- package/next.config.ts +51 -0
- package/package.json +69 -0
- package/public/brand/README.md +35 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/sw.js +41 -0
- package/scripts/backfill-attachments.mjs +96 -0
- package/scripts/backfill-content-ids.mjs +101 -0
- package/scripts/backfill-list-columns.mjs +80 -0
- package/scripts/offload-bodies.mjs +152 -0
- package/scripts/sample-files.mjs +119 -0
- package/scripts/seed-dev.mjs +240 -0
- package/tools/README.md +18 -0
- package/tools/db-backup.py +158 -0
- package/tools/doh.py +46 -0
- package/tools/import-mbox.py +703 -0
- package/tools/import-takeout.sh +49 -0
- package/tsconfig.json +35 -0
package/lib/password.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/signature.ts
ADDED
|
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
+
}
|