@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.
Files changed (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
package/.env.example ADDED
@@ -0,0 +1,110 @@
1
+ # ─── Tenant identity ────────────────────────────────────────────────────────
2
+ # Every mailbox address lives on this domain.
3
+ MAIL_ADDRESS_DOMAIN=example.com
4
+ MAIL_FROM="Example <info@example.com>"
5
+ MAIL_SUPPORT_EMAIL=info@example.com
6
+
7
+ # The origin used for links that go out in email (password resets, the open pixel).
8
+ # Never derived from the request: the Host header is caller-controlled.
9
+ MAIL_PUBLIC_URL=https://mail.example.com
10
+
11
+ # ─── Branding (server) ──────────────────────────────────────────────────────
12
+ BRAND_NAME=Example
13
+ BRAND_LEGAL_NAME=Example Limited
14
+ BRAND_WEBSITE=www.example.com
15
+ BRAND_LOGO_URL=
16
+ BRAND_MARK_URL=
17
+ BRAND_ADDRESS=
18
+ BRAND_TEL=
19
+ BRAND_REGULATORY=
20
+ BRAND_DISCLAIMER=
21
+ BRAND_COLOR_ACCENT=rgb(109, 40, 217)
22
+ BRAND_COLOR_LINK=rgb(17, 85, 204)
23
+ BRAND_COLOR_MUTED=rgb(107, 114, 128)
24
+
25
+ # ─── Branding (client, inlined at build time) ───────────────────────────────
26
+ NEXT_PUBLIC_BRAND_NAME=Example
27
+ NEXT_PUBLIC_MAIL_DOMAIN=example.com
28
+ NEXT_PUBLIC_BRAND_WEBSITE=www.example.com
29
+ NEXT_PUBLIC_MAIL_PUBLIC_URL=https://mail.example.com
30
+ NEXT_PUBLIC_BRAND_MARK_URL=
31
+ NEXT_PUBLIC_BRAND_ACCENT="#6d28d9"
32
+ # Comma-separated seed addresses offered on the sign-in screen.
33
+ NEXT_PUBLIC_MAIL_ADDRESSES=info@example.com
34
+
35
+ # ─── Seats ──────────────────────────────────────────────────────────────────
36
+ # JSON. The first admin owns the shared inbox. Seeded on first boot only.
37
+ MAIL_SEATS=[{"email":"info@example.com","address":"info@example.com","name":"Example","role":"admin"}]
38
+ # JSON. Delivery aliases: mail for the key is stored in the value's box.
39
+ MAIL_ADDRESS_ALIASES={}
40
+
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.
46
+ CLOUDFLARE_ACCOUNT_ID=
47
+ CLOUDFLARE_D1_TOKEN=
48
+ D1_DATABASE_ID=
49
+
50
+ # Signs the session cookie. Without it every request falls back to sending the
51
+ # password on each call, so set it. Rotating the value signs everybody out.
52
+ MAIL_SESSION_SECRET=
53
+
54
+ # ─── Sending ────────────────────────────────────────────────────────────────
55
+ # ses | resend | brevo
56
+ MAIL_PROVIDER=ses
57
+ SES_ACCESS_KEY_ID=
58
+ SES_SECRET_ACCESS_KEY=
59
+ AWS_SES_REGION=eu-north-1
60
+ RESEND_API_KEY=
61
+ RESEND_WEBHOOK_SECRET=
62
+
63
+ # Optional: copy inbound mail to an outside address. Leave empty to forward
64
+ # nothing — pointing this at an address this app receives would loop.
65
+ MAIL_FORWARD_TO=
66
+
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=
72
+ BLOB_READ_WRITE_TOKEN=
73
+
74
+ # ─── Optional extras ────────────────────────────────────────────────────────
75
+ SAFE_BROWSING_API_KEY=
76
+ VAPID_PUBLIC_KEY=
77
+ VAPID_PRIVATE_KEY=
78
+ VAPID_SUBJECT=mailto:info@example.com
79
+ NEXT_PUBLIC_BRAND_LEGAL_NAME=Example Limited
80
+ MAIL_ADDRESS_DOMAINS=
81
+ MAIL_DEFAULT_ADDRESS_DOMAIN=
82
+ NEXT_PUBLIC_MAIL_ADDRESS_DOMAINS=
83
+ BRAND_ICON_URL=
84
+ BRAND_APPLE_ICON_URL=
85
+ BRAND_ACCENT_HEX="#6d28d9"
86
+
87
+ # Per-tenant looks that must not carry over between brands.
88
+ # Mark drawn in the app chrome (alpha-only PNG, CSS-masked). A path under public/ or a full URL.
89
+ BRAND_CHROME_MARK_URL=/brand/mark.png
90
+ NEXT_PUBLIC_BRAND_CHROME_MARK_URL=/brand/mark.png
91
+ # Frame around the mark in email signatures: none, accent, or any CSS colour. Radius in px.
92
+ BRAND_SIGNATURE_MARK_BORDER=none
93
+ BRAND_SIGNATURE_MARK_RADIUS=0
94
+ NEXT_PUBLIC_BRAND_SIGNATURE_MARK_BORDER=none
95
+ NEXT_PUBLIC_BRAND_SIGNATURE_MARK_RADIUS=0
96
+ # The browser builds the house signature too, so the fields it prints need public twins.
97
+ NEXT_PUBLIC_BRAND_ADDRESS=
98
+ NEXT_PUBLIC_BRAND_TEL=
99
+ NEXT_PUBLIC_BRAND_REGULATORY=
100
+ NEXT_PUBLIC_BRAND_DISCLAIMER=
101
+ NEXT_PUBLIC_BRAND_COLOR_ACCENT=rgb(109, 40, 217)
102
+ NEXT_PUBLIC_BRAND_COLOR_LINK=rgb(17, 85, 204)
103
+ NEXT_PUBLIC_BRAND_COLOR_MUTED=rgb(107, 114, 128)
104
+
105
+ # Tenant images live on the volume, never in the repository. See public/brand/README.md.
106
+ BRAND_ASSET_DIR=/data/brand
107
+
108
+ # Outside production the app seeds test@$MAIL_ADDRESS_DOMAIN as an admin, password the
109
+ # same as the address, so a fresh checkout can be signed into. Never seeded when
110
+ # NODE_ENV=production.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Novacraft Engineering
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # Mailbox
2
+
3
+ A shared webmail app: one codebase, one deployment per mailbox. Inbox and
4
+ threads, a rich composer with signatures and templates, attachments on
5
+ S3-compatible storage, sharing links, full-text search, web push, PWA install,
6
+ multi-accessor accounts with roles, and password reset.
7
+
8
+ Every tenant-specific value — name, domain, colours, signature copy, artwork —
9
+ lives in configuration. Nothing in this repository names or pictures any one
10
+ business, and nothing added to it should.
11
+
12
+ ## Deploying — read this first
13
+
14
+ **Never deploy, and never `git push`, unless the person you are working with asks
15
+ for it in that same request.** This is a standing rule for every agent and every
16
+ session. A granted deploy covers that one deploy only; it is not permission for
17
+ the next one.
18
+
19
+ Here, pushing *is* deploying. Coolify auto-deploys `master` on push, and a single
20
+ push updates **every** live mailbox at once, not just the one you are working on.
21
+ Finish the work, commit locally, leave it unpushed, and say plainly that it is
22
+ waiting.
23
+
24
+ ## Standing up a new mailbox
25
+
26
+ 1. Copy `.env.example` and fill it in. Nothing is hardcoded to a tenant.
27
+ 2. Put three images on the volume at `BRAND_ASSET_DIR` — see `public/brand/README.md`.
28
+ 3. Point `TURSO_DATABASE_URL` at a SQLite file on a mounted volume.
29
+ 4. Deploy. The schema is created on first boot and migrates itself forward.
30
+
31
+ ## Trying it locally
32
+
33
+ Outside production the app seeds one account for you — `test@<MAIL_ADDRESS_DOMAIN>`,
34
+ an admin whose password is the address itself — so a fresh checkout can be signed
35
+ into without configuring seats. It is never seeded when `NODE_ENV=production`.
36
+
37
+ `node --experimental-strip-types scripts/seed-dev.mjs` then fills that mailbox with
38
+ enough traffic to exercise the list, search, paging and attachments. Create the schema
39
+ first by starting the app once, or by calling `ensureMailSchema()`.
40
+
41
+ The attachments are real files, not records: a PDF, a photo, a short video, a
42
+ spreadsheet and a Word document, generated on the spot and uploaded once. Pictures and
43
+ video need `ffmpeg` and the document needs `zip`; whatever is missing is skipped. With
44
+ no bucket configured the seed still runs and writes the records alone, as it always
45
+ did.
46
+
47
+ ## Configuration
48
+
49
+ `lib/brand.ts` is the single server-side source of tenant identity — name,
50
+ domain, signature copy, colours, seats, aliases. `lib/brand.client.ts` is the
51
+ browser half, read from `NEXT_PUBLIC_*` and inlined at build time, so a rebuild
52
+ is required to change client-visible branding.
53
+
54
+ Seats are JSON in `MAIL_SEATS` and seed on first boot only; afterwards accounts
55
+ are managed in the app. `MAIL_ADDRESS_ALIASES` redirects delivery for a seat
56
+ whose mail someone else now reads.
57
+
58
+ ## Storage
59
+
60
+ `TURSO_DATABASE_URL` takes precedence; without it the app falls back to
61
+ Cloudflare D1, so moving between the two needs no coordinated redeploy. A
62
+ `file:` URL uses local SQLite, which is what a single box wants.
63
+
64
+ Attachments go to any S3-compatible bucket via the `R2_*` variables.
65
+
66
+ ## Sending
67
+
68
+ `MAIL_PROVIDER` selects `ses`, `resend` or `brevo` behind one seam. SES is
69
+ signed in-process; no SDK.
70
+
71
+ ## Tests
72
+
73
+ ```
74
+ npx tsx --test lib/*.test.ts
75
+ ```
@@ -0,0 +1,197 @@
1
+ import { BRAND, ADDRESS_DOMAINS as BRAND_ADDRESS_DOMAINS } from '@/lib/brand'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { NextResponse } from 'next/server'
4
+ import { Resend } from 'resend'
5
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
6
+ import {
7
+ listAccounts,
8
+ getAccount,
9
+ getAccountByAddress,
10
+ createAccount,
11
+ updateAccount,
12
+ deleteAccount,
13
+ createResetToken,
14
+ recordSentMeta,
15
+ type MailRole,
16
+ } from '@/lib/mailbox'
17
+
18
+ export const runtime = 'nodejs'
19
+
20
+ const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
21
+ // Personal addresses live on the already-verified (sending + receiving) Resend domain.
22
+ const ADDRESS_DOMAIN = BRAND.domain
23
+
24
+ /**
25
+ * Every suffix an accessor mailbox may use. Only list domains whose MX actually points at
26
+ * our inbound webhook — a domain we can send as but not receive on would look like a working
27
+ * mailbox and silently drop replies. The default domain always stays first.
28
+ */
29
+ const ADDRESS_DOMAINS: string[] = BRAND_ADDRESS_DOMAINS
30
+
31
+ function resolveDomain(requested?: string): string | null {
32
+ if (!requested) return ADDRESS_DOMAIN
33
+ const wanted = requested.trim().toLowerCase().replace(/^@/, '')
34
+ return ADDRESS_DOMAINS.includes(wanted) ? wanted : null
35
+ }
36
+
37
+ async function requireAdmin(req: Request): Promise<NextResponse | null> {
38
+ const guard = await mailAuthGuard(req)
39
+ if (guard) return guard
40
+ const account = await resolveAccount(req)
41
+ if (account.role !== 'admin') {
42
+ return NextResponse.json({ ok: false, error: 'Admin access required' }, { status: 403 })
43
+ }
44
+ return null
45
+ }
46
+
47
+ function slugifyHandle(raw: string): string {
48
+ return raw
49
+ .trim()
50
+ .toLowerCase()
51
+ .replace(/@.*/, '')
52
+ .replace(/[^a-z0-9.]+/g, '.')
53
+ .replace(/^\.+|\.+$/g, '')
54
+ .slice(0, 40)
55
+ }
56
+
57
+ async function uniqueAddress(handle: string, domain: string = ADDRESS_DOMAIN): Promise<string> {
58
+ const base = slugifyHandle(handle) || 'user'
59
+ for (let attempt = 0; attempt < 50; attempt += 1) {
60
+ const candidate = `${attempt === 0 ? base : `${base}${attempt + 1}`}@${domain}`
61
+ if (!(await getAccountByAddress(candidate))) return candidate
62
+ }
63
+ return `${base}.${randomBytes(3).toString('hex')}@${domain}`
64
+ }
65
+
66
+ export async function GET(req: Request) {
67
+ const denied = await requireAdmin(req)
68
+ if (denied) return denied
69
+ const accessors = await listAccounts()
70
+ return NextResponse.json({ ok: true, accessors, domain: ADDRESS_DOMAIN, domains: ADDRESS_DOMAINS })
71
+ }
72
+
73
+ export async function POST(req: Request) {
74
+ const denied = await requireAdmin(req)
75
+ if (denied) return denied
76
+ const inviter = await resolveAccount(req)
77
+
78
+ let body: { email?: string; name?: string; role?: MailRole; handle?: string; domain?: string }
79
+ try {
80
+ body = await req.json()
81
+ } catch {
82
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
83
+ }
84
+
85
+ const email = (body.email ?? '').trim().toLowerCase()
86
+ if (!email || !email.includes('@')) {
87
+ return NextResponse.json({ ok: false, error: 'A valid email is required' }, { status: 400 })
88
+ }
89
+ const existing = await getAccount(email)
90
+ if (existing && existing.status === 'active') {
91
+ return NextResponse.json({ ok: false, error: 'That person already has an account' }, { status: 409 })
92
+ }
93
+
94
+ const role: MailRole = body.role === 'admin' ? 'admin' : 'member'
95
+ const name = body.name?.trim() || null
96
+ const domain = resolveDomain(body.domain)
97
+ if (!domain) return NextResponse.json({ ok: false, error: 'That mail domain is not available' }, { status: 400 })
98
+ const address = existing?.address ?? (await uniqueAddress(body.handle || name || email, domain))
99
+
100
+ await createAccount({ email, address, role, name, status: 'pending', invitedBy: inviter.email })
101
+
102
+ const token = randomBytes(32).toString('hex')
103
+ await createResetToken(email, token, Date.now() + INVITE_TTL_MS)
104
+
105
+ const apiKey = process.env.RESEND_API_KEY
106
+ if (apiKey) {
107
+ const from = (process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '')
108
+ const origin = process.env.MAIL_PUBLIC_URL?.replace(/\/$/, '') || new URL(req.url).origin
109
+ const inviteUrl = `${origin}/mail/reset?token=${token}&invite=1`
110
+ const resend = new Resend(apiKey)
111
+ try {
112
+ const { data } = await resend.emails.send({
113
+ from: `${BRAND.name} Mail <${from}>`,
114
+ to: [email],
115
+ subject: `You've been invited to ${BRAND.name} Mail`,
116
+ text: `${inviter.email} invited you to ${BRAND.name} Mail.\n\nSet your password to get started: ${inviteUrl}\n\nThis link expires in 7 days.`,
117
+ html: `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.6;color:#1A1030;">
118
+ <p style="margin:0 0 12px;color:${BRAND.colors.accent};font-weight:600;">You've been invited to ${BRAND.name} Mail</p>
119
+ <p style="margin:0 0 16px;">${name ? `Hi ${name}, ` : ''}you've been given access to ${BRAND.name} Mail. Set a password to get started.</p>
120
+ <a href="${inviteUrl}" style="display:inline-block;background:${BRAND.colors.accent};color:#fff;text-decoration:none;font-weight:600;padding:11px 20px;border-radius:8px;">Set your password</a>
121
+ <p style="margin:16px 0 0;color:#8E84A8;font-size:13px;">This invite link expires in 7 days.</p>
122
+ </div>`,
123
+ })
124
+ if (data?.id) await recordSentMeta(data.id, null, true).catch(() => {})
125
+ } catch (err) {
126
+ console.warn('[mail] invite email failed:', err)
127
+ }
128
+ }
129
+
130
+ return NextResponse.json({ ok: true, email, address, role })
131
+ }
132
+
133
+ export async function PATCH(req: Request) {
134
+ const denied = await requireAdmin(req)
135
+ if (denied) return denied
136
+ let body: { email?: string; role?: MailRole; name?: string | null; handle?: string; domain?: string }
137
+ try {
138
+ body = await req.json()
139
+ } catch {
140
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
141
+ }
142
+ const email = (body.email ?? '').trim().toLowerCase()
143
+ if (!email) return NextResponse.json({ ok: false, error: 'email is required' }, { status: 400 })
144
+ const patch: { role?: MailRole; name?: string | null; address?: string } = {}
145
+ if (body.role) patch.role = body.role === 'admin' ? 'admin' : 'member'
146
+ if (body.name !== undefined) patch.name = body.name?.trim() || null
147
+
148
+ if (body.handle !== undefined || body.domain !== undefined) {
149
+ const current = (await getAccount(email))?.address ?? ''
150
+ const handle = slugifyHandle(body.handle ?? current.split('@')[0])
151
+ if (!handle) return NextResponse.json({ ok: false, error: 'Mailbox name is not valid' }, { status: 400 })
152
+ const domain = resolveDomain(body.domain ?? current.split('@')[1])
153
+ if (!domain) return NextResponse.json({ ok: false, error: 'That mail domain is not available' }, { status: 400 })
154
+ const address = `${handle}@${domain}`
155
+ const holder = await getAccountByAddress(address)
156
+ if (holder && holder.email !== email) {
157
+ return NextResponse.json({ ok: false, error: `${address} is already taken` }, { status: 409 })
158
+ }
159
+ patch.address = address
160
+ }
161
+
162
+ // Never demote the last remaining admin.
163
+ if (patch.role === 'member') {
164
+ const admins = (await listAccounts()).filter(acc => acc.role === 'admin')
165
+ if (admins.length <= 1 && admins.some(acc => acc.email === email)) {
166
+ return NextResponse.json({ ok: false, error: 'At least one admin is required' }, { status: 400 })
167
+ }
168
+ }
169
+ await updateAccount(email, patch)
170
+ return NextResponse.json({ ok: true })
171
+ }
172
+
173
+ export async function DELETE(req: Request) {
174
+ const denied = await requireAdmin(req)
175
+ if (denied) return denied
176
+ const admin = await resolveAccount(req)
177
+ let body: { email?: string }
178
+ try {
179
+ body = await req.json()
180
+ } catch {
181
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
182
+ }
183
+ const email = (body.email ?? '').trim().toLowerCase()
184
+ if (!email) return NextResponse.json({ ok: false, error: 'email is required' }, { status: 400 })
185
+ if (email === admin.email.toLowerCase()) {
186
+ return NextResponse.json({ ok: false, error: "You can't remove your own access" }, { status: 400 })
187
+ }
188
+ const target = await getAccount(email)
189
+ if (target?.role === 'admin') {
190
+ const admins = (await listAccounts()).filter(acc => acc.role === 'admin')
191
+ if (admins.length <= 1) {
192
+ return NextResponse.json({ ok: false, error: 'At least one admin is required' }, { status: 400 })
193
+ }
194
+ }
195
+ await deleteAccount(email)
196
+ return NextResponse.json({ ok: true })
197
+ }
@@ -0,0 +1,54 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { listInboundWithAttachments } from '@/lib/mailbox'
4
+
5
+ // The attachment list is found with a LIKE over the mailbox, which no index can serve:
6
+ // every row the owner has is examined on each call, and Turso meters every one of
7
+ // them. A warm instance answers repeat callers from memory for five minutes.
8
+ const FILES_TTL_MS = 5 * 60 * 1000
9
+ type FileRows = Awaited<ReturnType<typeof listInboundWithAttachments>>
10
+ const filesCache = new Map<string, { at: number; value: FileRows }>()
11
+ async function cachedFiles(owner: string | null): Promise<FileRows> {
12
+ const key = owner ?? ''
13
+ const hit = filesCache.get(key)
14
+ if (hit && Date.now() - hit.at < FILES_TTL_MS) return hit.value
15
+ const value = await cachedFiles(owner)
16
+ filesCache.set(key, { at: Date.now(), value })
17
+ return value
18
+ }
19
+
20
+ export const runtime = 'nodejs'
21
+ export const dynamic = 'force-dynamic'
22
+
23
+ export async function GET(req: Request) {
24
+ const guard = await mailAuthGuard(req)
25
+ if (guard) return guard
26
+ const account = await resolveAccount(req)
27
+ if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
28
+
29
+ // Null would list every mailbox's files; the Files panel shows the signed-in one.
30
+ const owner = account.address ?? account.email
31
+ const rows = await listInboundWithAttachments(owner)
32
+ // Bytes have lived under `key` since they moved to our own bucket; only the oldest rows
33
+ // still carry a provider `url`. Filtering on url alone hid nearly everything here, and
34
+ // handed out a link this app cannot serve inline.
35
+ const files = rows.flatMap(row =>
36
+ row.attachments
37
+ .map((file, index) => ({ file, index }))
38
+ .filter(({ file }) => (typeof file.key === 'string' && file.key) || (typeof file.url === 'string' && file.url))
39
+ .map(({ file, index }) => ({
40
+ messageId: row.id,
41
+ filename: String(file.filename ?? 'attachment'),
42
+ size: Number(file.size ?? 0),
43
+ contentType: file.contentType ? String(file.contentType) : undefined,
44
+ url:
45
+ typeof file.key === 'string' && file.key
46
+ ? `/api/mail/inbox/attachments/download?id=${encodeURIComponent(row.id)}&index=${index}`
47
+ : String(file.url),
48
+ subject: row.subject,
49
+ from: row.from,
50
+ at: row.receivedAt,
51
+ })),
52
+ )
53
+ return NextResponse.json({ ok: true, files })
54
+ }
@@ -0,0 +1,36 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { getSettings, setSettings } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ /** Not an address anyone can sign in as, so it can never collide with a person's settings. */
8
+ const COMPANY = '__company__'
9
+
10
+ export async function GET(req: Request) {
11
+ const guard = await mailAuthGuard(req)
12
+ if (guard) return guard
13
+ const stored = await getSettings(COMPANY)
14
+ return NextResponse.json({
15
+ ok: true,
16
+ signature: typeof stored.signature === 'string' ? stored.signature : '',
17
+ logo: typeof stored.logo === 'string' ? stored.logo : '',
18
+ })
19
+ }
20
+
21
+ export async function PUT(req: Request) {
22
+ const guard = await mailAuthGuard(req)
23
+ if (guard) return guard
24
+ const account = await resolveAccount(req)
25
+ if (account.role !== 'admin') return NextResponse.json({ ok: false, error: 'Admin access required' }, { status: 403 })
26
+ let body: { signature?: unknown; logo?: unknown }
27
+ try {
28
+ body = await req.json()
29
+ } catch {
30
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
31
+ }
32
+ const signature = typeof body.signature === 'string' ? body.signature : ''
33
+ const logo = typeof body.logo === 'string' ? body.logo : ''
34
+ await setSettings(COMPANY, { signature, logo })
35
+ return NextResponse.json({ ok: true })
36
+ }
@@ -0,0 +1,13 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard } from '@/lib/dev-auth'
3
+ import { searchContacts } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ export async function GET(req: Request) {
8
+ const guard = await mailAuthGuard(req)
9
+ if (guard) return guard
10
+ const query = new URL(req.url).searchParams.get('q') ?? ''
11
+ const contacts = await searchContacts(query)
12
+ return NextResponse.json({ ok: true, contacts })
13
+ }
@@ -0,0 +1,69 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard } from '@/lib/dev-auth'
3
+ import { getSentAttachments } from '@/lib/mailbox'
4
+ import { getObject } from '@/lib/r2'
5
+
6
+ export const runtime = 'nodejs'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ /**
10
+ * Streams an attachment off a message we sent, through this domain.
11
+ *
12
+ * The provider hands out links on a host of its own, and on the networks the office
13
+ * uses that host does not resolve — the message opens and the file does not. The
14
+ * reader already reached this origin to load the page.
15
+ */
16
+ export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
17
+ const guard = await mailAuthGuard(req)
18
+ if (guard) return guard
19
+
20
+ const { id } = await context.params
21
+ const params = new URL(req.url).searchParams
22
+ const index = Number(params.get('index') ?? 0)
23
+ const inline = params.get('inline') === '1'
24
+ const position = Number.isFinite(index) ? index : 0
25
+
26
+ const stored = (await getSentAttachments(id).catch(() => []))[position]
27
+ if (stored?.key) {
28
+ const object = await getObject(stored.key)
29
+ if (object?.body) {
30
+ const name = (stored.filename || 'attachment').replace(/["\\]/g, '')
31
+ return new Response(object.body, {
32
+ headers: {
33
+ 'content-type': stored.contentType ?? object.headers.get('content-type') ?? 'application/octet-stream',
34
+ 'content-disposition': `${inline ? 'inline' : 'attachment'}; filename="${name}"`,
35
+ ...(object.headers.get('content-length') ? { 'content-length': object.headers.get('content-length')! } : {}),
36
+ 'cache-control': 'private, max-age=3600',
37
+ },
38
+ })
39
+ }
40
+ }
41
+
42
+ const apiKey = process.env.RESEND_API_KEY
43
+ if (!apiKey) return NextResponse.json({ ok: false, error: 'Sending is not configured.' }, { status: 503 })
44
+
45
+ const listing = await fetch(`https://api.resend.com/emails/${encodeURIComponent(id)}/attachments`, {
46
+ headers: { authorization: `Bearer ${apiKey}` },
47
+ }).catch(() => null)
48
+ if (!listing?.ok) return NextResponse.json({ ok: false, error: 'That file could not be read.' }, { status: 502 })
49
+
50
+ const payload = (await listing.json()) as { data?: Array<Record<string, unknown>> }
51
+ const entry = (payload.data ?? [])[position]
52
+ const source = entry?.download_url ? String(entry.download_url) : ''
53
+ if (!source) return NextResponse.json({ ok: false, error: 'That file is no longer available.' }, { status: 404 })
54
+
55
+ const file = await fetch(source).catch(() => null)
56
+ if (!file?.ok || !file.body) {
57
+ return NextResponse.json({ ok: false, error: 'That file could not be read.' }, { status: 502 })
58
+ }
59
+
60
+ const filename = String(entry.filename ?? 'attachment').replace(/["\\]/g, '')
61
+ return new Response(file.body, {
62
+ headers: {
63
+ 'content-type': String(entry.content_type ?? file.headers.get('content-type') ?? 'application/octet-stream'),
64
+ 'content-disposition': `${inline ? 'inline' : 'attachment'}; filename="${filename}"`,
65
+ ...(file.headers.get('content-length') ? { 'content-length': file.headers.get('content-length')! } : {}),
66
+ 'cache-control': 'private, max-age=3600',
67
+ },
68
+ })
69
+ }
@@ -0,0 +1,65 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { mailAuthGuard } from '@/lib/dev-auth'
4
+ import { getSentAttachments } from '@/lib/mailbox'
5
+ import { getObject, putObject } from '@/lib/r2'
6
+
7
+ export const runtime = 'nodejs'
8
+ export const dynamic = 'force-dynamic'
9
+
10
+ /**
11
+ * Copies the files on a message we sent into a fresh outgoing key, so it can be
12
+ * forwarded.
13
+ *
14
+ * Anything sent since we started recording keys is already in our bucket and is copied
15
+ * from there. Older messages only exist at the provider, so those are pulled back and
16
+ * stored on the way through — which also means the next forward of the same message no
17
+ * longer depends on the provider still having it.
18
+ */
19
+ export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
20
+ const guard = await mailAuthGuard(req)
21
+ if (guard) return guard
22
+
23
+ const { id } = await context.params
24
+ const copied: Array<{ filename: string; size: number; contentType?: string; key: string }> = []
25
+
26
+ const mint = (filename: string) =>
27
+ `outgoing/${randomBytes(9).toString('hex')}/${filename.replace(/[^\w.\- ]+/g, '_').slice(0, 120)}`
28
+
29
+ const ours = await getSentAttachments(id).catch(() => [])
30
+ for (const entry of ours) {
31
+ if (!entry.key) continue
32
+ const object = await getObject(entry.key)
33
+ if (!object) continue
34
+ const bytes = Buffer.from(await object.arrayBuffer())
35
+ const key = mint(entry.filename)
36
+ if (!(await putObject(key, bytes, entry.contentType))) continue
37
+ copied.push({ filename: entry.filename, size: bytes.length, contentType: entry.contentType, key })
38
+ }
39
+ if (copied.length) return NextResponse.json({ ok: true, attachments: copied })
40
+
41
+ // Nothing of ours recorded: this predates that, so fall back to the provider.
42
+ const apiKey = process.env.RESEND_API_KEY
43
+ if (!apiKey) return NextResponse.json({ ok: true, attachments: [] })
44
+
45
+ const listing = await fetch(`https://api.resend.com/emails/${encodeURIComponent(id)}/attachments`, {
46
+ headers: { authorization: `Bearer ${apiKey}` },
47
+ }).catch(() => null)
48
+ if (!listing?.ok) return NextResponse.json({ ok: true, attachments: [] })
49
+
50
+ const payload = (await listing.json()) as { data?: Array<Record<string, unknown>> }
51
+ for (const entry of payload.data ?? []) {
52
+ const source = entry.download_url ? String(entry.download_url) : ''
53
+ if (!source) continue
54
+ const file = await fetch(source).catch(() => null)
55
+ if (!file?.ok) continue
56
+ const bytes = Buffer.from(await file.arrayBuffer())
57
+ const filename = String(entry.filename ?? 'attachment')
58
+ const contentType = entry.content_type ? String(entry.content_type) : undefined
59
+ const key = mint(filename)
60
+ if (!(await putObject(key, bytes, contentType))) continue
61
+ copied.push({ filename, size: bytes.length, contentType, key })
62
+ }
63
+
64
+ return NextResponse.json({ ok: true, attachments: copied })
65
+ }