@novacraft-engineering/mailbox 0.4.3 → 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 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
- # Local SQLite on a mounted volume (recommended):
43
- TURSO_DATABASE_URL=file:/data/mail.sqlite
44
- TURSO_AUTH_TOKEN=
45
- # Falls back to Cloudflare D1 when TURSO_DATABASE_URL is unset.
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 (S3-compatible, e.g. Cloudflare R2) ────────────────────────
68
- R2_S3_ENDPOINT=
69
- R2_BUCKET=
70
- R2_ACCESS_KEY_ID=
71
- R2_SECRET_ACCESS_KEY=
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="mailbox by Novacraft" width="100%">
1
+ <img src=".github/banner.png" alt="Mailbox" width="100%">
2
2
 
3
3
  # Mailbox
4
4
 
5
- A shared webmail app: one codebase, one deployment per mailbox. Inbox and
6
- threads, a rich composer with signatures and templates, attachments on
7
- S3-compatible storage, sharing links, full-text search, web push, PWA install,
8
- multi-accessor accounts with roles, and password reset.
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
- Every tenant-specific value — name, domain, colours, signature copy, artwork —
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
- ## Deploying — read this first
14
+ ## Running your own
15
15
 
16
- **Never deploy, and never `git push`, unless the person you are working with asks
17
- for it in that same request.** This is a standing rule for every agent and every
18
- session. A granted deploy covers that one deploy only; it is not permission for
19
- the next one.
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
- Here, pushing *is* deploying — for one tenant. A GitHub webhook on this repository
22
- points at the Coolify that serves production, so a push to `master` auto-deploys the
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 signed
40
- into without configuring seats. It is never seeded when `NODE_ENV=production`.
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 with
43
- enough traffic to exercise the list, search, paging and attachments. Create the schema
44
- first by starting the app once, or by calling `ensureMailSchema()`.
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. Pictures and
48
- video need `ffmpeg` and the document needs `zip`; whatever is missing is skipped. With
49
- no bucket configured the seed still runs and writes the records alone, as it always
50
- did.
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 single server-side source of tenant identity — name,
55
- domain, signature copy, colours, seats, aliases. `lib/brand.client.ts` is the
56
- browser half, read from `NEXT_PUBLIC_*` and inlined at build time, so a rebuild
57
- is required to change client-visible branding.
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; afterwards accounts
60
- are managed in the app. `MAIL_ADDRESS_ALIASES` redirects delivery for a seat
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
- `TURSO_DATABASE_URL` takes precedence; without it the app falls back to
66
- Cloudflare D1, so moving between the two needs no coordinated redeploy. A
67
- `file:` URL uses local SQLite, which is what a single box wants.
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 via the `R2_*` variables.
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` selects `ses`, `resend` or `brevo` behind one seam. SES is
74
- signed in-process; no SDK.
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/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
- /** Turso once it is configured, D1 until then, so the switch needs no redeploy dance. */
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.R2_REGION ?? process.env.S3_REGION ?? 'auto'
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('R2_S3_ENDPOINT, R2_BUCKET, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY must be configured')
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('TURSO_DATABASE_URL must be configured')
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",
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",
@@ -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. */
@@ -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. |
@@ -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)
@@ -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('TURSO_DATABASE_URL and TURSO_AUTH_TOKEN must be set')
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)