@novacraft-engineering/mailbox 0.4.23 → 0.4.24
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 +0 -4
- package/README.md +0 -5
- package/app/api/mail/send/route.ts +2 -1
- package/app/api/mail/signature-logo/route.ts +2 -1
- package/lib/email-html.test.ts +23 -1
- package/lib/email-html.ts +16 -0
- package/lib/mailbox.ts +7 -11
- package/lib/scheduled.ts +5 -1
- package/lib/turso.ts +2 -2
- package/package.json +1 -1
- package/lib/d1.ts +0 -64
package/.env.example
CHANGED
|
@@ -44,10 +44,6 @@ MAIL_ADDRESS_ALIASES={}
|
|
|
44
44
|
# (Turso or your own sqld) and needs the token below.
|
|
45
45
|
DATABASE_URL=file:/data/mail.sqlite
|
|
46
46
|
DATABASE_AUTH_TOKEN=
|
|
47
|
-
# Optional alternative: Cloudflare D1, used only when DATABASE_URL is unset.
|
|
48
|
-
CLOUDFLARE_ACCOUNT_ID=
|
|
49
|
-
CLOUDFLARE_D1_TOKEN=
|
|
50
|
-
D1_DATABASE_ID=
|
|
51
47
|
|
|
52
48
|
# Signs the session cookie. Without it every request falls back to sending the
|
|
53
49
|
# password on each call, so set it. Rotating the value signs everybody out.
|
package/README.md
CHANGED
|
@@ -58,11 +58,6 @@ seat whose mail someone else now reads.
|
|
|
58
58
|
on a mounted volume, which is what a single box wants. A `libsql://` URL points
|
|
59
59
|
at a hosted one — Turso, or your own `sqld` — and takes `DATABASE_AUTH_TOKEN`.
|
|
60
60
|
|
|
61
|
-
If you would rather not run a database at all, leave `DATABASE_URL` unset and
|
|
62
|
-
fill in the Cloudflare D1 variables instead; the app falls back to D1 over its
|
|
63
|
-
REST API. Either way the queries are the same SQLite, so you can move between
|
|
64
|
-
them without a coordinated redeploy.
|
|
65
|
-
|
|
66
61
|
Attachments go to any S3-compatible bucket through the `S3_*` variables: AWS
|
|
67
62
|
S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi. The older `R2_*` names still
|
|
68
63
|
work if you already set them.
|
|
@@ -6,6 +6,7 @@ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
|
6
6
|
import { presign } from '@/lib/r2'
|
|
7
7
|
import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
|
|
8
8
|
import { scheduleSend } from '@/lib/scheduled'
|
|
9
|
+
import { absoluteUrls, stripOwnPixel } from '@/lib/email-html'
|
|
9
10
|
import { publicOrigin } from '@/lib/public-url'
|
|
10
11
|
|
|
11
12
|
export const runtime = 'nodejs'
|
|
@@ -145,7 +146,7 @@ export async function POST(req: Request) {
|
|
|
145
146
|
const origin = publicOrigin(req)
|
|
146
147
|
const pixelId = randomUUID()
|
|
147
148
|
const trackedHtml = body.html?.trim()
|
|
148
|
-
? `${body.html.trim()}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`
|
|
149
|
+
? `${stripOwnPixel(absoluteUrls(body.html.trim(), origin))}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`
|
|
149
150
|
: null
|
|
150
151
|
|
|
151
152
|
// Thread the reply: In-Reply-To/References point at the inbound Message-ID so the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
3
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
4
|
+
import { publicOrigin } from '@/lib/public-url'
|
|
4
5
|
import { presign } from '@/lib/r2'
|
|
5
6
|
|
|
6
7
|
export const runtime = 'nodejs'
|
|
@@ -88,5 +89,5 @@ export async function POST(req: Request) {
|
|
|
88
89
|
}).catch(() => null)
|
|
89
90
|
if (!put?.ok) return NextResponse.json({ ok: false, error: 'Could not store that image.' }, { status: 502 })
|
|
90
91
|
|
|
91
|
-
return NextResponse.json({ ok: true, url:
|
|
92
|
+
return NextResponse.json({ ok: true, url: `${publicOrigin(req)}/api/mail/signature-logo?key=${encodeURIComponent(key)}` })
|
|
92
93
|
}
|
package/lib/email-html.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import { inlineEmailStyles, htmlToPlainText, safeHref, dropUnreachableImages, outlookSafeImages, healGooglePrivateImages } from './email-html.ts'
|
|
2
|
+
import { absoluteUrls, inlineEmailStyles, htmlToPlainText, safeHref, dropUnreachableImages, outlookSafeImages, healGooglePrivateImages } from './email-html.ts'
|
|
3
3
|
|
|
4
4
|
// A paragraph with no styling of its own gets the base inline style.
|
|
5
5
|
assert.match(inlineEmailStyles('<p>Hello</p>'), /<p style="font-family:Arial[^"]*">Hello<\/p>/)
|
|
@@ -88,3 +88,25 @@ assert.equal(
|
|
|
88
88
|
healGooglePrivateImages('<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">'),
|
|
89
89
|
'<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">',
|
|
90
90
|
)
|
|
91
|
+
|
|
92
|
+
assert.equal(
|
|
93
|
+
absoluteUrls('<img src="/api/mail/signature-logo?key=signatures%2Fa.png">', 'https://mail.example.com'),
|
|
94
|
+
'<img src="https://mail.example.com/api/mail/signature-logo?key=signatures%2Fa.png">',
|
|
95
|
+
)
|
|
96
|
+
assert.equal(
|
|
97
|
+
absoluteUrls('<img src="/brand/mark-email.png">', 'https://mail.example.com/'),
|
|
98
|
+
'<img src="https://mail.example.com/brand/mark-email.png">',
|
|
99
|
+
)
|
|
100
|
+
// A forwarded sender's own relative links stay theirs: our hostname must not end up behind them.
|
|
101
|
+
assert.equal(
|
|
102
|
+
absoluteUrls('<a href="/unsubscribe">stop</a><img src="/img/header.png">', 'https://mail.example.com'),
|
|
103
|
+
'<a href="/unsubscribe">stop</a><img src="/img/header.png">',
|
|
104
|
+
)
|
|
105
|
+
assert.equal(
|
|
106
|
+
absoluteUrls('<img src="https://cdn.example.com/a.png"><img src="//cdn/b.png">', 'https://mail.example.com'),
|
|
107
|
+
'<img src="https://cdn.example.com/a.png"><img src="//cdn/b.png">',
|
|
108
|
+
)
|
|
109
|
+
assert.equal(absoluteUrls('<a href="mailto:a@b.com">a</a>', 'https://mail.example.com'), '<a href="mailto:a@b.com">a</a>')
|
|
110
|
+
// Idempotent: already-absolute addresses survive a second pass unchanged.
|
|
111
|
+
const once = absoluteUrls('<img src="/api/mail/pixel/x">', 'https://mail.example.com')
|
|
112
|
+
assert.equal(absoluteUrls(once, 'https://mail.example.com'), once)
|
package/lib/email-html.ts
CHANGED
|
@@ -135,6 +135,22 @@ export function healGooglePrivateImages(html: string, mark?: string | null): str
|
|
|
135
135
|
})
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A root-relative address resolves against our own app, and nowhere else. Sent out as-is it
|
|
140
|
+
* points a recipient's client at its own host, so the signature logo the composer showed
|
|
141
|
+
* happily arrives broken in every other mailbox.
|
|
142
|
+
*
|
|
143
|
+
* Only our own asset paths are rewritten. A forwarded message carries the original
|
|
144
|
+
* sender's markup verbatim, and their "/unsubscribe" belongs to their site, not ours —
|
|
145
|
+
* giving it our hostname would put our domain behind someone else's link.
|
|
146
|
+
*/
|
|
147
|
+
const OWN_ASSET_PATH = /\b(src|href)\s*=\s*(["'])(\/(?:api|brand)\/[^"']*)\2/gi
|
|
148
|
+
|
|
149
|
+
export function absoluteUrls(html: string, origin: string): string {
|
|
150
|
+
const root = origin.replace(/\/+$/, '')
|
|
151
|
+
return html.replace(OWN_ASSET_PATH, (_tag, attribute, quote, path) => `${attribute}=${quote}${root}${path}${quote}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
138
154
|
export function stripCidPlaceholders(text: string): string {
|
|
139
155
|
return text
|
|
140
156
|
.replace(/\[cid:[^\]\n]{0,300}\]/gi, '')
|
package/lib/mailbox.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ADDRESS_ALIASES, MAIL_SEATS, type MailRole } from './brand'
|
|
2
2
|
/**
|
|
3
|
-
* Durable mail state on
|
|
3
|
+
* Durable mail state on libSQL (SQLite). Inbox, delivery events, account
|
|
4
4
|
* credentials, reset tokens, and a per-account stash for drafts + saved templates.
|
|
5
5
|
*
|
|
6
6
|
* SQLite differences that matter here: booleans are 0/1, JSON columns are TEXT and come
|
|
@@ -8,7 +8,6 @@ import { ADDRESS_ALIASES, MAIL_SEATS, type MailRole } from './brand'
|
|
|
8
8
|
* supplied by the app rather than `now()`.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { d1, d1Batch, d1Query } from './d1'
|
|
12
11
|
import { stripCidPlaceholders } from './email-html'
|
|
13
12
|
import { hashPassword } from './password'
|
|
14
13
|
import { duration, type ParsedQuery } from '@/app/mail/search'
|
|
@@ -67,24 +66,21 @@ export type StashItem = {
|
|
|
67
66
|
const MAX_INBOX = 500
|
|
68
67
|
const MAX_EVENTS = 150
|
|
69
68
|
|
|
70
|
-
/** SQLite/libSQL once it is configured, D1 until then, so the switch needs no redeploy dance. */
|
|
71
|
-
const tursoConfigured = () => Boolean(process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL)
|
|
72
|
-
|
|
73
69
|
export function db() {
|
|
74
|
-
return
|
|
70
|
+
return turso()
|
|
75
71
|
}
|
|
76
72
|
|
|
77
73
|
/** Raw SQL with positional args, for queries whose shape is built at runtime. */
|
|
78
74
|
function tagged(_sql: unknown, text: string, args: unknown[]): Promise<Record<string, unknown>[]> {
|
|
79
|
-
return
|
|
75
|
+
return tursoQuery(text, args)
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
function sqlRaw(text: string, args: unknown[] = []): Promise<Record<string, unknown>[]> {
|
|
83
|
-
return
|
|
79
|
+
return tursoQuery(text, args)
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
function dbBatch(statements: string[]): Promise<void> {
|
|
87
|
-
return
|
|
83
|
+
return tursoBatch(statements)
|
|
88
84
|
}
|
|
89
85
|
|
|
90
86
|
function nowIso(): string {
|
|
@@ -116,8 +112,8 @@ function isoOrNull(value: unknown): string | null {
|
|
|
116
112
|
}
|
|
117
113
|
|
|
118
114
|
/**
|
|
119
|
-
* Create the schema on first use.
|
|
120
|
-
*
|
|
115
|
+
* Create the schema on first use. There is no migration runner, but unlike the Postgres
|
|
116
|
+
* original we own the full CREATE, so there are no incremental ALTERs to replay.
|
|
121
117
|
* Memoized per server instance.
|
|
122
118
|
*/
|
|
123
119
|
let schemaReady: Promise<void> | null = null
|
package/lib/scheduled.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { BRAND } from '@/lib/brand'
|
|
2
|
+
import { absoluteUrls } from '@/lib/email-html'
|
|
1
3
|
import { randomUUID } from 'node:crypto'
|
|
2
4
|
import { db, ensureMailSchema, recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
|
|
3
5
|
import { sendMail, type SendPayload } from '@/lib/mail-provider'
|
|
@@ -149,7 +151,9 @@ export async function dispatchDue(limit = 25): Promise<{ due: number; sent: numb
|
|
|
149
151
|
try {
|
|
150
152
|
// scheduledAt is deliberately dropped: the wait already happened here.
|
|
151
153
|
const { scheduledAt: _ignored, ...payload } = send.payload
|
|
152
|
-
const result = await sendMail(
|
|
154
|
+
const result = await sendMail(
|
|
155
|
+
payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload,
|
|
156
|
+
)
|
|
153
157
|
await recordSend(send, result?.id ?? null)
|
|
154
158
|
await sql`
|
|
155
159
|
UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
|
package/lib/turso.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Turso (libSQL) over HTTP, exposing
|
|
2
|
+
* Turso (libSQL) over HTTP, exposing a tagged-template shape
|
|
3
3
|
* so every call site in `mailbox.ts` reads unchanged. libSQL is SQLite, so the schema
|
|
4
4
|
* and its `ON CONFLICT` clauses port without translation.
|
|
5
5
|
*
|
|
@@ -45,7 +45,7 @@ export function turso(): SqlTag {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Run several statements as one transaction.
|
|
48
|
+
* Run several statements as one transaction. A plain loop cannot roll back, so a
|
|
49
49
|
* half-applied schema was possible; libSQL gives us the real thing.
|
|
50
50
|
*/
|
|
51
51
|
export async function tursoBatch(statements: string[]): Promise<void> {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.24",
|
|
4
4
|
"description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"webmail",
|
package/lib/d1.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cloudflare D1 over the REST API, shaped like the `neon()` tagged-template client so
|
|
3
|
-
* query call sites read the same. The app runs on Vercel, so there is no Worker binding
|
|
4
|
-
* available — every statement is an HTTPS round-trip to Cloudflare.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
type Param = string | number | null
|
|
8
|
-
export type D1Sql = (strings: TemplateStringsArray, ...values: unknown[]) => Promise<Record<string, unknown>[]>
|
|
9
|
-
|
|
10
|
-
const API = 'https://api.cloudflare.com/client/v4'
|
|
11
|
-
|
|
12
|
-
function config() {
|
|
13
|
-
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
|
14
|
-
const databaseId = process.env.D1_DATABASE_ID
|
|
15
|
-
const token = process.env.CLOUDFLARE_D1_TOKEN
|
|
16
|
-
if (!accountId || !databaseId || !token) {
|
|
17
|
-
throw new Error('CLOUDFLARE_ACCOUNT_ID, D1_DATABASE_ID and CLOUDFLARE_D1_TOKEN must be configured')
|
|
18
|
-
}
|
|
19
|
-
return { accountId, databaseId, token }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** SQLite has no boolean or object types — normalise before binding. */
|
|
23
|
-
function toParam(value: unknown): Param {
|
|
24
|
-
if (value === undefined || value === null) return null
|
|
25
|
-
if (typeof value === 'boolean') return value ? 1 : 0
|
|
26
|
-
if (typeof value === 'number') return value
|
|
27
|
-
if (value instanceof Date) return value.toISOString()
|
|
28
|
-
if (typeof value === 'object') return JSON.stringify(value)
|
|
29
|
-
return String(value)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function d1Query(sql: string, params: unknown[] = []): Promise<Record<string, unknown>[]> {
|
|
33
|
-
const { accountId, databaseId, token } = config()
|
|
34
|
-
const response = await fetch(`${API}/accounts/${accountId}/d1/database/${databaseId}/query`, {
|
|
35
|
-
method: 'POST',
|
|
36
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
37
|
-
body: JSON.stringify({ sql, params: params.map(toParam) }),
|
|
38
|
-
})
|
|
39
|
-
const payload = (await response.json()) as {
|
|
40
|
-
success?: boolean
|
|
41
|
-
errors?: Array<{ message?: string }>
|
|
42
|
-
result?: Array<{ results?: Record<string, unknown>[]; success?: boolean; error?: string }>
|
|
43
|
-
}
|
|
44
|
-
if (!response.ok || !payload.success) {
|
|
45
|
-
const detail = payload.errors?.map(entry => entry.message).filter(Boolean).join('; ') || `HTTP ${response.status}`
|
|
46
|
-
throw new Error(`D1 query failed: ${detail}`)
|
|
47
|
-
}
|
|
48
|
-
const first = payload.result?.[0]
|
|
49
|
-
if (first?.error) throw new Error(`D1 query failed: ${first.error}`)
|
|
50
|
-
return first?.results ?? []
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Tagged-template entry point: interpolations become bound `?` parameters. */
|
|
54
|
-
export function d1(): D1Sql {
|
|
55
|
-
return (strings, ...values) => d1Query(strings.raw.join('?'), values)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Run several statements in order — used for schema setup. */
|
|
59
|
-
export async function d1Batch(statements: string[]): Promise<void> {
|
|
60
|
-
for (const statement of statements) {
|
|
61
|
-
const trimmed = statement.trim()
|
|
62
|
-
if (trimmed) await d1Query(trimmed)
|
|
63
|
-
}
|
|
64
|
-
}
|