@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { Resend } from 'resend'
|
|
3
|
+
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
4
|
+
import { getSentAttachments, readSentMessage } from '@/lib/mailbox'
|
|
5
|
+
|
|
6
|
+
export const runtime = 'nodejs'
|
|
7
|
+
|
|
8
|
+
function getResend(): Resend | null {
|
|
9
|
+
const apiKey = process.env.RESEND_API_KEY
|
|
10
|
+
return apiKey ? new Resend(apiKey) : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Imported archives were never in Resend, and a provider drops its copy eventually anyway;
|
|
15
|
+
* our own mail_sent row is the durable source, and the provider is asked only for the
|
|
16
|
+
* details it alone holds (attachment links, schedule, delivery state).
|
|
17
|
+
*/
|
|
18
|
+
async function fromArchive(id: string) {
|
|
19
|
+
const stored = await readSentMessage(id).catch(() => null)
|
|
20
|
+
if (!stored) return null
|
|
21
|
+
return NextResponse.json({
|
|
22
|
+
ok: true,
|
|
23
|
+
email: {
|
|
24
|
+
id: stored.id,
|
|
25
|
+
from: stored.from,
|
|
26
|
+
to: stored.to,
|
|
27
|
+
cc: stored.cc,
|
|
28
|
+
bcc: stored.bcc,
|
|
29
|
+
replyTo: stored.replyTo[0] ?? null,
|
|
30
|
+
subject: stored.subject || '(no subject)',
|
|
31
|
+
html: stored.html,
|
|
32
|
+
text: stored.text,
|
|
33
|
+
createdAt: stored.createdAt,
|
|
34
|
+
scheduledAt: null,
|
|
35
|
+
lastEvent: stored.lastEvent ?? '',
|
|
36
|
+
attachments: [],
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
42
|
+
const guard = await mailAuthGuard(req)
|
|
43
|
+
if (guard) return guard
|
|
44
|
+
const { id } = await params
|
|
45
|
+
const resend = getResend()
|
|
46
|
+
|
|
47
|
+
if (id.startsWith('mbox-') || !resend) {
|
|
48
|
+
const archived = await fromArchive(id)
|
|
49
|
+
if (archived) return archived
|
|
50
|
+
return NextResponse.json({ ok: false, error: resend ? 'Not found' : 'RESEND_API_KEY not configured' }, { status: resend ? 404 : 500 })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { data, error } = await resend.emails.get(id)
|
|
54
|
+
if (error) {
|
|
55
|
+
const archived = await fromArchive(id)
|
|
56
|
+
if (archived) return archived
|
|
57
|
+
return NextResponse.json({ ok: false, error: error.message }, { status: 502 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stored = await getSentAttachments(id).catch(() => [])
|
|
61
|
+
const rawAttachments = stored.some(entry => entry.key)
|
|
62
|
+
? stored
|
|
63
|
+
: ((await resend.emails.attachments.list({ emailId: id }).catch(() => null))?.data as unknown as {
|
|
64
|
+
data?: Array<Record<string, unknown>>
|
|
65
|
+
})?.data ?? []
|
|
66
|
+
// Served through this domain rather than the provider's host, which does not resolve
|
|
67
|
+
// on some of the networks the office uses.
|
|
68
|
+
const attachments = rawAttachments.map((entry, index) => ({
|
|
69
|
+
filename: String((entry as Record<string, unknown>).filename ?? 'attachment'),
|
|
70
|
+
size: Number((entry as Record<string, unknown>).size ?? 0),
|
|
71
|
+
downloadUrl: `/api/mail/emails/${encodeURIComponent(id)}/attachments/download?index=${index}`,
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
const item = data as unknown as Record<string, unknown>
|
|
75
|
+
return NextResponse.json({
|
|
76
|
+
ok: true,
|
|
77
|
+
email: {
|
|
78
|
+
id: String(item.id ?? id),
|
|
79
|
+
from: String(item.from ?? ''),
|
|
80
|
+
to: Array.isArray(item.to) ? (item.to as string[]) : [String(item.to ?? '')],
|
|
81
|
+
cc: Array.isArray(item.cc) ? (item.cc as string[]) : [],
|
|
82
|
+
bcc: Array.isArray(item.bcc) ? (item.bcc as string[]) : [],
|
|
83
|
+
replyTo: item.reply_to ? String(item.reply_to) : null,
|
|
84
|
+
subject: String(item.subject ?? '(no subject)'),
|
|
85
|
+
html: item.html ? String(item.html) : null,
|
|
86
|
+
text: item.text ? String(item.text) : null,
|
|
87
|
+
createdAt: String(item.created_at ?? ''),
|
|
88
|
+
scheduledAt: item.scheduled_at ? String(item.scheduled_at) : null,
|
|
89
|
+
lastEvent: String(item.last_event ?? ''),
|
|
90
|
+
attachments,
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
96
|
+
const guard = await mailAuthGuard(req)
|
|
97
|
+
if (guard) return guard
|
|
98
|
+
const resend = getResend()
|
|
99
|
+
if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
|
|
100
|
+
|
|
101
|
+
const { id } = await params
|
|
102
|
+
let body: { scheduledAt?: string }
|
|
103
|
+
try {
|
|
104
|
+
body = await req.json()
|
|
105
|
+
} catch {
|
|
106
|
+
return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
|
|
107
|
+
}
|
|
108
|
+
if (!body.scheduledAt) {
|
|
109
|
+
return NextResponse.json({ ok: false, error: 'scheduledAt is required' }, { status: 400 })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { error } = await resend.emails.update({ id, scheduledAt: body.scheduledAt })
|
|
113
|
+
if (error) return NextResponse.json({ ok: false, error: error.message }, { status: 502 })
|
|
114
|
+
return NextResponse.json({ ok: true })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
118
|
+
const guard = await mailAuthGuard(req)
|
|
119
|
+
if (guard) return guard
|
|
120
|
+
const resend = getResend()
|
|
121
|
+
if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
|
|
122
|
+
|
|
123
|
+
const { id } = await params
|
|
124
|
+
const { error } = await resend.emails.cancel(id)
|
|
125
|
+
if (error) return NextResponse.json({ ok: false, error: error.message }, { status: 502 })
|
|
126
|
+
return NextResponse.json({ ok: true })
|
|
127
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { BRAND } from '@/lib/brand'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { FORWARD_RECIPIENTS, mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
4
|
+
import { readSentFlags, setSentFlags, readPixelOpens, readSentMeta, setSentMetaOwner, listAccounts, readSentArchive, type SentFlags } from '@/lib/mailbox'
|
|
5
|
+
|
|
6
|
+
const SHARED_ADDRESS = (process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim().toLowerCase()
|
|
7
|
+
|
|
8
|
+
function fromAddressOf(email: SentEmail): string {
|
|
9
|
+
return (email.from.match(/<([^>]+)>/)?.[1] ?? email.from).trim().toLowerCase()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const runtime = 'nodejs'
|
|
13
|
+
|
|
14
|
+
type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string }
|
|
15
|
+
|
|
16
|
+
const KNOWN_AUTO_SUBJECTS = new Set([`Reset your ${BRAND.name} Mail password`])
|
|
17
|
+
|
|
18
|
+
/** Heuristic for pre-existing automated sends that predate owner/auto tagging. */
|
|
19
|
+
function looksAutomated(email: SentEmail): boolean {
|
|
20
|
+
if (email.subject.startsWith('Fwd:')) return true
|
|
21
|
+
if (KNOWN_AUTO_SUBJECTS.has(email.subject)) return true
|
|
22
|
+
const internal = new Set(
|
|
23
|
+
[...FORWARD_RECIPIENTS, ...(process.env.RESEND_TO ?? '').split(',')]
|
|
24
|
+
.map(addr => addr.trim().toLowerCase())
|
|
25
|
+
.filter(Boolean),
|
|
26
|
+
)
|
|
27
|
+
const recipients = email.to
|
|
28
|
+
.map(entry => (entry.match(/<([^>]+)>/)?.[1] ?? entry).trim().toLowerCase())
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
return recipients.length > 0 && recipients.every(addr => internal.has(addr))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function GET(req: Request) {
|
|
34
|
+
const guard = await mailAuthGuard(req)
|
|
35
|
+
if (guard) return guard
|
|
36
|
+
|
|
37
|
+
const account = await resolveAccount(req)
|
|
38
|
+
// Read our own archive rather than the provider's list: the history stays ours across
|
|
39
|
+
// a provider switch, and every send is written here at send time.
|
|
40
|
+
// Meta is looked up only for the archive rows being returned, so fetch those first.
|
|
41
|
+
const archive = await readSentArchive().catch(() => [])
|
|
42
|
+
const [flags, opens, sentMeta, accounts] = await Promise.all([
|
|
43
|
+
readSentFlags().catch(() => ({})),
|
|
44
|
+
readPixelOpens().catch(() => ({})),
|
|
45
|
+
readSentMeta(archive.map(item => item.id)).catch(() => ({})),
|
|
46
|
+
listAccounts().catch(() => []),
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
const all: SentEmail[] = archive.map(item => ({
|
|
50
|
+
id: item.id,
|
|
51
|
+
from: item.from,
|
|
52
|
+
to: item.to,
|
|
53
|
+
subject: item.subject || '(no subject)',
|
|
54
|
+
createdAt: item.createdAt,
|
|
55
|
+
scheduledAt: null,
|
|
56
|
+
lastEvent: item.lastEvent ?? '',
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
// Sent mail is scoped the same way the inbox is: to the signed-in mailbox, whatever
|
|
60
|
+
// the role. Administering accounts does not carry the right to read someone's sends.
|
|
61
|
+
const ownerScope: string = account.address ?? ' no-address'
|
|
62
|
+
|
|
63
|
+
// Ownership is the sender identity: the `from` address if it's an accessor's, else the
|
|
64
|
+
// shared inbox (admin). This attributes historical sends (all from hello@) correctly.
|
|
65
|
+
const accountAddresses = new Set(accounts.filter(entry => entry.address).map(entry => entry.address!.toLowerCase()))
|
|
66
|
+
const metaMap = sentMeta as Record<string, { owner: string | null; isAuto: boolean; inReplyTo: string | null }>
|
|
67
|
+
const sentOwner = (email: SentEmail): string => {
|
|
68
|
+
// An explicit assignment wins; otherwise the sender identity is the `from` address.
|
|
69
|
+
const assigned = metaMap[email.id]?.owner
|
|
70
|
+
if (assigned) return assigned
|
|
71
|
+
const from = fromAddressOf(email)
|
|
72
|
+
return accountAddresses.has(from) ? from : SHARED_ADDRESS
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const emails = all
|
|
76
|
+
.filter(email => {
|
|
77
|
+
const meta = metaMap[email.id]
|
|
78
|
+
if (meta?.isAuto) return false // tagged automated → never in Sent
|
|
79
|
+
if (!meta && looksAutomated(email)) return false // pre-existing automated → excluded
|
|
80
|
+
if (ownerScope) return sentOwner(email) === ownerScope
|
|
81
|
+
return true
|
|
82
|
+
})
|
|
83
|
+
.map(email => ({ ...email, owner: sentOwner(email), inReplyTo: metaMap[email.id]?.inReplyTo ?? null }))
|
|
84
|
+
|
|
85
|
+
return NextResponse.json({ ok: true, emails, flags, opens })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function PATCH(req: Request) {
|
|
89
|
+
const guard = await mailAuthGuard(req)
|
|
90
|
+
if (guard) return guard
|
|
91
|
+
let body: { id?: string; ids?: string[]; owner?: string } & SentFlags
|
|
92
|
+
try {
|
|
93
|
+
body = await req.json()
|
|
94
|
+
} catch {
|
|
95
|
+
return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
|
|
96
|
+
}
|
|
97
|
+
const ids = body.ids ?? (body.id ? [body.id] : [])
|
|
98
|
+
if (!ids.length) return NextResponse.json({ ok: false, error: 'id or ids is required' }, { status: 400 })
|
|
99
|
+
|
|
100
|
+
// Reassigning a message to a mailbox is admin-only.
|
|
101
|
+
if (body.owner !== undefined) {
|
|
102
|
+
const account = await resolveAccount(req)
|
|
103
|
+
if (account.role !== 'admin') {
|
|
104
|
+
return NextResponse.json({ ok: false, error: 'Admin access required' }, { status: 403 })
|
|
105
|
+
}
|
|
106
|
+
await Promise.all(ids.map(id => setSentMetaOwner(id, body.owner!)))
|
|
107
|
+
return NextResponse.json({ ok: true })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const flags: SentFlags = {}
|
|
111
|
+
if (body.starred !== undefined) flags.starred = body.starred
|
|
112
|
+
if (body.archived !== undefined) flags.archived = body.archived
|
|
113
|
+
if (body.trashed !== undefined) flags.trashed = body.trashed
|
|
114
|
+
await Promise.all(ids.map(id => setSentFlags(id, flags)))
|
|
115
|
+
return NextResponse.json({ ok: true })
|
|
116
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
3
|
+
import { readEvents } 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 events = await readEvents()
|
|
11
|
+
return NextResponse.json({ ok: true, events })
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
4
|
+
import { presign } from '@/lib/r2'
|
|
5
|
+
|
|
6
|
+
export const runtime = 'nodejs'
|
|
7
|
+
|
|
8
|
+
const TYPES: Record<string, string> = { woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf' }
|
|
9
|
+
const MAX_BYTES = 5 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
export async function GET(req: Request) {
|
|
12
|
+
const key = new URL(req.url).searchParams.get('key')
|
|
13
|
+
if (!key || !/^fonts\/[a-f0-9]+\.(woff2|woff|ttf|otf)$/.test(key)) {
|
|
14
|
+
return NextResponse.json({ ok: false, error: 'Bad key' }, { status: 400 })
|
|
15
|
+
}
|
|
16
|
+
const upstream = await fetch(presign(key, 'GET', 300)).catch(() => null)
|
|
17
|
+
if (!upstream?.ok) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
|
|
18
|
+
return new NextResponse(upstream.body, {
|
|
19
|
+
headers: {
|
|
20
|
+
'content-type': TYPES[key.split('.').pop() ?? ''] ?? 'application/octet-stream',
|
|
21
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
22
|
+
'access-control-allow-origin': '*',
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function POST(req: Request) {
|
|
28
|
+
const guard = await mailAuthGuard(req)
|
|
29
|
+
if (guard) return guard
|
|
30
|
+
const account = await resolveAccount(req)
|
|
31
|
+
if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
|
|
32
|
+
|
|
33
|
+
const extension = (new URL(req.url).searchParams.get('ext') ?? '').toLowerCase()
|
|
34
|
+
if (!TYPES[extension]) return NextResponse.json({ ok: false, error: 'Use a WOFF2, WOFF, TTF or OTF font file.' }, { status: 400 })
|
|
35
|
+
|
|
36
|
+
const bytes = Buffer.from(await req.arrayBuffer())
|
|
37
|
+
if (bytes.length === 0) return NextResponse.json({ ok: false, error: 'That file is empty.' }, { status: 400 })
|
|
38
|
+
if (bytes.length > MAX_BYTES) return NextResponse.json({ ok: false, error: 'Keep the font under 5MB.' }, { status: 413 })
|
|
39
|
+
|
|
40
|
+
const key = `fonts/${createHash('sha256').update(bytes).digest('hex').slice(0, 24)}.${extension}`
|
|
41
|
+
const put = await fetch(presign(key, 'PUT', 300), { method: 'PUT', headers: { 'content-type': TYPES[extension] }, body: bytes }).catch(() => null)
|
|
42
|
+
if (!put?.ok) return NextResponse.json({ ok: false, error: 'Could not store that font.' }, { status: 502 })
|
|
43
|
+
return NextResponse.json({ ok: true, url: `/api/mail/fonts?key=${encodeURIComponent(key)}` })
|
|
44
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
3
|
+
import { getInboundAttachments } from '@/lib/mailbox'
|
|
4
|
+
import { getObject } from '@/lib/r2'
|
|
5
|
+
|
|
6
|
+
export const runtime = 'nodejs'
|
|
7
|
+
export const dynamic = 'force-dynamic'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Serves attachment bytes from this domain rather than handing the browser a bucket or
|
|
11
|
+
* provider URL. Those hosts are a separate name to resolve and reach, and on the networks
|
|
12
|
+
* the office actually uses some of them do not resolve at all — the message opens, the
|
|
13
|
+
* attachment does not. The reader already reached this origin to load the page, so
|
|
14
|
+
* anything served from here is reachable by definition.
|
|
15
|
+
*/
|
|
16
|
+
export async function GET(req: Request) {
|
|
17
|
+
const guard = await mailAuthGuard(req)
|
|
18
|
+
if (guard) return guard
|
|
19
|
+
|
|
20
|
+
const params = new URL(req.url).searchParams
|
|
21
|
+
const id = params.get('id')
|
|
22
|
+
const index = Number(params.get('index') ?? 0)
|
|
23
|
+
if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
|
|
24
|
+
|
|
25
|
+
// The key is read from the message rather than taken from the caller, so this cannot
|
|
26
|
+
// be pointed at an arbitrary object in the bucket.
|
|
27
|
+
const attachments = await getInboundAttachments(id).catch(() => [])
|
|
28
|
+
const entry = attachments[Number.isFinite(index) ? index : 0]
|
|
29
|
+
const key = entry && typeof entry.key === 'string' ? entry.key : ''
|
|
30
|
+
if (!key) return NextResponse.json({ ok: false, error: 'That file is not stored here.' }, { status: 404 })
|
|
31
|
+
|
|
32
|
+
const object = await getObject(key)
|
|
33
|
+
if (!object || !object.body) {
|
|
34
|
+
return NextResponse.json({ ok: false, error: 'That file could not be read.' }, { status: 502 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const filename = String(entry.filename ?? 'attachment').replace(/["\\]/g, '')
|
|
38
|
+
// The preview and the download button share this route. `attachment` tells the browser
|
|
39
|
+
// to save rather than render, which turns a preview into a download — so the viewer
|
|
40
|
+
// asks for `inline` and only the download button gets the other.
|
|
41
|
+
const inline = params.get('inline') === '1'
|
|
42
|
+
return new Response(object.body, {
|
|
43
|
+
headers: {
|
|
44
|
+
'content-type': String(entry.contentType ?? object.headers.get('content-type') ?? 'application/octet-stream'),
|
|
45
|
+
'content-disposition': `${inline ? 'inline' : 'attachment'}; filename="${filename}"`,
|
|
46
|
+
...(object.headers.get('content-length') ? { 'content-length': object.headers.get('content-length')! } : {}),
|
|
47
|
+
'cache-control': 'private, max-age=3600',
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
4
|
+
import { getInboundAttachments } 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 received message into the outgoing space, so they can be
|
|
12
|
+
* forwarded.
|
|
13
|
+
*
|
|
14
|
+
* The send route will only attach keys under `outgoing/`, which is what stops a caller
|
|
15
|
+
* naming any object in the bucket. Rather than widen that, the copy happens here: the
|
|
16
|
+
* key is read from the message itself, never from the request, so the only files that
|
|
17
|
+
* can be copied are the ones actually on a message this caller may read.
|
|
18
|
+
*/
|
|
19
|
+
export async function POST(req: Request) {
|
|
20
|
+
const guard = await mailAuthGuard(req)
|
|
21
|
+
if (guard) return guard
|
|
22
|
+
|
|
23
|
+
let body: { id?: string }
|
|
24
|
+
try {
|
|
25
|
+
body = await req.json()
|
|
26
|
+
} catch {
|
|
27
|
+
return NextResponse.json({ ok: false, error: 'Invalid request.' }, { status: 400 })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const id = String(body.id ?? '')
|
|
31
|
+
if (!id) return NextResponse.json({ ok: false, error: 'Which message?' }, { status: 400 })
|
|
32
|
+
|
|
33
|
+
const held = await getInboundAttachments(id).catch(() => [])
|
|
34
|
+
const copied: Array<{ filename: string; size: number; contentType?: string; key: string }> = []
|
|
35
|
+
|
|
36
|
+
for (const entry of held) {
|
|
37
|
+
const sourceKey = typeof entry.key === 'string' ? entry.key : ''
|
|
38
|
+
if (!sourceKey) continue
|
|
39
|
+
|
|
40
|
+
const object = await getObject(sourceKey)
|
|
41
|
+
if (!object) continue
|
|
42
|
+
const bytes = Buffer.from(await object.arrayBuffer())
|
|
43
|
+
|
|
44
|
+
const filename = String(entry.filename ?? 'attachment')
|
|
45
|
+
const safe = filename.replace(/[^\w.\- ]+/g, '_').slice(0, 120)
|
|
46
|
+
const key = `outgoing/${randomBytes(9).toString('hex')}/${safe}`
|
|
47
|
+
const contentType = typeof entry.contentType === 'string' ? entry.contentType : undefined
|
|
48
|
+
if (!(await putObject(key, bytes, contentType))) continue
|
|
49
|
+
|
|
50
|
+
copied.push({ filename, size: bytes.length, contentType, key })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return NextResponse.json({ ok: true, attachments: copied })
|
|
54
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
3
|
+
import { getInboundAttachments, resolveInboundBody, setInboundAttachments } from '@/lib/mailbox'
|
|
4
|
+
import { isEmbedded, referencedCids } from '@/lib/attachments'
|
|
5
|
+
|
|
6
|
+
export const runtime = 'nodejs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Our own rehosted copy wins over Resend's: the provider's download URLs expire and vanish
|
|
10
|
+
* with the account, and imported archives were never in Resend to begin with.
|
|
11
|
+
*/
|
|
12
|
+
export async function GET(req: Request) {
|
|
13
|
+
const guard = await mailAuthGuard(req)
|
|
14
|
+
if (guard) return guard
|
|
15
|
+
|
|
16
|
+
const id = new URL(req.url).searchParams.get('id')
|
|
17
|
+
if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
|
|
18
|
+
|
|
19
|
+
// Anything we hold is served back through this domain. A bucket link and a provider
|
|
20
|
+
// link are both hosts the reader's network has to reach separately, and on some of
|
|
21
|
+
// those networks they cannot be reached at all.
|
|
22
|
+
const held = await getInboundAttachments(id).catch(() => [])
|
|
23
|
+
// The body decides which images are decoration. Read once, not once per file.
|
|
24
|
+
const body = await resolveInboundBody(id).catch(() => ({ html: null, text: null }))
|
|
25
|
+
const referenced = referencedCids(body.html)
|
|
26
|
+
const stored = held
|
|
27
|
+
.map((entry, index) => ({ entry, index }))
|
|
28
|
+
.filter(({ entry }) => typeof entry.key === 'string' && entry.key)
|
|
29
|
+
.filter(({ entry }) => !isEmbedded(entry, referenced))
|
|
30
|
+
.map(({ entry, index }) => ({
|
|
31
|
+
filename: String(entry.filename ?? 'attachment'),
|
|
32
|
+
size: Number(entry.size ?? 0),
|
|
33
|
+
contentType: typeof entry.contentType === 'string' ? entry.contentType : undefined,
|
|
34
|
+
downloadUrl: `/api/mail/inbox/attachments/download?id=${encodeURIComponent(id)}&index=${index}`,
|
|
35
|
+
}))
|
|
36
|
+
// A file too big to send was put behind a share page instead. We hold no bytes for it,
|
|
37
|
+
// but it is not lost: it has somewhere to go, and a tile with nowhere to go is a dead end.
|
|
38
|
+
const shared = held
|
|
39
|
+
.map((entry, index) => ({ entry, index }))
|
|
40
|
+
.filter(({ entry }) => typeof entry.shareId === 'string' && entry.shareId && !entry.key)
|
|
41
|
+
.map(({ entry }) => ({
|
|
42
|
+
filename: String(entry.filename ?? 'attachment'),
|
|
43
|
+
size: Number(entry.size ?? 0),
|
|
44
|
+
contentType: typeof entry.contentType === 'string' ? entry.contentType : undefined,
|
|
45
|
+
shareId: String(entry.shareId),
|
|
46
|
+
downloadUrl: `/share/${encodeURIComponent(String(entry.shareId))}`,
|
|
47
|
+
}))
|
|
48
|
+
if (stored.length || shared.length) return NextResponse.json({ ok: true, attachments: [...stored, ...shared] })
|
|
49
|
+
|
|
50
|
+
// Nothing of ours yet: this message predates the copy-on-arrival. Pull the bytes from
|
|
51
|
+
// the provider now and keep them, so the next reader is served from here and the file
|
|
52
|
+
// survives the provider's own retention window.
|
|
53
|
+
const apiKey = process.env.RESEND_API_KEY
|
|
54
|
+
if (!apiKey) return NextResponse.json({ ok: true, attachments: [] })
|
|
55
|
+
|
|
56
|
+
const response = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(id)}/attachments`, {
|
|
57
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
58
|
+
})
|
|
59
|
+
if (!response.ok) return NextResponse.json({ ok: true, attachments: [] })
|
|
60
|
+
|
|
61
|
+
const payload = (await response.json()) as { data?: Array<Record<string, unknown>> }
|
|
62
|
+
const listed = payload.data ?? []
|
|
63
|
+
const { putObject } = await import('@/lib/r2')
|
|
64
|
+
|
|
65
|
+
const kept = await Promise.all(
|
|
66
|
+
listed.map(async (entry, index) => {
|
|
67
|
+
const filename = String(entry.filename ?? 'attachment')
|
|
68
|
+
const contentType = entry.content_type ? String(entry.content_type) : undefined
|
|
69
|
+
const source = entry.download_url ? String(entry.download_url) : ''
|
|
70
|
+
const contentId = entry.content_id ? String(entry.content_id).replace(/^<|>$/g, '') : undefined
|
|
71
|
+
const meta: Record<string, unknown> = { filename, contentType, size: Number(entry.size ?? 0), contentId }
|
|
72
|
+
if (!source) return meta
|
|
73
|
+
try {
|
|
74
|
+
const binary = await fetch(source)
|
|
75
|
+
if (!binary.ok) return meta
|
|
76
|
+
const bytes = Buffer.from(await binary.arrayBuffer())
|
|
77
|
+
const safeName = filename.replace(/[^\w.\- ]+/g, '_').slice(-120)
|
|
78
|
+
const key = `attachments/${id}/${index}-${safeName}`
|
|
79
|
+
if (await putObject(key, bytes, contentType)) return { ...meta, size: bytes.length, key }
|
|
80
|
+
} catch {
|
|
81
|
+
// Fall through: the file is still listed, just not ours yet.
|
|
82
|
+
}
|
|
83
|
+
return meta
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if (kept.some(entry => entry.key)) await setInboundAttachments(id, kept).catch(() => {})
|
|
88
|
+
|
|
89
|
+
const attachments = kept.map((entry, index) => ({ entry, index }))
|
|
90
|
+
.filter(({ entry }) => !isEmbedded(entry, referenced))
|
|
91
|
+
.map(({ entry, index }) => ({
|
|
92
|
+
filename: String(entry.filename ?? 'attachment'),
|
|
93
|
+
size: Number(entry.size ?? 0),
|
|
94
|
+
// Only ours are offered. A provider link that the reader's network cannot reach is
|
|
95
|
+
// worse than none: it looks like the app is broken rather than the file being absent.
|
|
96
|
+
downloadUrl: entry.key ? `/api/mail/inbox/attachments/download?id=${encodeURIComponent(id)}&index=${index}` : '',
|
|
97
|
+
}))
|
|
98
|
+
return NextResponse.json({ ok: true, attachments })
|
|
99
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
3
|
+
import { resolveInboundBody } 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 id = new URL(req.url).searchParams.get('id')
|
|
11
|
+
if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
|
|
12
|
+
const body = await resolveInboundBody(id).catch(() => ({ html: null, text: null }))
|
|
13
|
+
return NextResponse.json({ ok: true, ...body })
|
|
14
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
|
+
import { countFolders, countFoldersCached } from '@/lib/mailbox'
|
|
4
|
+
|
|
5
|
+
const COUNTS_TTL_MS = 20 * 1000
|
|
6
|
+
const countsCache = new Map<string, { at: number; value: Awaited<ReturnType<typeof countFolders>> }>()
|
|
7
|
+
async function cachedCounts(owner: string) {
|
|
8
|
+
const hit = countsCache.get(owner)
|
|
9
|
+
if (hit && Date.now() - hit.at < COUNTS_TTL_MS) return hit.value
|
|
10
|
+
const value = await countFoldersCached(owner)
|
|
11
|
+
countsCache.set(owner, { at: Date.now(), value })
|
|
12
|
+
return value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const runtime = 'nodejs'
|
|
16
|
+
|
|
17
|
+
export async function GET(req: Request) {
|
|
18
|
+
const guard = await mailAuthGuard(req)
|
|
19
|
+
if (guard) return guard
|
|
20
|
+
|
|
21
|
+
// Counts describe the signed-in mailbox, matching what the list will actually show.
|
|
22
|
+
const account = await resolveAccount(req)
|
|
23
|
+
const counts = await cachedCounts(account.address ?? ' no-address')
|
|
24
|
+
return NextResponse.json({ ok: true, counts })
|
|
25
|
+
}
|