@novacraft-engineering/mailbox 0.4.2 → 0.4.4
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 +16 -10
- package/README.md +52 -47
- package/app/mail/page.tsx +27 -9
- package/lib/email-html.test.ts +17 -1
- package/lib/email-html.ts +23 -0
- package/lib/mailbox.ts +2 -2
- package/lib/r2.ts +6 -6
- package/lib/turso.ts +3 -3
- package/package.json +1 -1
- package/public/brand/README.md +0 -4
- package/scripts/backfill-content-ids.mjs +1 -1
- package/scripts/backfill-list-columns.mjs +2 -2
- package/scripts/offload-bodies.mjs +2 -2
- package/scripts/seed-dev.mjs +3 -3
- package/tools/README.md +0 -4
- package/tools/db-backup.py +2 -2
- package/tools/import-mbox.py +4 -4
package/.env.example
CHANGED
|
@@ -39,10 +39,12 @@ MAIL_SEATS=[{"email":"info@example.com","address":"info@example.com","name":"Exa
|
|
|
39
39
|
MAIL_ADDRESS_ALIASES={}
|
|
40
40
|
|
|
41
41
|
# ─── Store ──────────────────────────────────────────────────────────────────
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
# Any SQLite/libSQL database. A file: URL is local SQLite on a mounted volume
|
|
43
|
+
# (simplest, and what a single box wants); a libsql:// URL is a hosted one
|
|
44
|
+
# (Turso or your own sqld) and needs the token below.
|
|
45
|
+
DATABASE_URL=file:/data/mail.sqlite
|
|
46
|
+
DATABASE_AUTH_TOKEN=
|
|
47
|
+
# Optional alternative: Cloudflare D1, used only when DATABASE_URL is unset.
|
|
46
48
|
CLOUDFLARE_ACCOUNT_ID=
|
|
47
49
|
CLOUDFLARE_D1_TOKEN=
|
|
48
50
|
D1_DATABASE_ID=
|
|
@@ -52,7 +54,7 @@ D1_DATABASE_ID=
|
|
|
52
54
|
MAIL_SESSION_SECRET=
|
|
53
55
|
|
|
54
56
|
# ─── Sending ────────────────────────────────────────────────────────────────
|
|
55
|
-
# ses | resend | brevo
|
|
57
|
+
# Pick one: ses | resend | brevo. Fill in only that provider's keys.
|
|
56
58
|
MAIL_PROVIDER=ses
|
|
57
59
|
SES_ACCESS_KEY_ID=
|
|
58
60
|
SES_SECRET_ACCESS_KEY=
|
|
@@ -64,11 +66,15 @@ RESEND_WEBHOOK_SECRET=
|
|
|
64
66
|
# nothing — pointing this at an address this app receives would loop.
|
|
65
67
|
MAIL_FORWARD_TO=
|
|
66
68
|
|
|
67
|
-
# ─── Attachments
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
# ─── Attachments ────────────────────────────────────────────────────────────
|
|
70
|
+
# Any S3-compatible bucket: AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi.
|
|
71
|
+
S3_ENDPOINT=
|
|
72
|
+
S3_BUCKET=
|
|
73
|
+
S3_ACCESS_KEY_ID=
|
|
74
|
+
S3_SECRET_ACCESS_KEY=
|
|
75
|
+
# Region: real one for AWS, anything for R2 (defaults to "auto").
|
|
76
|
+
S3_REGION=
|
|
77
|
+
# Only if you import an mbox with --store blob (Vercel Blob).
|
|
72
78
|
BLOB_READ_WRITE_TOKEN=
|
|
73
79
|
|
|
74
80
|
# ─── Optional extras ────────────────────────────────────────────────────────
|
package/README.md
CHANGED
|
@@ -1,80 +1,85 @@
|
|
|
1
|
-
<img src=".github/banner.png" alt="
|
|
1
|
+
<img src=".github/banner.png" alt="Mailbox" width="100%">
|
|
2
2
|
|
|
3
3
|
# Mailbox
|
|
4
4
|
|
|
5
|
-
A shared webmail app
|
|
6
|
-
threads, a
|
|
7
|
-
S3-compatible storage, sharing links, full-text search, web
|
|
8
|
-
|
|
5
|
+
A shared webmail app you host yourself. One codebase, one deployment per
|
|
6
|
+
mailbox. Inbox and threads, a composer with signatures and templates,
|
|
7
|
+
attachments on S3-compatible storage, sharing links, full-text search, web
|
|
8
|
+
push, PWA install, accounts with roles, and password reset.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Everything specific to you — name, domain, colours, signature copy, artwork —
|
|
11
11
|
lives in configuration. Nothing in this repository names or pictures any one
|
|
12
12
|
business, and nothing added to it should.
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## Running your own
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
1. Copy `.env.example` and fill it in.
|
|
17
|
+
2. Put three images on the volume at `BRAND_ASSET_DIR`. See
|
|
18
|
+
[public/brand/README.md](public/brand/README.md) for the sizes.
|
|
19
|
+
3. Point `DATABASE_URL` at a SQLite file on a mounted volume.
|
|
20
|
+
4. Deploy it anywhere that runs Next.js. The schema is created on first boot
|
|
21
|
+
and migrates itself forward, so there is no migration step to run.
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
**Novacraft** mailbox. The second tenant is deliberately not wired: it shares this
|
|
24
|
-
repository and branch, but Coolify checks the webhook signature against each
|
|
25
|
-
application's own secret, so it is skipped and still needs a manual deploy from Coolify.
|
|
26
|
-
Finish the work, commit locally, leave it unpushed, and say plainly that it is
|
|
27
|
-
waiting.
|
|
28
|
-
|
|
29
|
-
## Standing up a new mailbox
|
|
30
|
-
|
|
31
|
-
1. Copy `.env.example` and fill it in. Nothing is hardcoded to a tenant.
|
|
32
|
-
2. Put three images on the volume at `BRAND_ASSET_DIR` — see `public/brand/README.md`.
|
|
33
|
-
3. Point `TURSO_DATABASE_URL` at a SQLite file on a mounted volume.
|
|
34
|
-
4. Deploy. The schema is created on first boot and migrates itself forward.
|
|
23
|
+
It is a plain Next.js app, so Vercel, Coolify, Docker on a VPS, Fly, Render and
|
|
24
|
+
Railway all work. Nothing in the code assumes a particular host.
|
|
35
25
|
|
|
36
26
|
## Trying it locally
|
|
37
27
|
|
|
38
28
|
Outside production the app seeds one account for you — `test@<MAIL_ADDRESS_DOMAIN>`,
|
|
39
|
-
an admin whose password is the address itself — so a fresh checkout can be
|
|
40
|
-
into without configuring seats. It is never seeded when
|
|
29
|
+
an admin whose password is the address itself — so a fresh checkout can be
|
|
30
|
+
signed into without configuring seats. It is never seeded when
|
|
31
|
+
`NODE_ENV=production`.
|
|
41
32
|
|
|
42
|
-
`node --experimental-strip-types scripts/seed-dev.mjs` then fills that mailbox
|
|
43
|
-
enough traffic to exercise the list, search, paging and attachments.
|
|
44
|
-
first by starting the app once, or by calling
|
|
33
|
+
`node --experimental-strip-types scripts/seed-dev.mjs` then fills that mailbox
|
|
34
|
+
with enough traffic to exercise the list, search, paging and attachments.
|
|
35
|
+
Create the schema first by starting the app once, or by calling
|
|
36
|
+
`ensureMailSchema()`.
|
|
45
37
|
|
|
46
38
|
The attachments are real files, not records: a PDF, a photo, a short video, a
|
|
47
|
-
spreadsheet and a Word document, generated on the spot and uploaded once.
|
|
48
|
-
video need `ffmpeg` and the document needs `zip`; whatever is
|
|
49
|
-
no bucket configured the seed still runs and writes
|
|
50
|
-
|
|
39
|
+
spreadsheet and a Word document, generated on the spot and uploaded once.
|
|
40
|
+
Pictures and video need `ffmpeg` and the document needs `zip`; whatever is
|
|
41
|
+
missing is skipped. With no bucket configured the seed still runs and writes
|
|
42
|
+
the records alone.
|
|
51
43
|
|
|
52
44
|
## Configuration
|
|
53
45
|
|
|
54
|
-
`lib/brand.ts` is the
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
`lib/brand.ts` is the server-side source of identity — name, domain, signature
|
|
47
|
+
copy, colours, seats, aliases. `lib/brand.client.ts` is the browser half, read
|
|
48
|
+
from `NEXT_PUBLIC_*` and inlined at build time, so changing anything the
|
|
49
|
+
browser shows means a rebuild.
|
|
58
50
|
|
|
59
|
-
Seats are JSON in `MAIL_SEATS` and seed on first boot only;
|
|
60
|
-
|
|
61
|
-
whose mail someone else now reads.
|
|
51
|
+
Seats are JSON in `MAIL_SEATS` and seed on first boot only; after that you
|
|
52
|
+
manage accounts in the app. `MAIL_ADDRESS_ALIASES` redirects delivery for a
|
|
53
|
+
seat whose mail someone else now reads.
|
|
62
54
|
|
|
63
55
|
## Storage
|
|
64
56
|
|
|
65
|
-
`
|
|
66
|
-
|
|
67
|
-
|
|
57
|
+
`DATABASE_URL` is any SQLite or libSQL database. A `file:` URL is local SQLite
|
|
58
|
+
on a mounted volume, which is what a single box wants. A `libsql://` URL points
|
|
59
|
+
at a hosted one — Turso, or your own `sqld` — and takes `DATABASE_AUTH_TOKEN`.
|
|
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.
|
|
68
65
|
|
|
69
|
-
Attachments go to any S3-compatible bucket
|
|
66
|
+
Attachments go to any S3-compatible bucket through the `S3_*` variables: AWS
|
|
67
|
+
S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi. The older `R2_*` names still
|
|
68
|
+
work if you already set them.
|
|
70
69
|
|
|
71
70
|
## Sending
|
|
72
71
|
|
|
73
|
-
`MAIL_PROVIDER`
|
|
74
|
-
|
|
72
|
+
`MAIL_PROVIDER` picks `ses`, `resend` or `brevo` behind one seam, so switching
|
|
73
|
+
providers is an environment change rather than a code change. SES is signed
|
|
74
|
+
in-process, with no SDK. Adding a fourth provider means one send function and one case in
|
|
75
|
+
`lib/mail-provider.ts`.
|
|
75
76
|
|
|
76
77
|
## Tests
|
|
77
78
|
|
|
78
79
|
```
|
|
79
80
|
npx tsx --test lib/*.test.ts
|
|
80
81
|
```
|
|
82
|
+
|
|
83
|
+
## Licence
|
|
84
|
+
|
|
85
|
+
MIT. See [LICENSE](LICENSE).
|
package/app/mail/page.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { parseQuery, matchesQuery, serverSearchParams, splitForServer } from './
|
|
|
8
8
|
import { useConfirm } from './ConfirmDialog'
|
|
9
9
|
import { subscribePush, unsubscribePush, useInstall, useNotifications } from './pwa'
|
|
10
10
|
import RichEditor from './RichEditor'
|
|
11
|
-
import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel } from '@/lib/email-html'
|
|
11
|
+
import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel, healGooglePrivateImages } from '@/lib/email-html'
|
|
12
12
|
import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
|
|
13
13
|
import MailSelect, { GLYPH } from './MailSelect'
|
|
14
14
|
|
|
@@ -923,6 +923,14 @@ function pastedMarkdown(event: React.ClipboardEvent): string | null {
|
|
|
923
923
|
*/
|
|
924
924
|
const OWN_IMAGE_HOSTS = `${CLIENT_BRAND.websiteUrl} ${CLIENT_BRAND.publicUrl}`
|
|
925
925
|
|
|
926
|
+
/** Mail one of our own addresses sent, so a signature logo in it was ours to restore. */
|
|
927
|
+
function sentByUs(address: string): boolean {
|
|
928
|
+
const email = (address.match(/<([^>]+)>/)?.[1] ?? address).trim().toLowerCase()
|
|
929
|
+
if (!email.includes('@')) return false
|
|
930
|
+
const domain = email.slice(email.lastIndexOf('@') + 1)
|
|
931
|
+
return MAIL_ADDRESSES.includes(email) || LOGIN_DOMAINS.includes(domain)
|
|
932
|
+
}
|
|
933
|
+
|
|
926
934
|
// When allowRemote is false the CSP blocks remote fetches so tracking pixels never load.
|
|
927
935
|
function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING, dark = false): string {
|
|
928
936
|
const csp = allowRemote
|
|
@@ -1668,6 +1676,8 @@ export default function DevMailPage() {
|
|
|
1668
1676
|
setThreadsResolved(false)
|
|
1669
1677
|
threadsFetch.current = null
|
|
1670
1678
|
threadsLoadedFolder.current = null
|
|
1679
|
+
setInboxEmails([])
|
|
1680
|
+
setServerCounts(null)
|
|
1671
1681
|
}, [mailboxQuery])
|
|
1672
1682
|
const loadThreads = useCallback(async () => {
|
|
1673
1683
|
if (!isLoggedIn) return
|
|
@@ -2143,13 +2153,18 @@ export default function DevMailPage() {
|
|
|
2143
2153
|
* is loaded describes the page, not the mailbox, and grows as you scroll.
|
|
2144
2154
|
*/
|
|
2145
2155
|
const lastCountAt = useRef(0)
|
|
2156
|
+
// Keyed on the mailbox as well as the clock: the throttle is there to absorb a burst of
|
|
2157
|
+
// actions on one mailbox, and must never swallow the recount that a switch requires.
|
|
2158
|
+
const lastCountKey = useRef<string | null>(null)
|
|
2146
2159
|
const loadCounts = useCallback(async (force = false) => {
|
|
2147
2160
|
// Counting reads the whole mailbox — about seventy thousand rows here — so it runs when
|
|
2148
2161
|
// the figures can actually have changed, not on the periodic refresh. A minute's
|
|
2149
2162
|
// throttle keeps a burst of actions from re-counting once each.
|
|
2150
|
-
|
|
2151
|
-
if (!force &&
|
|
2163
|
+
const sameMailbox = lastCountKey.current === mailboxQuery
|
|
2164
|
+
if (!force && sameMailbox && Date.now() - lastCountAt.current < 300000) return
|
|
2165
|
+
if (!force && sameMailbox && document.visibilityState !== 'visible') return
|
|
2152
2166
|
lastCountAt.current = Date.now()
|
|
2167
|
+
lastCountKey.current = mailboxQuery
|
|
2153
2168
|
setCountsLoading(true)
|
|
2154
2169
|
try {
|
|
2155
2170
|
const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
|
|
@@ -2204,7 +2219,7 @@ export default function DevMailPage() {
|
|
|
2204
2219
|
useEffect(() => {
|
|
2205
2220
|
if (!isLoggedIn) return
|
|
2206
2221
|
try {
|
|
2207
|
-
const cached = localStorage.getItem(`${LS_INBOX_CACHE_KEY}:${email}`)
|
|
2222
|
+
const cached = localStorage.getItem(`${LS_INBOX_CACHE_KEY}:${email}${mailboxQuery}`)
|
|
2208
2223
|
if (!cached) return
|
|
2209
2224
|
const saved = JSON.parse(cached) as { at?: number; rows?: InboundEmail[]; threads?: ConversationRow[]; folder?: string; counts?: typeof serverCounts }
|
|
2210
2225
|
if (saved?.counts) setServerCounts(current => current ?? saved.counts!)
|
|
@@ -2221,13 +2236,13 @@ export default function DevMailPage() {
|
|
|
2221
2236
|
// A cache that will not parse is not worth a broken mailbox.
|
|
2222
2237
|
}
|
|
2223
2238
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2224
|
-
}, [isLoggedIn, email])
|
|
2239
|
+
}, [isLoggedIn, email, mailboxQuery])
|
|
2225
2240
|
|
|
2226
2241
|
useEffect(() => {
|
|
2227
2242
|
if (!isLoggedIn || mailboxStale || inboxEmails.length === 0) return
|
|
2228
2243
|
try {
|
|
2229
2244
|
localStorage.setItem(
|
|
2230
|
-
`${LS_INBOX_CACHE_KEY}:${email}`,
|
|
2245
|
+
`${LS_INBOX_CACHE_KEY}:${email}${mailboxQuery}`,
|
|
2231
2246
|
JSON.stringify({
|
|
2232
2247
|
at: Date.now(),
|
|
2233
2248
|
folder: threadFolder,
|
|
@@ -2239,7 +2254,7 @@ export default function DevMailPage() {
|
|
|
2239
2254
|
} catch {
|
|
2240
2255
|
// Storage full or blocked: the cache is an optimisation, never a requirement.
|
|
2241
2256
|
}
|
|
2242
|
-
}, [inboxEmails, threads, threadFolder, isLoggedIn, mailboxStale, email, serverCounts])
|
|
2257
|
+
}, [inboxEmails, threads, threadFolder, isLoggedIn, mailboxStale, email, mailboxQuery, serverCounts])
|
|
2243
2258
|
|
|
2244
2259
|
const typedOnce = useRef(false)
|
|
2245
2260
|
useEffect(() => {
|
|
@@ -5574,7 +5589,10 @@ export default function DevMailPage() {
|
|
|
5574
5589
|
}
|
|
5575
5590
|
const split = splitQuotedTail(message.html)
|
|
5576
5591
|
const quoteShown = quoteOpen.has(message.id)
|
|
5577
|
-
const bodyHtml =
|
|
5592
|
+
const bodyHtml = healGooglePrivateImages(
|
|
5593
|
+
split.tail && !quoteShown ? split.head : message.html,
|
|
5594
|
+
sentByUs(message.from) ? CLIENT_BRAND.markUrl : null,
|
|
5595
|
+
)
|
|
5578
5596
|
if (framed) {
|
|
5579
5597
|
// Thread messages auto-size to content (whole conversation scrolls as one).
|
|
5580
5598
|
// sandbox omits allow-scripts, so no email JS runs; CSP still blocks fetches.
|
|
@@ -5617,7 +5635,7 @@ export default function DevMailPage() {
|
|
|
5617
5635
|
<iframe
|
|
5618
5636
|
className={styles.threadFrame}
|
|
5619
5637
|
sandbox="allow-same-origin"
|
|
5620
|
-
srcDoc={frameHtml(detail.html, true, readerSpacing, resolvedTheme === 'dark')}
|
|
5638
|
+
srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme === 'dark')}
|
|
5621
5639
|
title="Sent email"
|
|
5622
5640
|
onLoad={event => {
|
|
5623
5641
|
try {
|
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 } from './email-html.ts'
|
|
2
|
+
import { 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>/)
|
|
@@ -72,3 +72,19 @@ assert.ok(!centred.includes('data-align'), 'the editor marker is stripped')
|
|
|
72
72
|
// A linked logo keeps its link, inside the cell rather than around the table.
|
|
73
73
|
const linked = outlookSafeImages('<a href="https://x.test"><img src="https://x.test/m.png" style="display:block;width:100px;border:1px solid #000;"></a>')
|
|
74
74
|
assert.match(linked, /<td[^>]*><a href="https:\/\/x\.test"><img/, 'the anchor sits inside the cell')
|
|
75
|
+
|
|
76
|
+
// A Gmail-hosted signature logo cannot load outside that mailbox: drop it, or put ours back.
|
|
77
|
+
assert.equal(
|
|
78
|
+
healGooglePrivateImages('<p>Hi</p><img src="https://ci3.googleusercontent.com/mail-sig/ABC"><p>Regards</p>'),
|
|
79
|
+
'<p>Hi</p><p>Regards</p>',
|
|
80
|
+
)
|
|
81
|
+
assert.equal(
|
|
82
|
+
healGooglePrivateImages('<img src="https://ci3.googleusercontent.com/mail-sig/ABC" alt="logo">', 'https://mail.example.com/brand/mark.png'),
|
|
83
|
+
'<img src="https://mail.example.com/brand/mark.png" alt="logo">',
|
|
84
|
+
)
|
|
85
|
+
// Entity-encoded query strings still match, and everything else is left alone.
|
|
86
|
+
assert.equal(healGooglePrivateImages('<img src="https://mail.google.com/mail/u/0?ui=2&ik=x">'), '')
|
|
87
|
+
assert.equal(
|
|
88
|
+
healGooglePrivateImages('<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">'),
|
|
89
|
+
'<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">',
|
|
90
|
+
)
|
package/lib/email-html.ts
CHANGED
|
@@ -112,6 +112,29 @@ export function dropUnreachableImages(html: string): string {
|
|
|
112
112
|
})
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Addresses Gmail serves only to the account that owns them.
|
|
117
|
+
*
|
|
118
|
+
* A signature logo written by Gmail lives on Google's own CDN, and Takeout exports the
|
|
119
|
+
* link but never the bytes. Outside that mailbox the address answers 400, so the picture
|
|
120
|
+
* has been broken since the day the mail was exported and can never load again.
|
|
121
|
+
*/
|
|
122
|
+
const GOOGLE_PRIVATE_IMAGE = /^https?:\/\/(?:ci\d*\.googleusercontent\.com\/mail-sig\/|mail\.google\.com\/mail\/|docs\.google\.com\/uc\?)/i
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Removes those pictures so the signature reads as text rather than a broken icon.
|
|
126
|
+
* When `mark` is given — only for mail one of our own addresses sent, where we know whose
|
|
127
|
+
* logo it was — the tenant's own mark is put back in its place instead.
|
|
128
|
+
*/
|
|
129
|
+
export function healGooglePrivateImages(html: string, mark?: string | null): string {
|
|
130
|
+
return html.replace(/<img\b[^>]*>/gi, tag => {
|
|
131
|
+
const src = /\bsrc\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1]?.trim() ?? ''
|
|
132
|
+
if (!GOOGLE_PRIVATE_IMAGE.test(src)) return tag
|
|
133
|
+
if (!mark) return ''
|
|
134
|
+
return tag.replace(/\bsrc\s*=\s*["'][^"']*["']/i, `src="${mark}"`)
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
export function stripCidPlaceholders(text: string): string {
|
|
116
139
|
return text
|
|
117
140
|
.replace(/\[cid:[^\]\n]{0,300}\]/gi, '')
|
package/lib/mailbox.ts
CHANGED
|
@@ -60,8 +60,8 @@ export type StashItem = {
|
|
|
60
60
|
const MAX_INBOX = 500
|
|
61
61
|
const MAX_EVENTS = 150
|
|
62
62
|
|
|
63
|
-
/**
|
|
64
|
-
const tursoConfigured = () => Boolean(process.env.TURSO_DATABASE_URL)
|
|
63
|
+
/** SQLite/libSQL once it is configured, D1 until then, so the switch needs no redeploy dance. */
|
|
64
|
+
const tursoConfigured = () => Boolean(process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL)
|
|
65
65
|
|
|
66
66
|
function db() {
|
|
67
67
|
return tursoConfigured() ? turso() : d1()
|
package/lib/r2.ts
CHANGED
|
@@ -15,17 +15,17 @@ import { createHash, createHmac } from 'node:crypto'
|
|
|
15
15
|
|
|
16
16
|
const SERVICE = 's3'
|
|
17
17
|
// R2 ignores the region but SigV4 requires one in the scope; AWS S3 needs the real one.
|
|
18
|
-
const REGION = process.env.
|
|
18
|
+
const REGION = process.env.S3_REGION ?? process.env.R2_REGION ?? 'auto'
|
|
19
19
|
|
|
20
20
|
type Config = { endpoint: string; bucket: string; accessKeyId: string; secretAccessKey: string }
|
|
21
21
|
|
|
22
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
|
|
23
|
+
const endpoint = process.env.S3_ENDPOINT ?? process.env.R2_S3_ENDPOINT
|
|
24
|
+
const bucket = process.env.S3_BUCKET ?? process.env.R2_BUCKET
|
|
25
|
+
const accessKeyId = process.env.S3_ACCESS_KEY_ID ?? process.env.R2_ACCESS_KEY_ID
|
|
26
|
+
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY ?? process.env.R2_SECRET_ACCESS_KEY
|
|
27
27
|
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
|
28
|
-
throw new Error('
|
|
28
|
+
throw new Error('S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY must be configured')
|
|
29
29
|
}
|
|
30
30
|
return { endpoint: endpoint.replace(/\/+$/, ''), bucket, accessKeyId, secretAccessKey }
|
|
31
31
|
}
|
package/lib/turso.ts
CHANGED
|
@@ -15,9 +15,9 @@ let cached: Client | null = null
|
|
|
15
15
|
|
|
16
16
|
function client(): Client {
|
|
17
17
|
if (cached) return cached
|
|
18
|
-
const url = process.env.TURSO_DATABASE_URL
|
|
19
|
-
const authToken = process.env.TURSO_AUTH_TOKEN
|
|
20
|
-
if (!url) throw new Error('
|
|
18
|
+
const url = process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL
|
|
19
|
+
const authToken = process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN
|
|
20
|
+
if (!url) throw new Error('DATABASE_URL must be configured')
|
|
21
21
|
// Embedded replicas and file: URLs need no token; a remote libsql:// URL does.
|
|
22
22
|
cached = createClient({ url, ...(authToken ? { authToken } : {}) })
|
|
23
23
|
return cached
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
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/public/brand/README.md
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# Brand assets
|
|
2
2
|
|
|
3
|
-
> **Never deploy or `git push` unless asked for it in that request.** Pushing
|
|
4
|
-
> `master` auto-deploys the Novacraft mailbox. See the deploying section in the
|
|
5
|
-
> root [README](../README.md).
|
|
6
|
-
|
|
7
3
|
**No tenant's artwork belongs in this repository.** Each mailbox keeps its own
|
|
8
4
|
images on its mounted volume, in the directory named by `BRAND_ASSET_DIR`
|
|
9
5
|
(default `/data/brand`), and the app serves them from `/brand/<file>`.
|
|
@@ -20,7 +20,7 @@ const args = process.argv.slice(2)
|
|
|
20
20
|
const at = args.indexOf('--batch')
|
|
21
21
|
const BATCH = at === -1 ? 40 : Number(args[at + 1])
|
|
22
22
|
|
|
23
|
-
const db = createClient({ url: process.env.TURSO_DATABASE_URL, authToken: process.env.TURSO_AUTH_TOKEN })
|
|
23
|
+
const db = createClient({ url: process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL, authToken: process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN })
|
|
24
24
|
const apiKey = process.env.RESEND_API_KEY
|
|
25
25
|
|
|
26
26
|
async function retry(work) {
|
|
@@ -24,8 +24,8 @@ const at = args.indexOf('--batch')
|
|
|
24
24
|
const BATCH = at === -1 ? 500 : Number(args[at + 1])
|
|
25
25
|
|
|
26
26
|
const db = createClient({
|
|
27
|
-
url: process.env.TURSO_DATABASE_URL,
|
|
28
|
-
authToken: process.env.TURSO_AUTH_TOKEN,
|
|
27
|
+
url: process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL,
|
|
28
|
+
authToken: process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN,
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
const UPDATE = `UPDATE mail_inbox SET
|
|
@@ -24,8 +24,8 @@ const at = args.indexOf('--batch')
|
|
|
24
24
|
const BATCH = at === -1 ? 40 : Number(args[at + 1])
|
|
25
25
|
|
|
26
26
|
const db = createClient({
|
|
27
|
-
url: process.env.TURSO_DATABASE_URL,
|
|
28
|
-
authToken: process.env.TURSO_AUTH_TOKEN,
|
|
27
|
+
url: process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL,
|
|
28
|
+
authToken: process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN,
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
/** Ids are ours, but these go into string-interpolated SQL: anything unexpected is left alone. */
|
package/scripts/seed-dev.mjs
CHANGED
|
@@ -20,8 +20,8 @@ for (const line of readFileSync(new URL('../.env.local', import.meta.url), 'utf8
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const db = createClient({
|
|
23
|
-
url: process.env.TURSO_DATABASE_URL,
|
|
24
|
-
authToken: process.env.TURSO_AUTH_TOKEN,
|
|
23
|
+
url: process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL,
|
|
24
|
+
authToken: process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN,
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
const DEV_ADDRESS = 'test@example.com'
|
|
@@ -103,7 +103,7 @@ async function main() {
|
|
|
103
103
|
// so opening an attachment in the seeded mailbox opens something. Without a bucket
|
|
104
104
|
// configured the records are written on their own, as they always were.
|
|
105
105
|
const { buildSamples, buildHeavySamples } = await import('./sample-files.mjs')
|
|
106
|
-
const bucketReady = Boolean(process.env.R2_BUCKET && process.env.R2_ACCESS_KEY_ID && process.env.R2_S3_ENDPOINT)
|
|
106
|
+
const bucketReady = Boolean((process.env.S3_BUCKET ?? process.env.R2_BUCKET) && (process.env.S3_ACCESS_KEY_ID ?? process.env.R2_ACCESS_KEY_ID) && (process.env.S3_ENDPOINT ?? process.env.R2_S3_ENDPOINT))
|
|
107
107
|
let smallFiles = []
|
|
108
108
|
let heavyFiles = []
|
|
109
109
|
if (bucketReady) {
|
package/tools/README.md
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# Tools
|
|
2
2
|
|
|
3
|
-
> **Never deploy or `git push` unless asked for it in that request.** Pushing
|
|
4
|
-
> `master` auto-deploys the Novacraft mailbox. See the deploying section in the
|
|
5
|
-
> root [README](../README.md).
|
|
6
|
-
|
|
7
3
|
| File | What it does |
|
|
8
4
|
|---|---|
|
|
9
5
|
| `import-mbox.py` | Import an mbox into a mailbox. Bodies and metadata first, attachments second. |
|
package/tools/db-backup.py
CHANGED
|
@@ -33,7 +33,7 @@ def load_env(path):
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def query(sql, args=None, attempts=9):
|
|
36
|
-
host = os.environ['TURSO_DATABASE_URL'].replace('libsql://', 'https://').rstrip('/')
|
|
36
|
+
host = (os.environ.get('DATABASE_URL') or os.environ['TURSO_DATABASE_URL']).replace('libsql://', 'https://').rstrip('/')
|
|
37
37
|
stmt = {'sql': sql}
|
|
38
38
|
if args:
|
|
39
39
|
stmt['args'] = [{'type': 'text', 'value': str(a)} for a in args]
|
|
@@ -42,7 +42,7 @@ def query(sql, args=None, attempts=9):
|
|
|
42
42
|
for attempt in range(attempts):
|
|
43
43
|
try:
|
|
44
44
|
request = urllib.request.Request(host + '/v2/pipeline', body, {
|
|
45
|
-
'Authorization': 'Bearer ' + os.environ['TURSO_AUTH_TOKEN'],
|
|
45
|
+
'Authorization': 'Bearer ' + (os.environ.get('DATABASE_AUTH_TOKEN') or os.environ['TURSO_AUTH_TOKEN']),
|
|
46
46
|
'Content-Type': 'application/json'})
|
|
47
47
|
with urllib.request.urlopen(request, timeout=180) as response:
|
|
48
48
|
payload = json.load(response)
|
package/tools/import-mbox.py
CHANGED
|
@@ -260,8 +260,8 @@ class S3Store:
|
|
|
260
260
|
self.endpoint = (env('S3_ENDPOINT') or env('R2_S3_ENDPOINT') or '').rstrip('/')
|
|
261
261
|
self.bucket = env('S3_BUCKET') or env('R2_BUCKET') or ''
|
|
262
262
|
self.region = env('S3_REGION') or env('R2_REGION') or 'auto'
|
|
263
|
-
self.access_key = env('AWS_ACCESS_KEY_ID') or env('R2_ACCESS_KEY_ID') or ''
|
|
264
|
-
self.secret_key = env('AWS_SECRET_ACCESS_KEY') or env('R2_SECRET_ACCESS_KEY') or ''
|
|
263
|
+
self.access_key = env('S3_ACCESS_KEY_ID') or env('AWS_ACCESS_KEY_ID') or env('R2_ACCESS_KEY_ID') or ''
|
|
264
|
+
self.secret_key = env('S3_SECRET_ACCESS_KEY') or env('AWS_SECRET_ACCESS_KEY') or env('R2_SECRET_ACCESS_KEY') or ''
|
|
265
265
|
|
|
266
266
|
def configured(self):
|
|
267
267
|
return all([self.endpoint, self.bucket, self.access_key, self.secret_key])
|
|
@@ -471,10 +471,10 @@ def main():
|
|
|
471
471
|
if args.max_mbps:
|
|
472
472
|
UPLOAD_BUDGET.__init__(args.max_mbps)
|
|
473
473
|
print(f'upload cap: {args.max_mbps} Mbit/s', flush=True)
|
|
474
|
-
url, db_token = os.environ.get('TURSO_DATABASE_URL'), os.environ.get('TURSO_AUTH_TOKEN')
|
|
474
|
+
url, db_token = (os.environ.get('DATABASE_URL') or os.environ.get('TURSO_DATABASE_URL')), (os.environ.get('DATABASE_AUTH_TOKEN') or os.environ.get('TURSO_AUTH_TOKEN'))
|
|
475
475
|
blob_token = None if args.skip_attachments else (os.environ.get('BLOB_READ_WRITE_TOKEN') if STORE == 'blob' else 's3')
|
|
476
476
|
if not args.dry_run and not (url and (db_token or url.startswith('file:'))):
|
|
477
|
-
sys.exit('
|
|
477
|
+
sys.exit('DATABASE_URL and DATABASE_AUTH_TOKEN must be set')
|
|
478
478
|
kinds = {kind.strip() for kind in args.kinds.split(',') if kind.strip()}
|
|
479
479
|
if not args.dry_run and not args.skip_attachments and not blob_token:
|
|
480
480
|
print('BLOB_READ_WRITE_TOKEN unset: attachments will be metadata only', flush=True)
|