@novacraft-engineering/mailbox 0.4.24 → 0.4.26
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 +8 -2
- package/app/api/mail/company-dictionary/route.ts +36 -0
- package/app/api/mail/company-signature/route.ts +1 -1
- package/app/api/mail/events/ses/route.ts +88 -0
- package/app/api/mail/grammar/route.ts +52 -0
- package/app/api/mail/scheduled/route.ts +15 -1
- package/app/mail/RichEditor.tsx +450 -5
- package/app/mail/page.module.css +275 -0
- package/app/mail/page.tsx +115 -12
- package/app/mail/tabKey.ts +52 -0
- package/app/mail/writing/WritingPanel.tsx +259 -0
- package/app/mail/writing/assist.ts +139 -0
- package/app/mail/writing/checks.test.ts +48 -0
- package/app/mail/writing/checks.ts +58 -0
- package/app/mail/writing/find.ts +52 -0
- package/app/mail/writing/grammar.ts +100 -0
- package/app/mail/writing/live.ts +34 -0
- package/app/mail/writing/paste.test.ts +17 -0
- package/app/mail/writing/paste.ts +30 -0
- package/app/mail/writing/settings.ts +98 -0
- package/app/mail/writing/spell.worker.ts +40 -0
- package/app/mail/writing/spelling.ts +164 -0
- package/app/mail/writing/templates.ts +40 -0
- package/app/mail/writing/text.test.ts +88 -0
- package/app/mail/writing/text.ts +106 -0
- package/lib/dev-auth.ts +5 -3
- package/lib/dmarc-report.test.ts +18 -0
- package/lib/mail-provider.ts +1 -2
- package/lib/mailbox.ts +7 -2
- package/lib/receive.test.ts +24 -0
- package/lib/receive.ts +55 -11
- package/lib/scheduled.ts +67 -33
- package/lib/ses-send.test.ts +21 -0
- package/lib/ses-send.ts +29 -5
- package/lib/sns.ts +68 -0
- package/package.json +3 -1
- package/public/dictionaries/en-GB.LICENSE +347 -0
- package/public/dictionaries/en-GB.aff +205 -0
- package/public/dictionaries/en-GB.dic +49602 -0
- package/public/dictionaries/en-US.LICENSE +347 -0
- package/public/dictionaries/en-US.aff +205 -0
- package/public/dictionaries/en-US.dic +49569 -0
package/.env.example
CHANGED
|
@@ -58,9 +58,15 @@ AWS_SES_REGION=eu-north-1
|
|
|
58
58
|
RESEND_API_KEY=
|
|
59
59
|
RESEND_WEBHOOK_SECRET=
|
|
60
60
|
|
|
61
|
-
# Optional: copy inbound mail to
|
|
62
|
-
#
|
|
61
|
+
# Optional: where a copy goes when inbound mail belongs to no account. Pointing this
|
|
62
|
+
# at an address this app receives would loop.
|
|
63
63
|
MAIL_FORWARD_TO=
|
|
64
|
+
# Set to off to stop copying inbound mail to account owners' login addresses.
|
|
65
|
+
MAIL_FORWARDING=on
|
|
66
|
+
|
|
67
|
+
# SES delivery events arrive through SNS at /api/mail/events/ses. Only this topic is
|
|
68
|
+
# accepted; leave unset and every notification is refused.
|
|
69
|
+
MAIL_SES_EVENTS_TOPIC_ARN=
|
|
64
70
|
|
|
65
71
|
# ─── Attachments ────────────────────────────────────────────────────────────
|
|
66
72
|
# Any S3-compatible bucket: AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi.
|
|
@@ -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
|
+
const COMPANY = '__company__'
|
|
8
|
+
|
|
9
|
+
const wordsFrom = (value: unknown): string[] =>
|
|
10
|
+
Array.isArray(value)
|
|
11
|
+
? [...new Set(value.filter((word): word is string => typeof word === 'string').map(word => word.trim()).filter(word => word && word.length <= 60))].slice(0, 5000)
|
|
12
|
+
: []
|
|
13
|
+
|
|
14
|
+
/** Words everyone's spelling check accepts: client, insurer and product names. */
|
|
15
|
+
export async function GET(req: Request) {
|
|
16
|
+
const guard = await mailAuthGuard(req)
|
|
17
|
+
if (guard) return guard
|
|
18
|
+
const stored = await getSettings(COMPANY)
|
|
19
|
+
return NextResponse.json({ ok: true, words: wordsFrom(stored.dictionary) })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function PUT(req: Request) {
|
|
23
|
+
const guard = await mailAuthGuard(req)
|
|
24
|
+
if (guard) return guard
|
|
25
|
+
const account = await resolveAccount(req)
|
|
26
|
+
if (account.role !== 'admin') return NextResponse.json({ ok: false, error: 'Admin access required' }, { status: 403 })
|
|
27
|
+
let body: { words?: unknown }
|
|
28
|
+
try {
|
|
29
|
+
body = await req.json()
|
|
30
|
+
} catch {
|
|
31
|
+
return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
|
|
32
|
+
}
|
|
33
|
+
const words = wordsFrom(body.words)
|
|
34
|
+
await setSettings(COMPANY, { ...(await getSettings(COMPANY)), dictionary: words })
|
|
35
|
+
return NextResponse.json({ ok: true, words })
|
|
36
|
+
}
|
|
@@ -31,6 +31,6 @@ export async function PUT(req: Request) {
|
|
|
31
31
|
}
|
|
32
32
|
const signature = typeof body.signature === 'string' ? body.signature : ''
|
|
33
33
|
const logo = typeof body.logo === 'string' ? body.logo : ''
|
|
34
|
-
await setSettings(COMPANY, { signature, logo })
|
|
34
|
+
await setSettings(COMPANY, { ...(await getSettings(COMPANY)), signature, logo })
|
|
35
35
|
return NextResponse.json({ ok: true })
|
|
36
36
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { appendEvent, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent } from '@/lib/mailbox'
|
|
3
|
+
import { awsSnsUrl, verifySns, type SnsEnvelope } from '@/lib/sns'
|
|
4
|
+
|
|
5
|
+
export const runtime = 'nodejs'
|
|
6
|
+
|
|
7
|
+
type SesEvent = {
|
|
8
|
+
eventType?: string
|
|
9
|
+
mail?: { messageId?: string; timestamp?: string; destination?: string[]; commonHeaders?: { subject?: string } }
|
|
10
|
+
bounce?: { bounceType?: string; bounceSubType?: string; timestamp?: string; bouncedRecipients?: Array<{ emailAddress?: string; diagnosticCode?: string }> }
|
|
11
|
+
complaint?: { timestamp?: string; complaintFeedbackType?: string }
|
|
12
|
+
delivery?: { timestamp?: string }
|
|
13
|
+
deliveryDelay?: { timestamp?: string; delayType?: string }
|
|
14
|
+
reject?: { reason?: string }
|
|
15
|
+
failure?: { errorMessage?: string }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The same names Resend's webhooks use, so the Sent list reads either provider the same way.
|
|
19
|
+
const EVENT_TYPES: Record<string, string> = {
|
|
20
|
+
Send: 'email.sent',
|
|
21
|
+
Delivery: 'email.delivered',
|
|
22
|
+
DeliveryDelay: 'email.delivery_delayed',
|
|
23
|
+
Bounce: 'email.bounced',
|
|
24
|
+
Complaint: 'email.complained',
|
|
25
|
+
Reject: 'email.failed',
|
|
26
|
+
RenderingFailure: 'email.failed',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** SES delivery events, fanned out through an SNS topic this deployment pins by ARN. */
|
|
30
|
+
export async function POST(req: Request) {
|
|
31
|
+
let envelope: SnsEnvelope
|
|
32
|
+
try {
|
|
33
|
+
envelope = JSON.parse(await req.text()) as SnsEnvelope
|
|
34
|
+
} catch {
|
|
35
|
+
return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
|
|
36
|
+
}
|
|
37
|
+
if (!(await verifySns(envelope, process.env.MAIL_SES_EVENTS_TOPIC_ARN))) {
|
|
38
|
+
return NextResponse.json({ ok: false, error: 'Unverified' }, { status: 403 })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (envelope.Type === 'SubscriptionConfirmation') {
|
|
42
|
+
if (!envelope.SubscribeURL || !awsSnsUrl(envelope.SubscribeURL, '/')) {
|
|
43
|
+
return NextResponse.json({ ok: false, error: 'Unexpected subscribe URL' }, { status: 400 })
|
|
44
|
+
}
|
|
45
|
+
const confirmed = await fetch(envelope.SubscribeURL).then(response => response.ok).catch(() => false)
|
|
46
|
+
return NextResponse.json({ ok: confirmed })
|
|
47
|
+
}
|
|
48
|
+
if (envelope.Type !== 'Notification') return NextResponse.json({ ok: true, ignored: envelope.Type })
|
|
49
|
+
|
|
50
|
+
let event: SesEvent
|
|
51
|
+
try {
|
|
52
|
+
event = JSON.parse(envelope.Message ?? '{}') as SesEvent
|
|
53
|
+
} catch {
|
|
54
|
+
return NextResponse.json({ ok: false, error: 'Notification carried no JSON' }, { status: 400 })
|
|
55
|
+
}
|
|
56
|
+
const type = EVENT_TYPES[event.eventType ?? '']
|
|
57
|
+
const emailId = event.mail?.messageId
|
|
58
|
+
if (!type || !emailId) return NextResponse.json({ ok: true, ignored: event.eventType ?? 'unknown' })
|
|
59
|
+
|
|
60
|
+
const claimKey = `ses:${envelope.MessageId ?? ''}`
|
|
61
|
+
if ((await claimWebhookEvent(claimKey)) !== 'claimed') return NextResponse.json({ ok: true, duplicate: true })
|
|
62
|
+
|
|
63
|
+
const meta: Record<string, string> = {}
|
|
64
|
+
if (event.mail?.commonHeaders?.subject) meta.subject = event.mail.commonHeaders.subject
|
|
65
|
+
if (event.mail?.destination?.length) meta.to = event.mail.destination.join(', ')
|
|
66
|
+
if (event.bounce) {
|
|
67
|
+
meta.bounceType = [event.bounce.bounceType, event.bounce.bounceSubType].filter(Boolean).join(' / ')
|
|
68
|
+
const reasons = (event.bounce.bouncedRecipients ?? [])
|
|
69
|
+
.map(recipient => [recipient.emailAddress, recipient.diagnosticCode].filter(Boolean).join(': '))
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
if (reasons.length) meta.bounceMessage = reasons.join('; ')
|
|
72
|
+
}
|
|
73
|
+
if (event.complaint?.complaintFeedbackType) meta.complaint = event.complaint.complaintFeedbackType
|
|
74
|
+
if (event.deliveryDelay?.delayType) meta.delayType = event.deliveryDelay.delayType
|
|
75
|
+
if (event.reject?.reason) meta.failReason = event.reject.reason
|
|
76
|
+
if (event.failure?.errorMessage) meta.failReason = event.failure.errorMessage
|
|
77
|
+
|
|
78
|
+
const at = event.delivery?.timestamp ?? event.bounce?.timestamp ?? event.complaint?.timestamp
|
|
79
|
+
?? event.deliveryDelay?.timestamp ?? event.mail?.timestamp ?? new Date().toISOString()
|
|
80
|
+
try {
|
|
81
|
+
await appendEvent({ emailId, type, at, meta: Object.keys(meta).length ? meta : undefined })
|
|
82
|
+
} catch (err) {
|
|
83
|
+
await releaseWebhookEvent(claimKey)
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
await completeWebhookEvent(claimKey)
|
|
87
|
+
return NextResponse.json({ ok: true })
|
|
88
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
3
|
+
|
|
4
|
+
export const runtime = 'nodejs'
|
|
5
|
+
|
|
6
|
+
const server = () => process.env.LANGUAGETOOL_URL?.trim().replace(/\/+$/, '') || ''
|
|
7
|
+
|
|
8
|
+
// Spelling is checked in the browser against the company's own dictionaries; LanguageTool's
|
|
9
|
+
// spell rule would flag every client and insurer name a second time. The whitespace rule
|
|
10
|
+
// fires on the no-break spaces the Tab key inserts.
|
|
11
|
+
const SKIPPED_RULES = /^(MORFOLOGIK_RULE|HUNSPELL_RULE|WHITESPACE_RULE|CONSECUTIVE_SPACES|EN_QUOTES|DASH_RULE|UPPERCASE_SENTENCE_START|ENGLISH_WORD_REPEAT_RULE)/
|
|
12
|
+
|
|
13
|
+
export async function GET(req: Request) {
|
|
14
|
+
const guard = await mailAuthGuard(req)
|
|
15
|
+
if (guard) return guard
|
|
16
|
+
return NextResponse.json({ ok: true, available: Boolean(server()) })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function POST(req: Request) {
|
|
20
|
+
const guard = await mailAuthGuard(req)
|
|
21
|
+
if (guard) return guard
|
|
22
|
+
const base = server()
|
|
23
|
+
if (!base) return NextResponse.json({ ok: false, error: 'Grammar checking is not set up' }, { status: 503 })
|
|
24
|
+
let body: { text?: unknown; language?: unknown }
|
|
25
|
+
try {
|
|
26
|
+
body = await req.json()
|
|
27
|
+
} catch {
|
|
28
|
+
return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
|
|
29
|
+
}
|
|
30
|
+
const text = typeof body.text === 'string' ? body.text.slice(0, 20000) : ''
|
|
31
|
+
const language = body.language === 'en-US' ? 'en-US' : 'en-GB'
|
|
32
|
+
if (!text.trim()) return NextResponse.json({ ok: true, matches: [] })
|
|
33
|
+
const upstream = await fetch(`${base}/v2/check`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
36
|
+
body: new URLSearchParams({ text, language, level: 'default' }),
|
|
37
|
+
signal: AbortSignal.timeout(15000),
|
|
38
|
+
}).catch(() => null)
|
|
39
|
+
if (!upstream?.ok) return NextResponse.json({ ok: false, error: 'The grammar server did not answer' }, { status: 502 })
|
|
40
|
+
const data = (await upstream.json()) as {
|
|
41
|
+
matches?: Array<{ offset: number; length: number; message: string; replacements?: Array<{ value: string }>; rule?: { id?: string } }>
|
|
42
|
+
}
|
|
43
|
+
const matches = (data.matches ?? [])
|
|
44
|
+
.filter(match => !SKIPPED_RULES.test(match.rule?.id ?? ''))
|
|
45
|
+
.map(match => ({
|
|
46
|
+
offset: match.offset,
|
|
47
|
+
length: match.length,
|
|
48
|
+
message: match.message,
|
|
49
|
+
replacements: (match.replacements ?? []).slice(0, 5).map(entry => entry.value),
|
|
50
|
+
}))
|
|
51
|
+
return NextResponse.json({ ok: true, matches })
|
|
52
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
3
|
import { scopeFor } from '@/lib/scope'
|
|
4
|
-
import { listScheduled, cancelScheduled, rescheduleSend } from '@/lib/scheduled'
|
|
4
|
+
import { listScheduled, cancelScheduled, dispatchNow, rescheduleSend } from '@/lib/scheduled'
|
|
5
5
|
|
|
6
6
|
export const runtime = 'nodejs'
|
|
7
7
|
export const dynamic = 'force-dynamic'
|
|
@@ -46,3 +46,17 @@ export async function PATCH(req: Request) {
|
|
|
46
46
|
}
|
|
47
47
|
return NextResponse.json({ ok: true, scheduledAt: when.toISOString() })
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** Send a waiting message of the caller's now — the page calls this as its undo window closes. */
|
|
51
|
+
export async function POST(req: Request) {
|
|
52
|
+
const guard = await mailAuthGuard(req)
|
|
53
|
+
if (guard) return guard
|
|
54
|
+
const account = await resolveAccount(req)
|
|
55
|
+
const body = (await req.json().catch(() => null)) as { id?: string } | null
|
|
56
|
+
const id = body?.id ?? ''
|
|
57
|
+
if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
|
|
58
|
+
const outcome = await dispatchNow(id, account.address ?? '')
|
|
59
|
+
if (outcome === 'sent' || outcome === 'already-sent') return NextResponse.json({ ok: true, outcome })
|
|
60
|
+
const status = outcome === 'missing' ? 404 : outcome === 'not-due' ? 409 : 502
|
|
61
|
+
return NextResponse.json({ ok: false, outcome }, { status })
|
|
62
|
+
}
|