@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.
Files changed (42) hide show
  1. package/.env.example +8 -2
  2. package/app/api/mail/company-dictionary/route.ts +36 -0
  3. package/app/api/mail/company-signature/route.ts +1 -1
  4. package/app/api/mail/events/ses/route.ts +88 -0
  5. package/app/api/mail/grammar/route.ts +52 -0
  6. package/app/api/mail/scheduled/route.ts +15 -1
  7. package/app/mail/RichEditor.tsx +450 -5
  8. package/app/mail/page.module.css +275 -0
  9. package/app/mail/page.tsx +115 -12
  10. package/app/mail/tabKey.ts +52 -0
  11. package/app/mail/writing/WritingPanel.tsx +259 -0
  12. package/app/mail/writing/assist.ts +139 -0
  13. package/app/mail/writing/checks.test.ts +48 -0
  14. package/app/mail/writing/checks.ts +58 -0
  15. package/app/mail/writing/find.ts +52 -0
  16. package/app/mail/writing/grammar.ts +100 -0
  17. package/app/mail/writing/live.ts +34 -0
  18. package/app/mail/writing/paste.test.ts +17 -0
  19. package/app/mail/writing/paste.ts +30 -0
  20. package/app/mail/writing/settings.ts +98 -0
  21. package/app/mail/writing/spell.worker.ts +40 -0
  22. package/app/mail/writing/spelling.ts +164 -0
  23. package/app/mail/writing/templates.ts +40 -0
  24. package/app/mail/writing/text.test.ts +88 -0
  25. package/app/mail/writing/text.ts +106 -0
  26. package/lib/dev-auth.ts +5 -3
  27. package/lib/dmarc-report.test.ts +18 -0
  28. package/lib/mail-provider.ts +1 -2
  29. package/lib/mailbox.ts +7 -2
  30. package/lib/receive.test.ts +24 -0
  31. package/lib/receive.ts +55 -11
  32. package/lib/scheduled.ts +67 -33
  33. package/lib/ses-send.test.ts +21 -0
  34. package/lib/ses-send.ts +29 -5
  35. package/lib/sns.ts +68 -0
  36. package/package.json +3 -1
  37. package/public/dictionaries/en-GB.LICENSE +347 -0
  38. package/public/dictionaries/en-GB.aff +205 -0
  39. package/public/dictionaries/en-GB.dic +49602 -0
  40. package/public/dictionaries/en-US.LICENSE +347 -0
  41. package/public/dictionaries/en-US.aff +205 -0
  42. 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 an outside address. Leave empty to forward
62
- # nothing — pointing this at an address this app receives would loop.
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
+ }