@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.
Files changed (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
@@ -0,0 +1,166 @@
1
+ 'use client'
2
+
3
+ import { CLIENT_BRAND } from '@/lib/brand.client'
4
+ import { use, useCallback, useEffect, useState } from 'react'
5
+ import styles from './share.module.css'
6
+
7
+ type Meta = {
8
+ filename: string
9
+ size: number
10
+ contentType: string | null
11
+ needsPassword: boolean
12
+ expiresAt: string | null
13
+ downloadsLeft: number | null
14
+ }
15
+
16
+ function readableSize(bytes: number): string {
17
+ if (bytes < 1024) return `${bytes} B`
18
+ const units = ['KB', 'MB', 'GB', 'TB']
19
+ let value = bytes / 1024
20
+ let unit = 0
21
+ while (value >= 1024 && unit < units.length - 1) {
22
+ value /= 1024
23
+ unit += 1
24
+ }
25
+ return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
26
+ }
27
+
28
+ /** Enough of a hint to set expectations before the file opens. */
29
+ function kindOf(filename: string, contentType: string | null): string {
30
+ const extension = filename.split('.').pop()?.toLowerCase() ?? ''
31
+ if (/^(jpg|jpeg|png|gif|webp|avif|heic|svg)$/.test(extension)) return 'Image'
32
+ if (/^(mp4|mov|webm|mkv|avi)$/.test(extension)) return 'Video'
33
+ if (/^(mp3|wav|m4a|aac|flac|ogg)$/.test(extension)) return 'Audio'
34
+ if (extension === 'pdf') return 'PDF'
35
+ if (/^(zip|rar|7z|tar|gz)$/.test(extension)) return 'Archive'
36
+ if (/^(doc|docx|rtf|odt)$/.test(extension)) return 'Document'
37
+ if (/^(xls|xlsx|csv|ods)$/.test(extension)) return 'Spreadsheet'
38
+ if (/^(ppt|pptx|key|odp)$/.test(extension)) return 'Presentation'
39
+ return contentType?.split('/')[0] ? `${contentType.split('/')[0]} file` : 'File'
40
+ }
41
+
42
+ export default function SharePage({ params }: { params: Promise<{ id: string }> }) {
43
+ const { id } = use(params)
44
+ const [meta, setMeta] = useState<Meta | null>(null)
45
+ const [gone, setGone] = useState(false)
46
+ const [password, setPassword] = useState('')
47
+ const [busy, setBusy] = useState(false)
48
+ const [error, setError] = useState('')
49
+ const [done, setDone] = useState(false)
50
+
51
+ useEffect(() => {
52
+ let cancelled = false
53
+ fetch(`/api/share/${id}`)
54
+ .then(async response => {
55
+ const data = await response.json().catch(() => null)
56
+ if (cancelled) return
57
+ if (!response.ok || !data?.ok) setGone(true)
58
+ else setMeta(data)
59
+ })
60
+ .catch(() => !cancelled && setGone(true))
61
+ return () => {
62
+ cancelled = true
63
+ }
64
+ }, [id])
65
+
66
+ const claim = useCallback(async () => {
67
+ setBusy(true)
68
+ setError('')
69
+ try {
70
+ const response = await fetch(`/api/share/${id}`, {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ password }),
74
+ })
75
+ const data = await response.json().catch(() => null)
76
+ if (!response.ok || !data?.ok) {
77
+ setError(data?.error ?? 'That did not work.')
78
+ return
79
+ }
80
+ setDone(true)
81
+ // Navigating rather than opening a tab: a popup blocker would eat the tab,
82
+ // and the signed URL only lasts two minutes.
83
+ window.location.href = data.url
84
+ } catch {
85
+ setError('Could not reach the server. Try again.')
86
+ } finally {
87
+ setBusy(false)
88
+ }
89
+ }, [id, password])
90
+
91
+ if (gone) {
92
+ return (
93
+ <main className={styles.wrap}>
94
+ <div className={styles.card}>
95
+ <span className={styles.mark} aria-hidden />
96
+ <h1 className={styles.title}>This link is no longer available</h1>
97
+ <p className={styles.sub}>
98
+ It may have expired, been used its allowed number of times, or been withdrawn by
99
+ whoever sent it. Ask them for a new one.
100
+ </p>
101
+ </div>
102
+ </main>
103
+ )
104
+ }
105
+
106
+ if (!meta) {
107
+ return (
108
+ <main className={styles.wrap}>
109
+ <div className={styles.card}>
110
+ <span className={styles.skelMark} />
111
+ <span className={styles.skelLine} />
112
+ <span className={styles.skelLineShort} />
113
+ </div>
114
+ </main>
115
+ )
116
+ }
117
+
118
+ return (
119
+ <main className={styles.wrap}>
120
+ <div className={styles.card}>
121
+ <span className={styles.mark} aria-hidden />
122
+ <p className={styles.eyebrow}>{CLIENT_BRAND.legalName}</p>
123
+ <h1 className={styles.title}>{meta.filename}</h1>
124
+ <p className={styles.meta}>
125
+ {kindOf(meta.filename, meta.contentType)} · {readableSize(meta.size)}
126
+ {meta.downloadsLeft != null && ` · ${meta.downloadsLeft} download${meta.downloadsLeft === 1 ? '' : 's'} left`}
127
+ </p>
128
+
129
+ {meta.needsPassword && !done && (
130
+ <label className={styles.field}>
131
+ <span>Password</span>
132
+ <input
133
+ type="password"
134
+ value={password}
135
+ autoFocus
136
+ placeholder="The password you were given"
137
+ onChange={event => setPassword(event.target.value)}
138
+ onKeyDown={event => event.key === 'Enter' && password && claim()}
139
+ />
140
+ </label>
141
+ )}
142
+
143
+ {error && <p className={styles.error} role="alert">{error}</p>}
144
+
145
+ <button
146
+ type="button"
147
+ className={styles.go}
148
+ disabled={busy || done || (meta.needsPassword && !password)}
149
+ onClick={claim}
150
+ >
151
+ {done ? 'Starting download…' : busy ? 'Checking…' : 'Download'}
152
+ </button>
153
+
154
+ {meta.expiresAt && (
155
+ <p className={styles.foot}>
156
+ Available until {new Date(meta.expiresAt).toLocaleDateString('en-GB', {
157
+ day: 'numeric',
158
+ month: 'long',
159
+ year: 'numeric',
160
+ })}
161
+ </p>
162
+ )}
163
+ </div>
164
+ </main>
165
+ )
166
+ }
@@ -0,0 +1,148 @@
1
+ /* The one screen a recipient sees, and they are not a mailbox holder, so it
2
+ carries its own tokens rather than depending on the app's theme. */
3
+
4
+ @keyframes shareIn {
5
+ from { opacity: 0; transform: translate3d(0, 12px, 0) scale(.98); }
6
+ to { opacity: 1; transform: none; }
7
+ }
8
+ @keyframes sharePulse {
9
+ 0%, 100% { opacity: .55; }
10
+ 50% { opacity: 1; }
11
+ }
12
+
13
+ .wrap {
14
+ min-height: 100dvh;
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: center;
18
+ padding: 24px;
19
+ background: #f7f7fa;
20
+ font-family: Calibri, Carlito, 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
21
+ }
22
+
23
+ .card {
24
+ width: 100%;
25
+ max-width: 420px;
26
+ padding: 34px 32px 28px;
27
+ border: 1px solid #e4e4ec;
28
+ border-radius: 16px;
29
+ background: #fff;
30
+ box-shadow: 0 18px 48px rgba(8, 6, 16, .10);
31
+ text-align: center;
32
+ animation: shareIn .3s cubic-bezier(.2, .7, .3, 1) both;
33
+ }
34
+
35
+ .mark {
36
+ display: block;
37
+ width: 38px;
38
+ height: 28px;
39
+ margin: 0 auto 18px;
40
+ background: #6d28d9;
41
+ mask: url('/brand/mark.png') no-repeat center / contain;
42
+ -webkit-mask: url('/brand/mark.png') no-repeat center / contain;
43
+ }
44
+
45
+ .eyebrow {
46
+ margin: 0 0 8px;
47
+ font-size: 11px;
48
+ font-weight: 600;
49
+ letter-spacing: .14em;
50
+ text-transform: uppercase;
51
+ color: #9a96a6;
52
+ }
53
+
54
+ .title {
55
+ margin: 0;
56
+ font-size: 19px;
57
+ font-weight: 700;
58
+ line-height: 1.35;
59
+ color: #16121f;
60
+ word-break: break-word;
61
+ text-wrap: balance;
62
+ }
63
+
64
+ .meta {
65
+ margin: 8px 0 0;
66
+ font-size: 13px;
67
+ color: #6e6a7c;
68
+ }
69
+
70
+ .field {
71
+ display: block;
72
+ margin-top: 22px;
73
+ text-align: left;
74
+ }
75
+ .field span {
76
+ display: block;
77
+ margin-bottom: 6px;
78
+ font-size: 11px;
79
+ font-weight: 600;
80
+ letter-spacing: .08em;
81
+ text-transform: uppercase;
82
+ color: #6e6a7c;
83
+ }
84
+ .field input {
85
+ width: 100%;
86
+ padding: 11px 13px;
87
+ border: 1px solid #d3d3de;
88
+ border-radius: 10px;
89
+ background: #fff;
90
+ font: inherit;
91
+ font-size: 15px;
92
+ color: #16121f;
93
+ transition: border-color .16s ease, box-shadow .16s ease;
94
+ }
95
+ .field input:focus {
96
+ outline: none;
97
+ border-color: #6d28d9;
98
+ box-shadow: 0 0 0 3px rgba(169, 3, 23, .12);
99
+ }
100
+
101
+ .error {
102
+ margin: 12px 0 0;
103
+ font-size: 13px;
104
+ color: #c0261f;
105
+ }
106
+
107
+ .go {
108
+ width: 100%;
109
+ margin-top: 20px;
110
+ padding: 12px 18px;
111
+ border: 1px solid transparent;
112
+ border-radius: 10px;
113
+ background: #6d28d9;
114
+ color: #fff;
115
+ font: inherit;
116
+ font-size: 15px;
117
+ font-weight: 600;
118
+ cursor: pointer;
119
+ transition: background .16s ease, transform .16s ease;
120
+ }
121
+ .go:hover:not(:disabled) { background: #8e0213; transform: translateY(-1px); }
122
+ .go:active:not(:disabled) { transform: translateY(0) scale(.99); }
123
+ .go:disabled { opacity: .55; cursor: default; }
124
+
125
+ .foot {
126
+ margin: 16px 0 0;
127
+ font-size: 12px;
128
+ color: #9a96a6;
129
+ }
130
+
131
+ /* Shown while the file's details are still being fetched. */
132
+ .skelMark, .skelLine, .skelLineShort {
133
+ display: block;
134
+ margin: 0 auto;
135
+ border-radius: 8px;
136
+ background: #eceaf1;
137
+ animation: sharePulse 1.4s ease-in-out infinite;
138
+ }
139
+ .skelMark { width: 38px; height: 28px; margin-bottom: 20px; }
140
+ .skelLine { width: 78%; height: 17px; margin-bottom: 10px; }
141
+ .skelLineShort { width: 46%; height: 13px; }
142
+
143
+ @media (prefers-reduced-motion: reduce) {
144
+ .card { animation: none; }
145
+ .skelMark, .skelLine, .skelLineShort { animation: none; }
146
+ .go, .field input { transition: none; }
147
+ .go:hover:not(:disabled), .go:active:not(:disabled) { transform: none; }
148
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig, globalIgnores } from 'eslint/config'
2
+ import nextVitals from 'eslint-config-next/core-web-vitals'
3
+ import nextTs from 'eslint-config-next/typescript'
4
+
5
+ export default defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts', 'public/sw.js']),
9
+ ])
@@ -0,0 +1,54 @@
1
+ export type ThemeBase = 'dark' | 'dim' | 'light'
2
+
3
+ export const ACCENT_RAMP: Record<ThemeBase, Record<string, number>> = {
4
+ dark: { 100: 92, 200: 84, 300: 73, 400: 62, 500: 52, 600: 44, 700: 37, 800: 25, 900: 16 },
5
+ dim: { 100: 90, 200: 82, 300: 71, 400: 60, 500: 51, 600: 43, 700: 36, 800: 27, 900: 20 },
6
+ light: { 100: 34, 200: 40, 300: 46, 400: 46, 500: 46, 600: 55, 700: 70, 800: 93, 900: 96 },
7
+ }
8
+
9
+ export function hexToHsl(hex: string): { h: number; s: number; l: number } | null {
10
+ const clean = hex.replace('#', '')
11
+ const full = clean.length === 3 ? clean.split('').map(char => char + char).join('') : clean
12
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) return null
13
+ const red = parseInt(full.slice(0, 2), 16) / 255
14
+ const green = parseInt(full.slice(2, 4), 16) / 255
15
+ const blue = parseInt(full.slice(4, 6), 16) / 255
16
+ const max = Math.max(red, green, blue)
17
+ const min = Math.min(red, green, blue)
18
+ const lightness = (max + min) / 2
19
+ if (max === min) return { h: 0, s: 0, l: lightness * 100 }
20
+ const delta = max - min
21
+ const saturation = delta / (1 - Math.abs(2 * lightness - 1))
22
+ let hue: number
23
+ if (max === red) hue = ((green - blue) / delta) % 6
24
+ else if (max === green) hue = (blue - red) / delta + 2
25
+ else hue = (red - green) / delta + 4
26
+ return { h: (hue * 60 + 360) % 360, s: saturation * 100, l: lightness * 100 }
27
+ }
28
+
29
+ /**
30
+ * The accent scale for one base. The 500 stop is the colour every primary control is
31
+ * painted in, so it is the configured accent exactly rather than a shade near it; the
32
+ * other stops slide by the same amount so the scale keeps its shape.
33
+ */
34
+ export function accentScale(hex: string, base: ThemeBase = 'light'): Record<string, string> {
35
+ const hsl = hexToHsl(hex)
36
+ if (!hsl) return {}
37
+ const saturation = Math.max(28, Math.min(92, hsl.s))
38
+ const shift = hsl.l - ACCENT_RAMP[base][500]
39
+ const scale: Record<string, string> = {}
40
+ for (const [stop, lightness] of Object.entries(ACCENT_RAMP[base])) {
41
+ const shifted = Math.max(4, Math.min(97, lightness + shift))
42
+ scale[`--nc-violet-${stop}`] =
43
+ stop === '500'
44
+ ? `hsl(${hsl.h.toFixed(0)} ${hsl.s.toFixed(0)}% ${hsl.l.toFixed(0)}%)`
45
+ : `hsl(${hsl.h.toFixed(0)} ${saturation.toFixed(0)}% ${shifted.toFixed(0)}%)`
46
+ }
47
+ return scale
48
+ }
49
+
50
+ export function accentRampCss(hex: string, base: ThemeBase = 'light'): string {
51
+ return Object.entries(accentScale(hex, base))
52
+ .map(([name, value]) => `${name}:${value}`)
53
+ .join(';')
54
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Which files are part of the message and which are attached to it.
3
+ *
4
+ * A signature logo and a footer badge arrive as ordinary MIME parts, so a mailbox that
5
+ * lists everything shows files on a message whose sender attached nothing.
6
+ *
7
+ * A Content-ID alone does not settle it. Forwarding through Outlook stamps one onto
8
+ * every image part, including photographs the sender genuinely attached, so treating
9
+ * any image that has one as decoration hides real files — accident photographs, in the
10
+ * case that found this. The body is the authority: a part is embedded when the message
11
+ * points at it, and that pointer is the whole reason it was given an id.
12
+ *
13
+ * Where the body cannot be read, everything is shown. Listing a signature logo is
14
+ * untidy; hiding a document somebody sent looks like the mail arrived broken.
15
+ */
16
+
17
+ export type FileMeta = {
18
+ filename?: string
19
+ contentType?: string
20
+ size?: number
21
+ key?: string
22
+ contentId?: string
23
+ [extra: string]: unknown
24
+ }
25
+
26
+ /** Every `cid:` the body points at, lowercased and unwrapped. */
27
+ export function referencedCids(html: string | null | undefined): Set<string> {
28
+ const found = new Set<string>()
29
+ if (!html) return found
30
+ for (const match of html.matchAll(/cid:([^"'\s>)\\]+)/gi)) {
31
+ found.add(match[1].replace(/^<|>$/g, '').trim().toLowerCase())
32
+ }
33
+ return found
34
+ }
35
+
36
+ /**
37
+ * Only images are ever hidden, and only when the body draws them. A Content-ID on a PDF
38
+ * means a sender referenced a real document, which is still something they attached.
39
+ */
40
+ export function isEmbedded(file: FileMeta, referenced: Set<string>): boolean {
41
+ if (referenced.size === 0) return false
42
+ const contentId = typeof file.contentId === 'string' ? file.contentId.replace(/^<|>$/g, '').trim().toLowerCase() : ''
43
+ if (!contentId) return false
44
+ if (!String(file.contentType ?? '').toLowerCase().startsWith('image/')) return false
45
+ return referenced.has(contentId)
46
+ }
47
+
48
+ /** The files a reader should be offered, in their original order. */
49
+ export function attachedFiles<T extends FileMeta>(files: T[], html?: string | null): T[] {
50
+ const referenced = referencedCids(html)
51
+ return files.filter(file => !isEmbedded(file, referenced))
52
+ }
@@ -0,0 +1,52 @@
1
+ const name = process.env.NEXT_PUBLIC_BRAND_NAME?.trim() || 'Mailbox'
2
+ const domain = process.env.NEXT_PUBLIC_MAIL_DOMAIN?.trim().toLowerCase() || 'example.com'
3
+ const website = (process.env.NEXT_PUBLIC_BRAND_WEBSITE?.trim() || domain).replace(/^https?:\/\//, '').replace(/\/$/, '')
4
+ const publicUrl = (process.env.NEXT_PUBLIC_MAIL_PUBLIC_URL?.trim() || `https://mail.${domain}`).replace(/\/$/, '')
5
+
6
+ const legalName = process.env.NEXT_PUBLIC_BRAND_LEGAL_NAME?.trim() || name
7
+
8
+ export const CLIENT_BRAND = {
9
+ name,
10
+ legalName,
11
+ address: process.env.NEXT_PUBLIC_BRAND_ADDRESS?.trim() || '',
12
+ tel: process.env.NEXT_PUBLIC_BRAND_TEL?.trim() || '',
13
+ regulatory: process.env.NEXT_PUBLIC_BRAND_REGULATORY?.trim() || '',
14
+ disclaimer:
15
+ process.env.NEXT_PUBLIC_BRAND_DISCLAIMER?.trim() ||
16
+ `DISCLAIMER: The information contained in this e-mail is proprietary to ${legalName} and is intended only for the individual or entity to which it is addressed. It may contain information that is privileged, confidential or exempt from disclosure under applicable law. If you are not the intended recipient, any use, distribution, transmission, printing, copying or dissemination of this information is strictly prohibited. If you have received this communication in error, please delete it and notify us.`,
17
+ colors: {
18
+ accent: process.env.NEXT_PUBLIC_BRAND_COLOR_ACCENT?.trim() || 'rgb(109, 40, 217)',
19
+ link: process.env.NEXT_PUBLIC_BRAND_COLOR_LINK?.trim() || 'rgb(17, 85, 204)',
20
+ muted: process.env.NEXT_PUBLIC_BRAND_COLOR_MUTED?.trim() || 'rgb(107, 114, 128)',
21
+ },
22
+ slug: name.toLowerCase().replace(/[^a-z0-9]+/g, '') || 'mailbox',
23
+ domain,
24
+ website,
25
+ websiteUrl: `https://${website}`,
26
+ publicUrl,
27
+ markUrl: process.env.NEXT_PUBLIC_BRAND_MARK_URL?.trim() || `${publicUrl}/icon-192.png`,
28
+ accent: process.env.NEXT_PUBLIC_BRAND_ACCENT?.trim() || '#6d28d9',
29
+ chromeMarkUrl: process.env.NEXT_PUBLIC_BRAND_CHROME_MARK_URL?.trim() || '/brand/mark.png',
30
+ signatureMark: {
31
+ border: process.env.NEXT_PUBLIC_BRAND_SIGNATURE_MARK_BORDER?.trim() || 'none',
32
+ radius: process.env.NEXT_PUBLIC_BRAND_SIGNATURE_MARK_RADIUS?.trim() || '0',
33
+ },
34
+ addressDomains: (process.env.NEXT_PUBLIC_MAIL_ADDRESS_DOMAINS ?? '')
35
+ .split(',')
36
+ .map(value => value.trim().toLowerCase())
37
+ .filter(Boolean),
38
+ addresses: (process.env.NEXT_PUBLIC_MAIL_ADDRESSES ?? '')
39
+ .split(',')
40
+ .map(value => value.trim().toLowerCase())
41
+ .filter(Boolean),
42
+ } as const
43
+
44
+ export const LS = (key: string) => `${CLIENT_BRAND.slug}_mail_${key}`
45
+
46
+ /** Browser twin of signatureMarkStyle in lib/brand.ts; the two must agree or the preview lies about the sent mail. */
47
+ export function clientSignatureMarkStyle(width: number): string {
48
+ const { border, radius } = CLIENT_BRAND.signatureMark
49
+ const colour = border === 'accent' ? CLIENT_BRAND.accent : border
50
+ const frame = !colour || colour === 'none' ? 'border:0;' : `padding:8px;border:1px solid ${colour};border-radius:${radius}px;`
51
+ return `display:block;width:${width}px;height:auto;${frame}`
52
+ }
package/lib/brand.ts ADDED
@@ -0,0 +1,87 @@
1
+ export type MailRole = 'admin' | 'member'
2
+ export type MailSeat = { email: string; address: string; name: string; role: MailRole }
3
+
4
+ const env = (key: string, fallback = '') => (process.env[key] ?? fallback).trim()
5
+
6
+ function json<T>(key: string, fallback: T): T {
7
+ const raw = process.env[key]
8
+ if (!raw || !raw.trim()) return fallback
9
+ try {
10
+ return JSON.parse(raw) as T
11
+ } catch {
12
+ return fallback
13
+ }
14
+ }
15
+
16
+ const domain = env('MAIL_ADDRESS_DOMAIN', 'example.com').toLowerCase()
17
+ const name = env('BRAND_NAME', 'Mailbox')
18
+ const website = env('BRAND_WEBSITE', domain).replace(/^https?:\/\//, '').replace(/\/$/, '')
19
+ const publicUrl = env('MAIL_PUBLIC_URL', `https://mail.${domain}`).replace(/\/$/, '')
20
+
21
+ export const BRAND = {
22
+ name,
23
+ legalName: env('BRAND_LEGAL_NAME', name),
24
+ domain,
25
+ website,
26
+ websiteUrl: `https://${website}`,
27
+ publicUrl,
28
+ logoUrl: env('BRAND_LOGO_URL', `${publicUrl}/brand/mark-email.png`),
29
+ markUrl: env('BRAND_MARK_URL', `${publicUrl}/icon-192.png`),
30
+ address: env('BRAND_ADDRESS'),
31
+ tel: env('BRAND_TEL'),
32
+ regulatory: env('BRAND_REGULATORY'),
33
+ disclaimer: env(
34
+ 'BRAND_DISCLAIMER',
35
+ `DISCLAIMER: The information contained in this e-mail is proprietary to ${env('BRAND_LEGAL_NAME', name)} and is intended only for the individual or entity to which it is addressed. It may contain information that is privileged, confidential or exempt from disclosure under applicable law. If you are not the intended recipient, any use, distribution, transmission, printing, copying or dissemination of this information is strictly prohibited. If you have received this communication in error, please delete it and notify us.`,
36
+ ),
37
+ colors: {
38
+ accent: env('BRAND_COLOR_ACCENT', 'rgb(109, 40, 217)'),
39
+ link: env('BRAND_COLOR_LINK', 'rgb(17, 85, 204)'),
40
+ muted: env('BRAND_COLOR_MUTED', 'rgb(107, 114, 128)'),
41
+ },
42
+ chromeMarkUrl: env('BRAND_CHROME_MARK_URL', '/brand/mark.png'),
43
+ signatureMark: {
44
+ border: env('BRAND_SIGNATURE_MARK_BORDER', 'none'),
45
+ radius: env('BRAND_SIGNATURE_MARK_RADIUS', '0'),
46
+ },
47
+ iconUrl: env('BRAND_ICON_URL'),
48
+ appleIconUrl: env('BRAND_APPLE_ICON_URL', env('BRAND_ICON_URL')),
49
+ accentHex: env('BRAND_ACCENT_HEX', env('NEXT_PUBLIC_BRAND_ACCENT', '#6d28d9')),
50
+ supportEmail: env('MAIL_SUPPORT_EMAIL', `info@${domain}`),
51
+ vapidSubject: env('VAPID_SUBJECT', `mailto:info@${domain}`),
52
+ } as const
53
+
54
+ const addressDomains: string[] = (() => {
55
+ const extra = env('MAIL_ADDRESS_DOMAINS')
56
+ .split(',')
57
+ .map(entry => entry.trim().toLowerCase())
58
+ .filter(Boolean)
59
+ const preferred = env('MAIL_DEFAULT_ADDRESS_DOMAIN').toLowerCase() || domain
60
+ return [...new Set([preferred, domain, ...extra])]
61
+ })()
62
+
63
+ export const ADDRESS_DOMAINS = addressDomains
64
+
65
+ /**
66
+ * A mailbox a fresh checkout can sign into, so the package can be run and tried with no
67
+ * configuration at all. Its password is its own address, so it is never seeded into a
68
+ * production deployment.
69
+ */
70
+ const DEV_SEAT: MailSeat = { email: `test@${domain}`, address: `test@${domain}`, name: 'Test Account', role: 'admin' }
71
+
72
+ export const MAIL_SEATS: MailSeat[] = (() => {
73
+ const configured = json<MailSeat[]>('MAIL_SEATS', [])
74
+ if (process.env.NODE_ENV === 'production') return configured
75
+ return configured.some(seat => seat.email.toLowerCase() === DEV_SEAT.email) ? configured : [...configured, DEV_SEAT]
76
+ })()
77
+ export const ADDRESS_ALIASES: Record<string, string> = json<Record<string, string>>('MAIL_ADDRESS_ALIASES', {})
78
+
79
+ export const brandSlug = BRAND.name.toLowerCase().replace(/[^a-z0-9]+/g, '')
80
+
81
+ /** Inline style for the mark in a signature: a frame in the accent when the tenant asks for one, nothing otherwise. */
82
+ export function signatureMarkStyle(width: number): string {
83
+ const { border, radius } = BRAND.signatureMark
84
+ const colour = border === 'accent' ? BRAND.colors.accent : border
85
+ const frame = !colour || colour === 'none' ? 'border:0;' : `padding:8px;border:1px solid ${colour};border-radius:${radius}px;`
86
+ return `display:block;width:${width}px;height:auto;${frame}`
87
+ }
package/lib/d1.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Cloudflare D1 over the REST API, shaped like the `neon()` tagged-template client so
3
+ * query call sites read the same. The app runs on Vercel, so there is no Worker binding
4
+ * available — every statement is an HTTPS round-trip to Cloudflare.
5
+ */
6
+
7
+ type Param = string | number | null
8
+ export type D1Sql = (strings: TemplateStringsArray, ...values: unknown[]) => Promise<Record<string, unknown>[]>
9
+
10
+ const API = 'https://api.cloudflare.com/client/v4'
11
+
12
+ function config() {
13
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
14
+ const databaseId = process.env.D1_DATABASE_ID
15
+ const token = process.env.CLOUDFLARE_D1_TOKEN
16
+ if (!accountId || !databaseId || !token) {
17
+ throw new Error('CLOUDFLARE_ACCOUNT_ID, D1_DATABASE_ID and CLOUDFLARE_D1_TOKEN must be configured')
18
+ }
19
+ return { accountId, databaseId, token }
20
+ }
21
+
22
+ /** SQLite has no boolean or object types — normalise before binding. */
23
+ function toParam(value: unknown): Param {
24
+ if (value === undefined || value === null) return null
25
+ if (typeof value === 'boolean') return value ? 1 : 0
26
+ if (typeof value === 'number') return value
27
+ if (value instanceof Date) return value.toISOString()
28
+ if (typeof value === 'object') return JSON.stringify(value)
29
+ return String(value)
30
+ }
31
+
32
+ export async function d1Query(sql: string, params: unknown[] = []): Promise<Record<string, unknown>[]> {
33
+ const { accountId, databaseId, token } = config()
34
+ const response = await fetch(`${API}/accounts/${accountId}/d1/database/${databaseId}/query`, {
35
+ method: 'POST',
36
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ sql, params: params.map(toParam) }),
38
+ })
39
+ const payload = (await response.json()) as {
40
+ success?: boolean
41
+ errors?: Array<{ message?: string }>
42
+ result?: Array<{ results?: Record<string, unknown>[]; success?: boolean; error?: string }>
43
+ }
44
+ if (!response.ok || !payload.success) {
45
+ const detail = payload.errors?.map(entry => entry.message).filter(Boolean).join('; ') || `HTTP ${response.status}`
46
+ throw new Error(`D1 query failed: ${detail}`)
47
+ }
48
+ const first = payload.result?.[0]
49
+ if (first?.error) throw new Error(`D1 query failed: ${first.error}`)
50
+ return first?.results ?? []
51
+ }
52
+
53
+ /** Tagged-template entry point: interpolations become bound `?` parameters. */
54
+ export function d1(): D1Sql {
55
+ return (strings, ...values) => d1Query(strings.raw.join('?'), values)
56
+ }
57
+
58
+ /** Run several statements in order — used for schema setup. */
59
+ export async function d1Batch(statements: string[]): Promise<void> {
60
+ for (const statement of statements) {
61
+ const trimmed = statement.trim()
62
+ if (trimmed) await d1Query(trimmed)
63
+ }
64
+ }