@novacraft-engineering/mailbox 0.4.22 → 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 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: `/api/mail/signature-logo?key=${encodeURIComponent(key)}` })
92
+ return NextResponse.json({ ok: true, url: `${publicOrigin(req)}/api/mail/signature-logo?key=${encodeURIComponent(key)}` })
92
93
  }
package/app/mail/page.tsx CHANGED
@@ -3718,6 +3718,9 @@ export default function DevMailPage() {
3718
3718
  .catch(() => {})
3719
3719
  .finally(() => {
3720
3720
  if (flags.read === true) ids.forEach(id => pendingRead.current.delete(id))
3721
+ // The starred tally counts conversations, not messages, so it cannot be derived
3722
+ // from the ids alone — the server says what it is now.
3723
+ if (flags.starred !== undefined) loadCounts(true)
3721
3724
  })
3722
3725
  },
3723
3726
  [apiHeaders],
@@ -3754,6 +3757,40 @@ export default function DevMailPage() {
3754
3757
  },
3755
3758
  [apiHeaders, setInboundFlag, threadIds, threadKeys, loadCounts],
3756
3759
  )
3760
+ /**
3761
+ * The row carries the newest message's id, but the star belongs to the conversation —
3762
+ * starring the newest message leaves an older starred one lit and the row never clears.
3763
+ */
3764
+ const toggleRowStar = useCallback(
3765
+ (id: string, threadId: string | null, starred: boolean) => {
3766
+ if (!threadId) {
3767
+ setInboundFlag(threadIds(id), { starred })
3768
+ return
3769
+ }
3770
+ setInboxEmails(list =>
3771
+ list.map(entry => (threadKeys.get(entry.id) === threadKeys.get(id) ? { ...entry, starred } : entry)),
3772
+ )
3773
+ setThreads(list =>
3774
+ list.map(thread =>
3775
+ thread.threadId === threadId
3776
+ ? { ...thread, starredCount: starred ? Math.max(1, thread.count) : 0 }
3777
+ : thread,
3778
+ ),
3779
+ )
3780
+ setServerCounts(current =>
3781
+ current ? { ...current, starred: Math.max(0, current.starred + (starred ? 1 : -1)) } : current,
3782
+ )
3783
+ fetch('/api/mail/inbox', {
3784
+ method: 'PATCH',
3785
+ headers: apiHeaders(),
3786
+ body: JSON.stringify({ threadId, starred }),
3787
+ })
3788
+ .then(() => loadCounts(true))
3789
+ .catch(() => {})
3790
+ },
3791
+ [apiHeaders, setInboundFlag, threadIds, threadKeys, loadCounts],
3792
+ )
3793
+
3757
3794
  useEffect(() => { swipeCommitRef.current = commitRowFlags }, [commitRowFlags])
3758
3795
  useEffect(() => { setInboundFlagRef.current = setInboundFlag }, [setInboundFlag])
3759
3796
 
@@ -7899,12 +7936,12 @@ export default function DevMailPage() {
7899
7936
  className={`${styles.itemStar} ${item.starred ? styles.itemStarOn : ''}`}
7900
7937
  onClick={event => {
7901
7938
  event.stopPropagation()
7902
- setInboundFlag([item.id], { starred: !item.starred })
7939
+ toggleRowStar(item.id, item.kind === 'inbound' ? item.threadId : null, !item.starred)
7903
7940
  }}
7904
7941
  onKeyDown={event => {
7905
7942
  if (event.key === 'Enter') {
7906
7943
  event.stopPropagation()
7907
- setInboundFlag([item.id], { starred: !item.starred })
7944
+ toggleRowStar(item.id, item.kind === 'inbound' ? item.threadId : null, !item.starred)
7908
7945
  }
7909
7946
  }}
7910
7947
  >
@@ -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 Cloudflare D1 (SQLite). Inbox, delivery events, account
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 tursoConfigured() ? turso() : d1()
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 tursoConfigured() ? tursoQuery(text, args) : d1Query(text, args)
75
+ return tursoQuery(text, args)
80
76
  }
81
77
 
82
78
  function sqlRaw(text: string, args: unknown[] = []): Promise<Record<string, unknown>[]> {
83
- return tursoConfigured() ? tursoQuery(text, args) : d1Query(text, args)
79
+ return tursoQuery(text, args)
84
80
  }
85
81
 
86
82
  function dbBatch(statements: string[]): Promise<void> {
87
- return tursoConfigured() ? tursoBatch(statements) : d1Batch(statements)
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. D1 has no migration runner either, but unlike the
120
- * Postgres original we own the full CREATE, so there are no incremental ALTERs to replay.
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(payload)
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 the same tagged-template shape the D1 client used
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. The D1 client could only loop, so a
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.22",
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
- }