@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.
- package/.env.example +110 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app/api/mail/accessors/route.ts +197 -0
- package/app/api/mail/attachments/route.ts +54 -0
- package/app/api/mail/company-signature/route.ts +36 -0
- package/app/api/mail/contacts/route.ts +13 -0
- package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
- package/app/api/mail/emails/[id]/route.ts +127 -0
- package/app/api/mail/emails/route.ts +116 -0
- package/app/api/mail/events/route.ts +12 -0
- package/app/api/mail/fonts/route.ts +44 -0
- package/app/api/mail/inbox/attachments/download/route.ts +50 -0
- package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
- package/app/api/mail/inbox/attachments/route.ts +99 -0
- package/app/api/mail/inbox/body/route.ts +14 -0
- package/app/api/mail/inbox/counts/route.ts +25 -0
- package/app/api/mail/inbox/route.ts +506 -0
- package/app/api/mail/link-check/route.ts +95 -0
- package/app/api/mail/login/route.ts +38 -0
- package/app/api/mail/logout/route.ts +9 -0
- package/app/api/mail/maintenance/attachments/route.ts +26 -0
- package/app/api/mail/maintenance/bodies/route.ts +30 -0
- package/app/api/mail/maintenance/list-columns/route.ts +25 -0
- package/app/api/mail/maintenance/threads/route.ts +32 -0
- package/app/api/mail/me/route.ts +13 -0
- package/app/api/mail/outgoing-upload/route.ts +37 -0
- package/app/api/mail/password/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +20 -0
- package/app/api/mail/push/route.ts +42 -0
- package/app/api/mail/render-template/route.ts +58 -0
- package/app/api/mail/request-reset/route.ts +65 -0
- package/app/api/mail/reset/route.ts +46 -0
- package/app/api/mail/send/route.ts +205 -0
- package/app/api/mail/settings/route.ts +35 -0
- package/app/api/mail/share/attachment/route.ts +91 -0
- package/app/api/mail/share/route.ts +130 -0
- package/app/api/mail/signature-logo/route.ts +67 -0
- package/app/api/mail/stash/route.ts +55 -0
- package/app/api/mail/threads/route.ts +18 -0
- package/app/api/mail/upload/route.ts +43 -0
- package/app/api/share/[id]/route.ts +85 -0
- package/app/apple-icon.png +0 -0
- package/app/brand/[file]/route.ts +37 -0
- package/app/globals.css +14 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -0
- package/app/mail/AccessCheck.tsx +45 -0
- package/app/mail/AttachmentLightbox.tsx +241 -0
- package/app/mail/ConfirmDialog.tsx +88 -0
- package/app/mail/MailSelect.tsx +138 -0
- package/app/mail/RichEditor.tsx +859 -0
- package/app/mail/layout.tsx +20 -0
- package/app/mail/page.module.css +6320 -0
- package/app/mail/page.tsx +7971 -0
- package/app/mail/pwa.ts +191 -0
- package/app/mail/reset/page.tsx +132 -0
- package/app/mail/search.ts +172 -0
- package/app/manifest.ts +21 -0
- package/app/page.tsx +5 -0
- package/app/robots.ts +22 -0
- package/app/share/[id]/page.tsx +166 -0
- package/app/share/[id]/share.module.css +148 -0
- package/eslint.config.mjs +9 -0
- package/lib/accent-ramp.ts +54 -0
- package/lib/attachments.ts +52 -0
- package/lib/brand.client.ts +52 -0
- package/lib/brand.ts +87 -0
- package/lib/d1.ts +64 -0
- package/lib/default-signature.ts +68 -0
- package/lib/dev-auth.ts +222 -0
- package/lib/email-html.test.ts +74 -0
- package/lib/email-html.ts +169 -0
- package/lib/emails/index.ts +281 -0
- package/lib/emails/templates/academy-followup.html +68 -0
- package/lib/emails/templates/auto-reply.html +33 -0
- package/lib/emails/templates/contact-followup.html +58 -0
- package/lib/emails/templates/field-row.html +4 -0
- package/lib/emails/templates/notification.html +23 -0
- package/lib/fonts.ts +23 -0
- package/lib/link-safety.ts +81 -0
- package/lib/mail-provider.ts +183 -0
- package/lib/mailbox.ts +1888 -0
- package/lib/password.ts +71 -0
- package/lib/public-url.ts +14 -0
- package/lib/push.ts +37 -0
- package/lib/r2.ts +157 -0
- package/lib/rate-limit.ts +43 -0
- package/lib/session.test.ts +43 -0
- package/lib/session.ts +80 -0
- package/lib/signature.ts +28 -0
- package/lib/threads.test.ts +96 -0
- package/lib/threads.ts +72 -0
- package/lib/turso.ts +53 -0
- package/next.config.ts +51 -0
- package/package.json +69 -0
- package/public/brand/README.md +35 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/sw.js +41 -0
- package/scripts/backfill-attachments.mjs +96 -0
- package/scripts/backfill-content-ids.mjs +101 -0
- package/scripts/backfill-list-columns.mjs +80 -0
- package/scripts/offload-bodies.mjs +152 -0
- package/scripts/sample-files.mjs +119 -0
- package/scripts/seed-dev.mjs +240 -0
- package/tools/README.md +18 -0
- package/tools/db-backup.py +158 -0
- package/tools/doh.py +46 -0
- package/tools/import-mbox.py +703 -0
- package/tools/import-takeout.sh +49 -0
- 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
|
+
}
|