@novacraft-engineering/mailbox 0.4.5 → 0.4.18
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/README.md +17 -0
- package/app/api/mail/inbox/counts/route.ts +7 -7
- package/app/api/mail/inbox/route.ts +23 -4
- package/app/api/mail/maintenance/received/route.ts +11 -6
- package/app/api/mail/maintenance/rejudge/route.ts +32 -0
- package/app/api/mail/scheduled/route.ts +48 -0
- package/app/api/mail/scheduled/run/route.ts +19 -0
- package/app/api/mail/send/route.ts +35 -3
- package/app/api/mail/threads/route.ts +1 -1
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +197 -1
- package/app/mail/page.tsx +278 -35
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- package/lib/dev-auth.ts +25 -9
- package/lib/mail-provider.ts +22 -2
- package/lib/mailbox.ts +519 -42
- package/lib/receive.test.ts +20 -8
- package/lib/receive.ts +109 -12
- package/lib/scheduled.ts +168 -0
- package/lib/schema.test.ts +50 -0
- package/lib/ses-send.ts +14 -2
- package/package.json +1 -1
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
package/README.md
CHANGED
|
@@ -67,6 +67,23 @@ Attachments go to any S3-compatible bucket through the `S3_*` variables: AWS
|
|
|
67
67
|
S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi. The older `R2_*` names still
|
|
68
68
|
work if you already set them.
|
|
69
69
|
|
|
70
|
+
**The bucket needs a CORS rule.** The browser uploads straight to it with a
|
|
71
|
+
signed URL, so the bucket has to allow `PUT`, `GET` and `HEAD` from the address
|
|
72
|
+
the app is served on. Without one every attachment fails, and the browser
|
|
73
|
+
reports it the same way it reports being offline — so it reads as a network
|
|
74
|
+
problem rather than a missing rule. On R2:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
[{ "AllowedOrigins": ["https://mail.example.com"],
|
|
78
|
+
"AllowedMethods": ["PUT", "GET", "HEAD"],
|
|
79
|
+
"AllowedHeaders": ["*"],
|
|
80
|
+
"ExposeHeaders": ["ETag", "Content-Length", "Content-Type", "Content-Disposition", "Content-Range", "Accept-Ranges", "Last-Modified"],
|
|
81
|
+
"MaxAgeSeconds": 3600 }]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
List only your own origin. A wildcard lets any page that obtains a signed URL
|
|
85
|
+
upload with it.
|
|
86
|
+
|
|
70
87
|
## Sending
|
|
71
88
|
|
|
72
89
|
`MAIL_PROVIDER` picks `ses`, `resend` or `brevo` behind one seam, so switching
|
|
@@ -3,14 +3,14 @@ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
|
3
3
|
import { scopeFor } from '@/lib/scope'
|
|
4
4
|
import { countFolders, countFoldersCached } from '@/lib/mailbox'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Counts come from the durable cache, which every write that moves mail clears. An extra
|
|
8
|
+
* in-process copy used to sit in front of it, and nothing could reach in to drop it: a
|
|
9
|
+
* delete cleared the shared row and this map went on answering with figures from before
|
|
10
|
+
* it for another twenty seconds, per running instance.
|
|
11
|
+
*/
|
|
8
12
|
async function cachedCounts(owner: string | null, fresh: boolean) {
|
|
9
|
-
|
|
10
|
-
if (!fresh && hit && Date.now() - hit.at < COUNTS_TTL_MS) return hit.value
|
|
11
|
-
const value = fresh ? await countFolders(owner ?? undefined) : await countFoldersCached(owner)
|
|
12
|
-
countsCache.set(owner ?? '*all', { at: Date.now(), value })
|
|
13
|
-
return value
|
|
13
|
+
return fresh ? countFolders(owner ?? undefined) : countFoldersCached(owner)
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export const runtime = 'nodejs'
|
|
@@ -2,8 +2,8 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
3
|
import { mailAuthGuard, isLocalOrigin, resolveAccount } from '@/lib/dev-auth'
|
|
4
4
|
import { scopeFor } from '@/lib/scope'
|
|
5
|
-
import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
|
|
6
|
-
import { attributeOwner, forwardToAccounts, ingestReceived, parseSender } from '@/lib/receive'
|
|
5
|
+
import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundSpam, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
|
|
6
|
+
import { addressedToUs, attributeOwner, forwardToAccounts, ingestReceived, parseSender } from '@/lib/receive'
|
|
7
7
|
import { isBrevoInbound, normalizeBrevoInbound } from '@/lib/mail-provider'
|
|
8
8
|
|
|
9
9
|
export const runtime = 'nodejs'
|
|
@@ -27,7 +27,7 @@ export async function GET(req: Request) {
|
|
|
27
27
|
const threadId = params.get('thread') ?? undefined
|
|
28
28
|
if (text || limitRaw || offset || cursor || threadId) {
|
|
29
29
|
const folderParam = params.get('folder')
|
|
30
|
-
const folder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam)
|
|
30
|
+
const folder = (['inbox', 'archive', 'trash', 'starred', 'snoozed', 'spam'] as const).find(f => f === folderParam)
|
|
31
31
|
const { rows, total, nextCursor } = await searchInbox({
|
|
32
32
|
text,
|
|
33
33
|
owner: ownerFilter ?? undefined,
|
|
@@ -45,6 +45,7 @@ export async function GET(req: Request) {
|
|
|
45
45
|
label: params.get('label') ?? undefined,
|
|
46
46
|
from: params.get('from') ?? undefined,
|
|
47
47
|
to: params.get('to') ?? undefined,
|
|
48
|
+
addressed: params.get('addressed') ?? undefined,
|
|
48
49
|
})
|
|
49
50
|
return NextResponse.json({ ok: true, emails: rows, total, offset, nextCursor })
|
|
50
51
|
}
|
|
@@ -56,7 +57,7 @@ export async function GET(req: Request) {
|
|
|
56
57
|
export async function PATCH(req: Request) {
|
|
57
58
|
const guard = await mailAuthGuard(req)
|
|
58
59
|
if (guard) return guard
|
|
59
|
-
let body: { id?: string; ids?: string[]; threadId?: string; labels?: string[]; owner?: string; snoozedUntil?: string | null } & InboundFlags
|
|
60
|
+
let body: { id?: string; ids?: string[]; threadId?: string; labels?: string[]; owner?: string; snoozedUntil?: string | null; spam?: boolean } & InboundFlags
|
|
60
61
|
try {
|
|
61
62
|
body = await req.json()
|
|
62
63
|
} catch {
|
|
@@ -109,6 +110,11 @@ export async function PATCH(req: Request) {
|
|
|
109
110
|
const changed = await setInboundFlagsForThread(account.address ?? ' no-address', body.threadId, flags)
|
|
110
111
|
return NextResponse.json({ ok: true, ids: changed })
|
|
111
112
|
}
|
|
113
|
+
// Quarantine is its own move: it teaches the sender's standing, which a flag does not.
|
|
114
|
+
if (body.spam !== undefined) {
|
|
115
|
+
await Promise.all(ids.map(id => setInboundSpam(id, Boolean(body.spam))))
|
|
116
|
+
return NextResponse.json({ ok: true, ids })
|
|
117
|
+
}
|
|
112
118
|
await Promise.all(ids.map(id => setInboundFlags(id, flags)))
|
|
113
119
|
return NextResponse.json({ ok: true, ids })
|
|
114
120
|
}
|
|
@@ -239,6 +245,19 @@ export async function POST(req: Request) {
|
|
|
239
245
|
|
|
240
246
|
if (type === 'email.received') {
|
|
241
247
|
const emailId = String(data.email_id ?? data.id ?? `in_${Date.now()}`)
|
|
248
|
+
// The signature proves the provider sent it, not that it is ours. One provider account
|
|
249
|
+
// can hold several tenants' domains and posts all of their mail to the single webhook
|
|
250
|
+
// it knows about, so anything addressed elsewhere is acknowledged and dropped — a 2xx
|
|
251
|
+
// rather than an error, because retrying will never make it ours.
|
|
252
|
+
const addressed = [
|
|
253
|
+
...(Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
|
|
254
|
+
...(Array.isArray(data.cc) ? (data.cc as string[]) : []),
|
|
255
|
+
...(Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
|
|
256
|
+
].filter(Boolean)
|
|
257
|
+
if (addressed.length && !addressedToUs(addressed)) {
|
|
258
|
+
console.warn('[mail] refused inbound for another tenant', { emailId, to: addressed })
|
|
259
|
+
return NextResponse.json({ ok: true, ignored: 'not addressed to this mailbox' })
|
|
260
|
+
}
|
|
242
261
|
// Keyed on the delivery id, which a retry reuses, so a redelivery of something we
|
|
243
262
|
// already finished is a no-op rather than a second forwarded copy.
|
|
244
263
|
const deliveryId = req.headers.get('svix-id') || `received:${emailId}`
|
|
@@ -43,22 +43,27 @@ export async function POST(req: Request) {
|
|
|
43
43
|
if (!apiKey) return NextResponse.json({ ok: false, error: 'No provider key' }, { status: 400 })
|
|
44
44
|
const url = new URL(req.url)
|
|
45
45
|
const one = url.searchParams.get('id')
|
|
46
|
+
// Repair works on messages already stored, so the delivery claim is deliberately not
|
|
47
|
+
// consulted: it says "handled", which is exactly the state being corrected.
|
|
48
|
+
const repair = url.searchParams.get('repair') === '1'
|
|
46
49
|
const targets = one ? [one] : (await findMissingReceived(apiKey, sinceFrom(req))).map(item => item.id)
|
|
47
50
|
|
|
48
51
|
const taken: Array<{ id: string; owner: string; subject: string }> = []
|
|
49
52
|
const skipped: string[] = []
|
|
50
53
|
const failed: Array<{ id: string; error: string }> = []
|
|
51
54
|
for (const id of targets) {
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
if (!repair) {
|
|
56
|
+
const claim = await claimWebhookEvent(`received:${id}`)
|
|
57
|
+
if (claim !== 'claimed') { skipped.push(id); continue }
|
|
58
|
+
}
|
|
54
59
|
try {
|
|
55
|
-
const result = await ingestReceived(id)
|
|
56
|
-
await completeWebhookEvent(`received:${id}`)
|
|
60
|
+
const result = await ingestReceived(id, {}, repair ? 'repair' : 'store')
|
|
61
|
+
if (!repair) await completeWebhookEvent(`received:${id}`)
|
|
57
62
|
taken.push({ id, owner: result.owner, subject: result.subject })
|
|
58
63
|
} catch (err) {
|
|
59
|
-
await releaseWebhookEvent(`received:${id}`)
|
|
64
|
+
if (!repair) await releaseWebhookEvent(`received:${id}`)
|
|
60
65
|
failed.push({ id, error: err instanceof Error ? err.message : String(err) })
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
|
-
return NextResponse.json({ ok: true, taken, skipped, failed })
|
|
68
|
+
return NextResponse.json({ ok: true, repaired: repair, taken, skipped, failed })
|
|
64
69
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { rejudgeStored } from '@/lib/mailbox'
|
|
4
|
+
|
|
5
|
+
export const runtime = 'nodejs'
|
|
6
|
+
export const maxDuration = 300
|
|
7
|
+
|
|
8
|
+
function authorised(req: Request): boolean {
|
|
9
|
+
const secret = process.env.MAINTENANCE_TOKEN
|
|
10
|
+
if (!secret) return false
|
|
11
|
+
const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
|
|
12
|
+
if (token.length !== secret.length) return false
|
|
13
|
+
try {
|
|
14
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
|
|
15
|
+
} catch {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Re-reads stored mail through the current judgement and writes back what changed, a page
|
|
22
|
+
* at a time — `cursor` in the answer is the `before` for the next call.
|
|
23
|
+
*/
|
|
24
|
+
export async function POST(req: Request) {
|
|
25
|
+
if (!authorised(req)) return NextResponse.json({ ok: false, error: 'Unauthorised' }, { status: 403 })
|
|
26
|
+
const url = new URL(req.url)
|
|
27
|
+
const result = await rejudgeStored({
|
|
28
|
+
before: url.searchParams.get('before') ?? undefined,
|
|
29
|
+
limit: Number(url.searchParams.get('limit') ?? 200) || 200,
|
|
30
|
+
})
|
|
31
|
+
return NextResponse.json({ ok: true, ...result })
|
|
32
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
|
+
import { scopeFor } from '@/lib/scope'
|
|
4
|
+
import { listScheduled, cancelScheduled, rescheduleSend } from '@/lib/scheduled'
|
|
5
|
+
|
|
6
|
+
export const runtime = 'nodejs'
|
|
7
|
+
export const dynamic = 'force-dynamic'
|
|
8
|
+
|
|
9
|
+
export async function GET(req: Request) {
|
|
10
|
+
const guard = await mailAuthGuard(req)
|
|
11
|
+
if (guard) return guard
|
|
12
|
+
const account = await resolveAccount(req)
|
|
13
|
+
const scope = scopeFor(account, new URL(req.url).searchParams.get('mailbox'))
|
|
14
|
+
return NextResponse.json({ ok: true, scheduled: await listScheduled(scope) })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function DELETE(req: Request) {
|
|
18
|
+
const guard = await mailAuthGuard(req)
|
|
19
|
+
if (guard) return guard
|
|
20
|
+
const account = await resolveAccount(req)
|
|
21
|
+
const id = new URL(req.url).searchParams.get('id') ?? ''
|
|
22
|
+
if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
|
|
23
|
+
const cancelled = await cancelScheduled(id, account.address ?? '')
|
|
24
|
+
if (!cancelled) {
|
|
25
|
+
return NextResponse.json({ ok: false, error: 'That message is not waiting to be sent' }, { status: 404 })
|
|
26
|
+
}
|
|
27
|
+
return NextResponse.json({ ok: true })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function PATCH(req: Request) {
|
|
31
|
+
const guard = await mailAuthGuard(req)
|
|
32
|
+
if (guard) return guard
|
|
33
|
+
const account = await resolveAccount(req)
|
|
34
|
+
const body = (await req.json().catch(() => null)) as { id?: string; scheduledAt?: string } | null
|
|
35
|
+
const id = body?.id ?? new URL(req.url).searchParams.get('id') ?? ''
|
|
36
|
+
const when = body?.scheduledAt ? new Date(body.scheduledAt) : null
|
|
37
|
+
if (!id || !when || Number.isNaN(when.getTime())) {
|
|
38
|
+
return NextResponse.json({ ok: false, error: 'id and a valid scheduledAt are required' }, { status: 400 })
|
|
39
|
+
}
|
|
40
|
+
if (when.getTime() <= Date.now()) {
|
|
41
|
+
return NextResponse.json({ ok: false, error: 'Pick a time in the future' }, { status: 400 })
|
|
42
|
+
}
|
|
43
|
+
const moved = await rescheduleSend(id, account.address ?? '', when.toISOString())
|
|
44
|
+
if (!moved) {
|
|
45
|
+
return NextResponse.json({ ok: false, error: 'That message is not waiting to be sent' }, { status: 404 })
|
|
46
|
+
}
|
|
47
|
+
return NextResponse.json({ ok: true, scheduledAt: when.toISOString() })
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { dispatchDue } from '@/lib/scheduled'
|
|
3
|
+
|
|
4
|
+
export const runtime = 'nodejs'
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sends everything now due. Driven by a timer rather than by someone having the mailbox
|
|
9
|
+
* open, because a message written on Friday for Monday has nobody watching when its turn
|
|
10
|
+
* comes. Safe to call as often as you like: each row is claimed before it is sent.
|
|
11
|
+
*/
|
|
12
|
+
export async function POST(req: Request) {
|
|
13
|
+
const expected = (process.env.MAINTENANCE_TOKEN ?? '').trim()
|
|
14
|
+
const offered = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '').trim()
|
|
15
|
+
if (!expected || offered !== expected) {
|
|
16
|
+
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
|
17
|
+
}
|
|
18
|
+
return NextResponse.json({ ok: true, ...(await dispatchDue()) })
|
|
19
|
+
}
|
|
@@ -5,6 +5,7 @@ import { providerConfigProblem, sendMail } from '@/lib/mail-provider'
|
|
|
5
5
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
6
6
|
import { presign } from '@/lib/r2'
|
|
7
7
|
import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
|
|
8
|
+
import { scheduleSend } from '@/lib/scheduled'
|
|
8
9
|
import { publicOrigin } from '@/lib/public-url'
|
|
9
10
|
|
|
10
11
|
export const runtime = 'nodejs'
|
|
@@ -93,9 +94,10 @@ export async function POST(req: Request) {
|
|
|
93
94
|
|
|
94
95
|
const rawAttachments = (body.attachments ?? []).filter(entry => entry.filename && (entry.content || entry.path || entry.key))
|
|
95
96
|
if (rawAttachments.length && body.scheduledAt) {
|
|
96
|
-
//
|
|
97
|
+
// The message waits here rather than at a provider, and holding file bytes in the
|
|
98
|
+
// queue for hours is a cost this has not taken on yet.
|
|
97
99
|
return NextResponse.json(
|
|
98
|
-
{ ok: false, error: '
|
|
100
|
+
{ ok: false, error: 'A scheduled email cannot carry attachments yet — send it now, or schedule it without files' },
|
|
99
101
|
{ status: 400 },
|
|
100
102
|
)
|
|
101
103
|
}
|
|
@@ -155,6 +157,37 @@ export async function POST(req: Request) {
|
|
|
155
157
|
const fromEmail = from.replace(/^.*<|>$/g, '').trim()
|
|
156
158
|
const fromName = from.includes('<') ? from.slice(0, from.indexOf('<')).replace(/["']/g, '').trim() : undefined
|
|
157
159
|
|
|
160
|
+
const dueAt = body.scheduledAt ? new Date(body.scheduledAt) : null
|
|
161
|
+
if (dueAt && !Number.isNaN(dueAt.getTime()) && dueAt.getTime() > Date.now() + 5_000) {
|
|
162
|
+
// Held here, not handed over: SES has no scheduling and drops the instruction without
|
|
163
|
+
// a word, so "in an hour" arrived at once. The dispatcher sends it when it is due.
|
|
164
|
+
const scheduledId = await scheduleSend(account.address ?? fromEmail, dueAt.toISOString(), {
|
|
165
|
+
owner: account.address ?? fromEmail,
|
|
166
|
+
pixelId: trackedHtml ? pixelId : null,
|
|
167
|
+
subject: body.subject.trim(),
|
|
168
|
+
from,
|
|
169
|
+
recipients,
|
|
170
|
+
cc: body.cc ?? [],
|
|
171
|
+
bcc: body.bcc ?? [],
|
|
172
|
+
replyTo: replyTo ? [replyTo] : [],
|
|
173
|
+
inReplyTo: body.inReplyTo ?? null,
|
|
174
|
+
storedAttachments: [],
|
|
175
|
+
payload: {
|
|
176
|
+
from: fromEmail,
|
|
177
|
+
fromName: fromName || undefined,
|
|
178
|
+
to: recipients,
|
|
179
|
+
cc: body.cc,
|
|
180
|
+
bcc: body.bcc,
|
|
181
|
+
replyTo,
|
|
182
|
+
subject: body.subject.trim(),
|
|
183
|
+
html: trackedHtml,
|
|
184
|
+
text: body.text,
|
|
185
|
+
...(inReplyToId ? { headers: { 'In-Reply-To': inReplyToId, References: inReplyToId } } : {}),
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
return NextResponse.json({ ok: true, id: scheduledId, scheduled: true, scheduledAt: dueAt.toISOString() })
|
|
189
|
+
}
|
|
190
|
+
|
|
158
191
|
let data: { id: string | null }
|
|
159
192
|
try {
|
|
160
193
|
data = await sendMail({
|
|
@@ -167,7 +200,6 @@ export async function POST(req: Request) {
|
|
|
167
200
|
subject: body.subject.trim(),
|
|
168
201
|
html: trackedHtml,
|
|
169
202
|
text: body.text,
|
|
170
|
-
scheduledAt: body.scheduledAt,
|
|
171
203
|
attachments,
|
|
172
204
|
...(inReplyToId ? { headers: { 'In-Reply-To': inReplyToId, References: inReplyToId } } : {}),
|
|
173
205
|
})
|
|
@@ -12,7 +12,7 @@ export async function GET(req: Request) {
|
|
|
12
12
|
const account = await resolveAccount(req)
|
|
13
13
|
const url = new URL(req.url)
|
|
14
14
|
const folderParam = url.searchParams.get('folder')
|
|
15
|
-
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
15
|
+
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed', 'spam'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
16
16
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 500) || 500, 1000)
|
|
17
17
|
const page = await listThreads(scopeFor(account, url.searchParams.get('mailbox')), folder, limit, url.searchParams.get('cursor'))
|
|
18
18
|
return NextResponse.json({ ok: true, threads: page.rows, nextCursor: page.nextCursor })
|
package/app/globals.css
CHANGED
|
@@ -11,4 +11,11 @@ body {
|
|
|
11
11
|
margin: 0;
|
|
12
12
|
padding: 0;
|
|
13
13
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
14
|
+
/* Safari on iOS grows text block by block on its own reckoning, each by a different
|
|
15
|
+
amount, so a snippet came out larger than the subject above it and the list read as
|
|
16
|
+
though the sizes had been picked at random. Nothing else does this, which is why it
|
|
17
|
+
only ever showed on a phone. This pins the rendered size to the stated one; pinch to
|
|
18
|
+
zoom and the reader's own text-size setting are untouched. */
|
|
19
|
+
-webkit-text-size-adjust: 100%;
|
|
20
|
+
text-size-adjust: 100%;
|
|
14
21
|
}
|
|
@@ -35,7 +35,7 @@ export function attachmentKind(filename: string, contentType?: string): Attachme
|
|
|
35
35
|
* browser to save. Anything rendered here asks for the inline form instead, or a PDF opens
|
|
36
36
|
* the save dialog the moment the viewer appears.
|
|
37
37
|
*/
|
|
38
|
-
function inlineUrl(url: string): string {
|
|
38
|
+
export function inlineUrl(url: string): string {
|
|
39
39
|
if (!url.startsWith('/api/mail/')) return url
|
|
40
40
|
return `${url}${url.includes('?') ? '&' : '?'}inline=1`
|
|
41
41
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import styles from './page.module.css'
|
|
4
|
+
import type { IosBrowser } from './pwa'
|
|
5
|
+
|
|
6
|
+
const ShareArt = () => (
|
|
7
|
+
<svg className={styles.installArt} viewBox="0 0 160 104" aria-hidden="true">
|
|
8
|
+
<rect x="38" y="4" width="84" height="96" rx="13" fill="var(--nc-ink-2)" stroke="var(--nc-line-2)" />
|
|
9
|
+
<rect x="48" y="16" width="46" height="5" rx="2.5" fill="var(--nc-line-2)" />
|
|
10
|
+
<rect x="48" y="28" width="64" height="4" rx="2" fill="var(--nc-line-1)" />
|
|
11
|
+
<rect x="48" y="37" width="54" height="4" rx="2" fill="var(--nc-line-1)" />
|
|
12
|
+
<rect x="48" y="46" width="60" height="4" rx="2" fill="var(--nc-line-1)" />
|
|
13
|
+
<rect x="39" y="73" width="82" height="26" rx="11" fill="var(--nc-ink-3)" />
|
|
14
|
+
<g stroke="var(--nc-fg-3)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none" opacity=".55">
|
|
15
|
+
<path d="M55 82l-4 4 4 4" />
|
|
16
|
+
<path d="M65 82l4 4-4 4" />
|
|
17
|
+
<path d="M99 80h8v12h-8z" />
|
|
18
|
+
<path d="M111 81h6v10h-6z" />
|
|
19
|
+
</g>
|
|
20
|
+
<g stroke="var(--nc-violet-400)" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" fill="none">
|
|
21
|
+
<path d="M74 85v6a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-6" />
|
|
22
|
+
<path d="M80 79v10" />
|
|
23
|
+
<path d="M77 82l3-3 3 3" />
|
|
24
|
+
</g>
|
|
25
|
+
<circle cx="80" cy="86" r="12" fill="none" stroke="var(--nc-violet-400)" strokeWidth="1.4" opacity=".55" />
|
|
26
|
+
</svg>
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const SheetArt = () => (
|
|
30
|
+
<svg className={styles.installArt} viewBox="0 0 160 104" aria-hidden="true">
|
|
31
|
+
<rect x="10" y="10" width="140" height="84" rx="13" fill="var(--nc-ink-2)" stroke="var(--nc-line-2)" />
|
|
32
|
+
<rect x="68" y="17" width="24" height="4" rx="2" fill="var(--nc-line-2)" />
|
|
33
|
+
<g>
|
|
34
|
+
<rect x="18" y="28" width="124" height="18" rx="8" fill="var(--nc-ink-3)" />
|
|
35
|
+
<rect x="26" y="33" width="9" height="9" rx="2.5" fill="var(--nc-line-2)" />
|
|
36
|
+
<rect x="42" y="35" width="44" height="4" rx="2" fill="var(--nc-line-2)" />
|
|
37
|
+
</g>
|
|
38
|
+
<g>
|
|
39
|
+
<rect x="18" y="50" width="124" height="22" rx="8" fill="none" stroke="var(--nc-violet-400)" strokeWidth="1.6" />
|
|
40
|
+
<g stroke="var(--nc-violet-400)" strokeWidth="1.7" strokeLinecap="round" fill="none">
|
|
41
|
+
<rect x="26" y="56" width="10" height="10" rx="2.5" />
|
|
42
|
+
<path d="M31 58.5v5" />
|
|
43
|
+
<path d="M28.5 61h5" />
|
|
44
|
+
</g>
|
|
45
|
+
<rect x="44" y="59" width="66" height="4" rx="2" fill="var(--nc-violet-400)" />
|
|
46
|
+
</g>
|
|
47
|
+
<g opacity=".5">
|
|
48
|
+
<rect x="26" y="79" width="9" height="9" rx="2.5" fill="var(--nc-line-2)" />
|
|
49
|
+
<rect x="42" y="81" width="38" height="4" rx="2" fill="var(--nc-line-2)" />
|
|
50
|
+
</g>
|
|
51
|
+
</svg>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const HomeArt = () => (
|
|
55
|
+
<svg className={styles.installArt} viewBox="0 0 160 104" aria-hidden="true">
|
|
56
|
+
<rect x="38" y="4" width="84" height="96" rx="13" fill="var(--nc-ink-2)" stroke="var(--nc-line-2)" />
|
|
57
|
+
<g fill="var(--nc-line-1)">
|
|
58
|
+
<rect x="50" y="20" width="18" height="18" rx="5" />
|
|
59
|
+
<rect x="72" y="20" width="18" height="18" rx="5" />
|
|
60
|
+
<rect x="94" y="20" width="18" height="18" rx="5" />
|
|
61
|
+
<rect x="72" y="50" width="18" height="18" rx="5" />
|
|
62
|
+
<rect x="94" y="50" width="18" height="18" rx="5" />
|
|
63
|
+
</g>
|
|
64
|
+
<rect x="50" y="50" width="18" height="18" rx="5" fill="var(--nc-violet-400)" />
|
|
65
|
+
<g stroke="var(--nc-ink-1)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none">
|
|
66
|
+
<rect x="54" y="56" width="10" height="7" rx="1.5" />
|
|
67
|
+
<path d="M54 57.5l5 3.5 5-3.5" />
|
|
68
|
+
</g>
|
|
69
|
+
<rect x="52" y="71" width="14" height="3" rx="1.5" fill="var(--nc-violet-400)" opacity=".6" />
|
|
70
|
+
<g fill="var(--nc-line-1)" opacity=".7">
|
|
71
|
+
<rect x="74" y="71" width="14" height="3" rx="1.5" />
|
|
72
|
+
<rect x="96" y="71" width="14" height="3" rx="1.5" />
|
|
73
|
+
<rect x="52" y="41" width="14" height="3" rx="1.5" />
|
|
74
|
+
<rect x="74" y="41" width="14" height="3" rx="1.5" />
|
|
75
|
+
<rect x="96" y="41" width="14" height="3" rx="1.5" />
|
|
76
|
+
</g>
|
|
77
|
+
</svg>
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
export function InstallGuide({ browser, appName }: { browser: IosBrowser; appName: string }) {
|
|
81
|
+
return (
|
|
82
|
+
<div className={styles.installGuide}>
|
|
83
|
+
{browser === 'other' && (
|
|
84
|
+
<p className={styles.installWarn}>
|
|
85
|
+
You are not in Safari. On iPhone and iPad only Safari can add a real app to the Home Screen — Chrome,
|
|
86
|
+
Firefox and Edge save a bookmark that reopens inside the browser and cannot receive notifications. Open
|
|
87
|
+
this page in Safari, then follow the steps below.
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
<ol className={styles.installSteps}>
|
|
92
|
+
<li className={styles.installStep}>
|
|
93
|
+
<ShareArt />
|
|
94
|
+
<div>
|
|
95
|
+
<strong>Tap Share.</strong>
|
|
96
|
+
<p className={styles.settingsNote}>
|
|
97
|
+
It is the square with an arrow coming out of the top, in the bar at the bottom of Safari. On an iPad
|
|
98
|
+
it sits in the top bar instead.
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
</li>
|
|
102
|
+
<li className={styles.installStep}>
|
|
103
|
+
<SheetArt />
|
|
104
|
+
<div>
|
|
105
|
+
<strong>Choose Add to Home Screen.</strong>
|
|
106
|
+
<p className={styles.settingsNote}>
|
|
107
|
+
Scroll the share sheet down past the row of apps. The entry has a plus inside a square next to it.
|
|
108
|
+
</p>
|
|
109
|
+
</div>
|
|
110
|
+
</li>
|
|
111
|
+
<li className={styles.installStep}>
|
|
112
|
+
<HomeArt />
|
|
113
|
+
<div>
|
|
114
|
+
<strong>Tap Add.</strong>
|
|
115
|
+
<p className={styles.settingsNote}>
|
|
116
|
+
{appName} Mail lands on your Home Screen and opens in its own window, with no address bar. You can
|
|
117
|
+
rename it on this screen before you confirm.
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
</li>
|
|
121
|
+
</ol>
|
|
122
|
+
|
|
123
|
+
<p className={styles.settingsNote}>
|
|
124
|
+
Notifications only reach the Home Screen copy. iOS will not deliver them to a page open in a browser tab,
|
|
125
|
+
so add it here first and then turn notifications on inside the app.
|
|
126
|
+
</p>
|
|
127
|
+
<p className={styles.settingsNote}>
|
|
128
|
+
There is no one-tap install button on iPhone because Apple gives websites no way to ask — the share sheet
|
|
129
|
+
is the only route, on every browser.
|
|
130
|
+
</p>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|