@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,859 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { EditorContent, NodeViewWrapper, ReactNodeViewRenderer, useEditor, type Editor, type NodeViewProps } from '@tiptap/react'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import Underline from '@tiptap/extension-underline'
7
+ import Link from '@tiptap/extension-link'
8
+ import Image from '@tiptap/extension-image'
9
+ import { CLIENT_BRAND } from '@/lib/brand.client'
10
+ import TextAlign from '@tiptap/extension-text-align'
11
+ import { FontFamily, FontSize, TextStyle } from '@tiptap/extension-text-style'
12
+ import { BUILTIN_FONTS, FONT_SIZES, fontStack, type BaseFont } from '@/lib/fonts'
13
+ import MailSelect from './MailSelect'
14
+ import { Color } from '@tiptap/extension-color'
15
+ import Highlight from '@tiptap/extension-highlight'
16
+ import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
17
+ import { safeHref } from '@/lib/email-html'
18
+ import styles from './page.module.css'
19
+
20
+ const TEXT_COLOURS = ['#030712', '#b91c1c', '#1d4ed8', '#047857', '#b45309', '#6d28d9', '#6b7280']
21
+ const HIGHLIGHTS = ['#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#FED7AA']
22
+ function Group({ children }: { children: React.ReactNode }) {
23
+ return <div className={styles.rteGroup}>{children}</div>
24
+ }
25
+
26
+ function Btn({
27
+ onClick,
28
+ active,
29
+ title,
30
+ children,
31
+ wide,
32
+ }: {
33
+ onClick: () => void
34
+ active?: boolean
35
+ title: string
36
+ children: React.ReactNode
37
+ wide?: boolean
38
+ }) {
39
+ return (
40
+ <button
41
+ type="button"
42
+ title={title}
43
+ aria-label={title}
44
+ aria-pressed={active}
45
+ onMouseDown={event => event.preventDefault()} // keep the selection while clicking
46
+ onClick={onClick}
47
+ className={`${styles.rteBtn} ${wide ? styles.rteBtnWide : ''} ${active ? styles.rteBtnOn : ''}`}
48
+ >
49
+ {children}
50
+ </button>
51
+ )
52
+ }
53
+
54
+ function Field({
55
+ label,
56
+ value,
57
+ onChange,
58
+ placeholder,
59
+ autoFocus,
60
+ onEnter,
61
+ }: {
62
+ label: string
63
+ value: string
64
+ onChange: (next: string) => void
65
+ placeholder?: string
66
+ autoFocus?: boolean
67
+ onEnter?: () => void
68
+ }) {
69
+ return (
70
+ <label className={styles.rtePopField}>
71
+ <span>{label}</span>
72
+ <input
73
+ value={value}
74
+ autoFocus={autoFocus}
75
+ placeholder={placeholder}
76
+ onChange={event => onChange(event.target.value)}
77
+ onKeyDown={event => {
78
+ if (event.key === 'Enter' && onEnter) {
79
+ event.preventDefault()
80
+ onEnter()
81
+ }
82
+ }}
83
+ />
84
+ </label>
85
+ )
86
+ }
87
+
88
+ function LinkPopover({ editor, close }: { editor: Editor; close: () => void }) {
89
+ const active = editor.isActive('link')
90
+ const [href, setHref] = useState(() => (editor.getAttributes('link').href as string) ?? '')
91
+ const [text, setText] = useState(() => {
92
+ const { from, to } = editor.state.selection
93
+ return from === to ? '' : editor.state.doc.textBetween(from, to, ' ')
94
+ })
95
+
96
+ const apply = () => {
97
+ const url = safeHref(href)
98
+ if (!url) return
99
+ const label = text.trim()
100
+ const chain = editor.chain().focus().extendMarkRange('link')
101
+ // With no selection there is nothing to mark, so the label (or the address) becomes the text.
102
+ if (label || editor.state.selection.empty) {
103
+ chain.insertContent({ type: 'text', text: label || url, marks: [{ type: 'link', attrs: { href: url } }] }).run()
104
+ } else {
105
+ chain.setLink({ href: url }).run()
106
+ }
107
+ close()
108
+ }
109
+
110
+ return (
111
+ <div className={styles.rtePop} role="dialog" aria-label="Insert link">
112
+ <Field label="Text to display" value={text} onChange={setText} placeholder="Our policy page" autoFocus onEnter={apply} />
113
+ <Field label="Address" value={href} onChange={setHref} placeholder="example.com" onEnter={apply} />
114
+ <div className={styles.rtePopActions}>
115
+ {active && (
116
+ <button
117
+ type="button"
118
+ className={styles.rtePopBtn}
119
+ onClick={() => {
120
+ editor.chain().focus().extendMarkRange('link').unsetLink().run()
121
+ close()
122
+ }}
123
+ >
124
+ Remove link
125
+ </button>
126
+ )}
127
+ <span className={styles.rtePopSpacer} />
128
+ <button type="button" className={styles.rtePopBtn} onClick={close}>
129
+ Cancel
130
+ </button>
131
+ <button type="button" className={styles.rtePopBtnPrimary} disabled={!safeHref(href)} onClick={apply}>
132
+ {active ? 'Update' : 'Insert'}
133
+ </button>
134
+ </div>
135
+ </div>
136
+ )
137
+ }
138
+
139
+ function ImagePopover({
140
+ editor,
141
+ close,
142
+ upload,
143
+ }: {
144
+ editor: Editor
145
+ close: () => void
146
+ upload?: (file: File) => Promise<string>
147
+ }) {
148
+ const [src, setSrc] = useState('')
149
+ const [alt, setAlt] = useState('')
150
+ const [busy, setBusy] = useState(false)
151
+ const [error, setError] = useState('')
152
+
153
+ const apply = (address = src) => {
154
+ const url = safeHref(address)
155
+ if (!url) return
156
+ editor.chain().focus().setImage({ src: url, alt: alt.trim() || undefined }).run()
157
+ close()
158
+ }
159
+
160
+ const pick = async (file: File) => {
161
+ if (!upload) return
162
+ setBusy(true)
163
+ setError('')
164
+ try {
165
+ const url = await upload(file)
166
+ setSrc(url)
167
+ editor.chain().focus().setImage({ src: url, alt: alt.trim() || undefined }).run()
168
+ close()
169
+ } catch (err) {
170
+ setError(err instanceof Error ? err.message : 'That image could not be uploaded.')
171
+ } finally {
172
+ setBusy(false)
173
+ }
174
+ }
175
+
176
+ return (
177
+ <div className={styles.rtePop} role="dialog" aria-label="Insert image">
178
+ {upload && (
179
+ <>
180
+ <label className={`${styles.rtePopUpload} ${busy ? styles.rtePopUploadBusy : ''}`}>
181
+ {busy ? 'Uploading…' : 'Choose a file…'}
182
+ <input
183
+ type="file"
184
+ accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
185
+ disabled={busy}
186
+ onChange={event => {
187
+ const file = event.target.files?.[0]
188
+ event.target.value = ''
189
+ if (file) void pick(file)
190
+ }}
191
+ />
192
+ </label>
193
+ <div className={styles.rtePopOr}>or paste an address</div>
194
+ </>
195
+ )}
196
+ <Field label="Address" value={src} onChange={setSrc} placeholder="https://…/photo.png" autoFocus={!upload} onEnter={() => apply()} />
197
+ <Field label="Description" value={alt} onChange={setAlt} placeholder="Shown if the image cannot load" onEnter={() => apply()} />
198
+ {error && <p className={styles.rtePopError}>{error}</p>}
199
+ <div className={styles.rtePopActions}>
200
+ <span className={styles.rtePopSpacer} />
201
+ <button type="button" className={styles.rtePopBtn} onClick={close}>
202
+ Cancel
203
+ </button>
204
+ <button type="button" className={styles.rtePopBtnPrimary} disabled={!safeHref(src) || busy} onClick={() => apply()}>
205
+ Insert
206
+ </button>
207
+ </div>
208
+ </div>
209
+ )
210
+ }
211
+
212
+ const GRID = 6
213
+
214
+ function TablePopover({ editor, close }: { editor: Editor; close: () => void }) {
215
+ const [hover, setHover] = useState({ rows: 0, cols: 0 })
216
+ const [header, setHeader] = useState(true)
217
+
218
+ const insert = (rows: number, cols: number) => {
219
+ editor.chain().focus().insertTable({ rows, cols, withHeaderRow: header }).run()
220
+ close()
221
+ }
222
+
223
+ return (
224
+ <div className={styles.rtePop} role="dialog" aria-label="Insert table">
225
+ <div className={styles.rteGrid} onMouseLeave={() => setHover({ rows: 0, cols: 0 })}>
226
+ {Array.from({ length: GRID }, (_, row) =>
227
+ Array.from({ length: GRID }, (_, col) => (
228
+ <button
229
+ key={`${row}-${col}`}
230
+ type="button"
231
+ aria-label={`${row + 1} by ${col + 1} table`}
232
+ className={`${styles.rteGridCell} ${row < hover.rows && col < hover.cols ? styles.rteGridCellOn : ''}`}
233
+ onMouseEnter={() => setHover({ rows: row + 1, cols: col + 1 })}
234
+ onFocus={() => setHover({ rows: row + 1, cols: col + 1 })}
235
+ onClick={() => insert(row + 1, col + 1)}
236
+ />
237
+ )),
238
+ )}
239
+ </div>
240
+ <div className={styles.rteGridLabel}>{hover.rows ? `${hover.rows} × ${hover.cols}` : 'Pick a size'}</div>
241
+ <label className={styles.rtePopCheck}>
242
+ <input type="checkbox" checked={header} onChange={event => setHeader(event.target.checked)} />
243
+ <span>Header row</span>
244
+ </label>
245
+ </div>
246
+ )
247
+ }
248
+
249
+ /**
250
+ * Tiptap's image keeps only src and alt, so any width or border a signature carries was
251
+ * dropped the moment it passed through the editor. Preserving the attributes is what makes
252
+ * the image editable at all.
253
+ */
254
+ const StyledImage = Image.extend({
255
+ addNodeView() {
256
+ return ReactNodeViewRenderer(ImageView)
257
+ },
258
+ renderHTML({ HTMLAttributes }) {
259
+ const { href, ...rest } = HTMLAttributes as Record<string, unknown> & { href?: string | null }
260
+ const image: [string, Record<string, unknown>] = ['img', rest]
261
+ if (!href) return image
262
+ const anchor: [string, Record<string, unknown>, typeof image] = [
263
+ 'a',
264
+ { href, target: '_blank', rel: 'noopener noreferrer' },
265
+ image,
266
+ ]
267
+ return anchor
268
+ },
269
+ addAttributes() {
270
+ return {
271
+ ...this.parent?.(),
272
+ href: {
273
+ default: null,
274
+ // Read off the anchor the image was saved inside, since the node itself is the image.
275
+ parseHTML: element => element.closest('a')?.getAttribute('href') ?? null,
276
+ renderHTML: attributes => (attributes.href ? { href: attributes.href } : {}),
277
+ },
278
+ align: {
279
+ default: null,
280
+ parseHTML: element => element.getAttribute('data-align'),
281
+ renderHTML: attributes => (attributes.align ? { 'data-align': attributes.align } : {}),
282
+ },
283
+ width: {
284
+ default: null,
285
+ parseHTML: element => element.getAttribute('width'),
286
+ renderHTML: attributes => (attributes.width ? { width: attributes.width } : {}),
287
+ },
288
+ height: {
289
+ default: null,
290
+ parseHTML: element => element.getAttribute('height'),
291
+ renderHTML: attributes => (attributes.height ? { height: attributes.height } : {}),
292
+ },
293
+ style: {
294
+ default: null,
295
+ parseHTML: element => element.getAttribute('style'),
296
+ renderHTML: attributes => (attributes.style ? { style: attributes.style } : {}),
297
+ },
298
+ }
299
+ },
300
+ })
301
+
302
+ function stylePairs(value: string | null | undefined): Record<string, string> {
303
+ return Object.fromEntries(
304
+ (value ?? '')
305
+ .split(';')
306
+ .map(part => part.split(':'))
307
+ .filter(pair => pair.length === 2)
308
+ .map(([name, entry]) => [name.trim(), entry.trim()]),
309
+ )
310
+ }
311
+
312
+ function styleText(pairs: Record<string, string>): string {
313
+ return Object.entries(pairs)
314
+ .filter(([, entry]) => entry)
315
+ .map(([name, entry]) => `${name}:${entry}`)
316
+ .join(';')
317
+ }
318
+
319
+ const GRIPS = [
320
+ { corner: 'nw', direction: -1, className: 'rteImageGripNW' },
321
+ { corner: 'ne', direction: 1, className: 'rteImageGripNE' },
322
+ { corner: 'sw', direction: -1, className: 'rteImageGripSW' },
323
+ { corner: 'se', direction: 1, className: 'rteImageGripSE' },
324
+ ] as const
325
+
326
+ const ALIGNMENTS = [
327
+ { key: 'left', label: 'Align left', glyph: '\u21e4' },
328
+ { key: 'center', label: 'Centre', glyph: '\u2194' },
329
+ { key: 'right', label: 'Align right', glyph: '\u21e5' },
330
+ ] as const
331
+
332
+ function reactStyle(pairs: Record<string, string>): React.CSSProperties {
333
+ return Object.fromEntries(
334
+ Object.entries(pairs).map(([name, value]) => [name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()), value]),
335
+ ) as React.CSSProperties
336
+ }
337
+
338
+ /**
339
+ * The image is edited where it sits: selected with an outline, resized by its corners, and
340
+ * given a border, an alignment or a link from a bar anchored to it. A row of number boxes at
341
+ * the top of the toolbar was correct and undiscoverable.
342
+ */
343
+ function ImageView({ node, updateAttributes, selected, editor }: NodeViewProps) {
344
+ const attributes = node.attrs as {
345
+ src: string
346
+ alt: string | null
347
+ width: string | null
348
+ style: string | null
349
+ href: string | null
350
+ align: string | null
351
+ }
352
+ const declared = stylePairs(attributes.style)
353
+ const framed = Boolean(declared.border) && !['none', '0', '0px'].includes(declared.border)
354
+ const radius = parseInt(declared['border-radius'] ?? '', 10)
355
+ const align = attributes.align ?? 'left'
356
+ const imageRef = useRef<HTMLImageElement | null>(null)
357
+ const [dragWidth, setDragWidth] = useState<number | null>(null)
358
+ const [linkOpen, setLinkOpen] = useState(false)
359
+ const [href, setHref] = useState(attributes.href ?? '')
360
+ const width = dragWidth ?? parseInt(String(declared.width ?? attributes.width ?? ''), 10)
361
+ const live = Boolean(selected && editor.isEditable)
362
+
363
+ // A mail client reads the width attribute; a browser reads the style. Write both.
364
+ const write = (patch: Record<string, string>) => {
365
+ const next = { ...declared, ...patch }
366
+ const pixels = parseInt(next.width ?? '', 10)
367
+ updateAttributes({ style: styleText(next) || null, width: Number.isFinite(pixels) ? String(pixels) : null })
368
+ }
369
+
370
+ // One history entry per drag rather than one per pixel: the width is held here while the
371
+ // pointer is down and committed once it is released.
372
+ const startResize = (event: React.PointerEvent<HTMLSpanElement>, direction: 1 | -1) => {
373
+ event.preventDefault()
374
+ event.stopPropagation()
375
+ const handle = event.currentTarget
376
+ handle.setPointerCapture(event.pointerId)
377
+ const startX = event.clientX
378
+ const startWidth = imageRef.current?.getBoundingClientRect().width ?? 200
379
+ let latest = Math.round(startWidth)
380
+ const move = (moveEvent: PointerEvent) => {
381
+ latest = Math.round(Math.min(800, Math.max(24, startWidth + direction * (moveEvent.clientX - startX))))
382
+ setDragWidth(latest)
383
+ }
384
+ const stop = () => {
385
+ handle.removeEventListener('pointermove', move)
386
+ handle.removeEventListener('pointerup', stop)
387
+ handle.removeEventListener('pointercancel', stop)
388
+ setDragWidth(null)
389
+ write({ width: `${latest}px`, height: 'auto' })
390
+ }
391
+ handle.addEventListener('pointermove', move)
392
+ handle.addEventListener('pointerup', stop)
393
+ handle.addEventListener('pointercancel', stop)
394
+ }
395
+
396
+ const applyLink = () => {
397
+ const target = href.trim() ? safeHref(href) : null
398
+ if (href.trim() && !target) return
399
+ updateAttributes({ href: target })
400
+ setLinkOpen(false)
401
+ }
402
+
403
+ return (
404
+ <NodeViewWrapper className={styles.rteImageWrap} data-align={align}>
405
+ <span className={`${styles.rteImageBox} ${live ? styles.rteImageBoxOn : ''}`}>
406
+ {/* eslint-disable-next-line @next/next/no-img-element */}
407
+ <img
408
+ ref={imageRef}
409
+ src={attributes.src}
410
+ alt={attributes.alt ?? ''}
411
+ draggable={false}
412
+ style={{ ...reactStyle(declared), ...(dragWidth ? { width: `${dragWidth}px`, height: 'auto' } : {}) }}
413
+ />
414
+ {live &&
415
+ GRIPS.map(grip => (
416
+ <span
417
+ key={grip.corner}
418
+ className={`${styles.rteImageGrip} ${styles[grip.className]}`}
419
+ role="presentation"
420
+ onPointerDown={event => startResize(event, grip.direction)}
421
+ />
422
+ ))}
423
+ </span>
424
+ {live && (
425
+ <div className={styles.rteImageBar} contentEditable={false}>
426
+ <span className={styles.rteGridLabel}>W</span>
427
+ <input
428
+ className={styles.rteSizeSelect}
429
+ type="number"
430
+ min={24}
431
+ max={800}
432
+ step={10}
433
+ title="Width in pixels"
434
+ aria-label="Image width in pixels"
435
+ value={Number.isFinite(width) ? width : ''}
436
+ onChange={event => write({ width: event.target.value ? `${event.target.value}px` : '', height: 'auto' })}
437
+ />
438
+ {ALIGNMENTS.map(option => (
439
+ <Btn
440
+ key={option.key}
441
+ title={option.label}
442
+ active={align === option.key}
443
+ onClick={() =>
444
+ updateAttributes({
445
+ align: option.key,
446
+ style: styleText({
447
+ ...declared,
448
+ 'margin-left': option.key === 'left' ? '' : 'auto',
449
+ 'margin-right': option.key === 'right' ? '' : 'auto',
450
+ }),
451
+ })
452
+ }
453
+ >
454
+ {option.glyph}
455
+ </Btn>
456
+ ))}
457
+ <Btn
458
+ title={framed ? 'Remove the border' : 'Add a border'}
459
+ active={framed}
460
+ onClick={() =>
461
+ write(
462
+ framed
463
+ ? { border: '', padding: '', 'border-radius': '' }
464
+ : { border: `1px solid ${CLIENT_BRAND.accent}`, padding: '8px', 'border-radius': '8px' },
465
+ )
466
+ }
467
+ wide
468
+ >
469
+ Border
470
+ </Btn>
471
+ {framed && (
472
+ <>
473
+ <span className={styles.rteGridLabel}>R</span>
474
+ <input
475
+ className={styles.rteSizeSelect}
476
+ type="number"
477
+ min={0}
478
+ max={200}
479
+ title="Corner radius in pixels"
480
+ aria-label="Corner radius in pixels"
481
+ value={Number.isFinite(radius) ? radius : 0}
482
+ onChange={event => write({ 'border-radius': `${event.target.value || 0}px` })}
483
+ />
484
+ {[...new Set([CLIENT_BRAND.accent, ...TEXT_COLOURS])].map(colour => (
485
+ <button
486
+ key={colour}
487
+ type="button"
488
+ className={styles.rteSwatch}
489
+ style={{ background: colour }}
490
+ title={`Border colour ${colour}`}
491
+ aria-label={`Border colour ${colour}`}
492
+ onMouseDown={event => event.preventDefault()}
493
+ onClick={() => write({ border: `1px solid ${colour}` })}
494
+ />
495
+ ))}
496
+ <label className={styles.rteSwatchCustom} title="Any other border colour">
497
+ <input
498
+ type="color"
499
+ aria-label="Choose any border colour"
500
+ onChange={event => write({ border: `1px solid ${event.target.value}` })}
501
+ />
502
+ </label>
503
+ </>
504
+ )}
505
+ <Btn title="Link this image" active={Boolean(attributes.href) || linkOpen} onClick={() => setLinkOpen(open => !open)} wide>
506
+ Link
507
+ </Btn>
508
+ {linkOpen && (
509
+ <>
510
+ <input
511
+ className={styles.rteImageLink}
512
+ value={href}
513
+ placeholder={CLIENT_BRAND.websiteUrl}
514
+ aria-label="Address this image opens"
515
+ onChange={event => setHref(event.target.value)}
516
+ onKeyDown={event => {
517
+ if (event.key === 'Enter') applyLink()
518
+ }}
519
+ />
520
+ <Btn
521
+ title="Use the company website"
522
+ onClick={() => {
523
+ setHref(CLIENT_BRAND.websiteUrl)
524
+ updateAttributes({ href: CLIENT_BRAND.websiteUrl })
525
+ setLinkOpen(false)
526
+ }}
527
+ wide
528
+ >
529
+ Website
530
+ </Btn>
531
+ <Btn title="Apply the address" onClick={applyLink} wide>
532
+ Apply
533
+ </Btn>
534
+ {attributes.href && (
535
+ <Btn
536
+ title="Remove the link"
537
+ onClick={() => {
538
+ setHref('')
539
+ updateAttributes({ href: null })
540
+ setLinkOpen(false)
541
+ }}
542
+ wide
543
+ >
544
+ Unlink
545
+ </Btn>
546
+ )}
547
+ </>
548
+ )}
549
+ </div>
550
+ )}
551
+ </NodeViewWrapper>
552
+ )
553
+ }
554
+
555
+ function TableTools({ editor }: { editor: Editor }) {
556
+ return (
557
+ <Group>
558
+ <Btn title="Add row" onClick={() => editor.chain().focus().addRowAfter().run()} wide>
559
+ +Row
560
+ </Btn>
561
+ <Btn title="Add column" onClick={() => editor.chain().focus().addColumnAfter().run()} wide>
562
+ +Col
563
+ </Btn>
564
+ <Btn title="Delete row" onClick={() => editor.chain().focus().deleteRow().run()} wide>
565
+ −Row
566
+ </Btn>
567
+ <Btn title="Delete column" onClick={() => editor.chain().focus().deleteColumn().run()} wide>
568
+ −Col
569
+ </Btn>
570
+ <Btn title="Delete table" onClick={() => editor.chain().focus().deleteTable().run()}>
571
+ 🗑
572
+ </Btn>
573
+ </Group>
574
+ )
575
+ }
576
+
577
+ type Pop = 'link' | 'image' | 'table' | null
578
+
579
+ function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadImage?: (file: File) => Promise<string>; fonts?: string[] }) {
580
+ const [pop, setPop] = useState<Pop>(null)
581
+ const popRef = useRef<HTMLDivElement>(null)
582
+
583
+ useEffect(() => {
584
+ if (!pop) return
585
+ const onDown = (event: MouseEvent) => {
586
+ if (!popRef.current?.contains(event.target as Node)) setPop(null)
587
+ }
588
+ const onKey = (event: KeyboardEvent) => {
589
+ if (event.key === 'Escape') setPop(null)
590
+ }
591
+ document.addEventListener('mousedown', onDown)
592
+ document.addEventListener('keydown', onKey)
593
+ return () => {
594
+ document.removeEventListener('mousedown', onDown)
595
+ document.removeEventListener('keydown', onKey)
596
+ }
597
+ }, [pop])
598
+
599
+ const openLink = useCallback(() => {
600
+ // Collapsed caret inside an existing link: widen to the whole link so its text and
601
+ // address can be read out and edited as a unit.
602
+ if (editor.isActive('link') && editor.state.selection.empty) {
603
+ editor.chain().extendMarkRange('link').run()
604
+ }
605
+ setPop(current => (current === 'link' ? null : 'link'))
606
+ }, [editor])
607
+
608
+ return (
609
+ <div className={styles.rteToolbar} role="toolbar" aria-label="Formatting">
610
+ <Group>
611
+ <MailSelect
612
+ buttonClassName={styles.rteSelect}
613
+ ariaLabel="Font"
614
+ placeholder="Font"
615
+ value={(editor.getAttributes('textStyle').fontFamily as string | undefined)?.replace(/^['"]|['"]$/g, '') ?? ''}
616
+ options={[{ value: '', label: 'Default font' }, ...[...BUILTIN_FONTS, ...fonts.filter(font => !BUILTIN_FONTS.includes(font))].map(font => ({ value: font, label: font }))]}
617
+ optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
618
+ onChange={family => {
619
+ if (family) editor.chain().focus().setFontFamily(family).run()
620
+ else editor.chain().focus().unsetFontFamily().run()
621
+ }}
622
+ />
623
+ <MailSelect
624
+ buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
625
+ ariaLabel="Font size"
626
+ placeholder="Size"
627
+ editable
628
+ value={(editor.getAttributes('textStyle').fontSize as string | undefined) ?? ''}
629
+ options={[{ value: '', label: 'Default' }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
630
+ onChange={raw => {
631
+ const size = /^\d+(\.\d+)?$/.test(raw) ? `${raw}px` : raw
632
+ if (!size || size === 'Default') editor.chain().focus().unsetFontSize().run()
633
+ else if (/^\d+(\.\d+)?(px|pt|em|rem|%)$/.test(size)) editor.chain().focus().setFontSize(size).run()
634
+ }}
635
+ />
636
+ </Group>
637
+
638
+ <Group>
639
+ <Btn title="Bold" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
640
+ <strong>B</strong>
641
+ </Btn>
642
+ <Btn title="Italic" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
643
+ <em>I</em>
644
+ </Btn>
645
+ <Btn title="Underline" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
646
+ <u>U</u>
647
+ </Btn>
648
+ <Btn title="Strikethrough" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
649
+ <s>S</s>
650
+ </Btn>
651
+ </Group>
652
+
653
+ <Group>
654
+ <span className={styles.rteSwatches} role="group" aria-label="Text colour">
655
+ {TEXT_COLOURS.map(colour => (
656
+ <button
657
+ key={colour}
658
+ type="button"
659
+ title={`Text ${colour}`}
660
+ aria-label={`Text colour ${colour}`}
661
+ className={styles.rteSwatch}
662
+ style={{ background: colour }}
663
+ onMouseDown={event => event.preventDefault()}
664
+ onClick={() => editor.chain().focus().setColor(colour).run()}
665
+ />
666
+ ))}
667
+ <label className={styles.rteSwatchCustom} title="Any other colour">
668
+ <input
669
+ type="color"
670
+ aria-label="Choose any text colour"
671
+ onChange={event => editor.chain().focus().setColor(event.target.value).run()}
672
+ />
673
+ </label>
674
+ </span>
675
+ <span className={styles.rteSwatches} role="group" aria-label="Highlight">
676
+ {HIGHLIGHTS.map(colour => (
677
+ <button
678
+ key={colour}
679
+ type="button"
680
+ title={`Highlight ${colour}`}
681
+ aria-label={`Highlight ${colour}`}
682
+ className={`${styles.rteSwatch} ${styles.rteSwatchHi}`}
683
+ style={{ background: colour }}
684
+ onMouseDown={event => event.preventDefault()}
685
+ onClick={() => editor.chain().focus().toggleHighlight({ color: colour }).run()}
686
+ />
687
+ ))}
688
+ </span>
689
+ <Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}>
690
+ ✕
691
+ </Btn>
692
+ </Group>
693
+
694
+ <Group>
695
+ <Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
696
+ ••
697
+ </Btn>
698
+ <Btn title="Numbered list" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
699
+ 1.
700
+ </Btn>
701
+ <Btn title="Quote" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
702
+ ❝
703
+ </Btn>
704
+ <Btn title="Code" active={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
705
+ {'</>'}
706
+ </Btn>
707
+ </Group>
708
+
709
+ <Group>
710
+ {(['left', 'center', 'right', 'justify'] as const).map(align => (
711
+ <Btn
712
+ key={align}
713
+ title={`Align ${align}`}
714
+ active={editor.isActive({ textAlign: align })}
715
+ onClick={() => editor.chain().focus().setTextAlign(align).run()}
716
+ >
717
+ {align === 'left' ? '⇤' : align === 'center' ? '↔' : align === 'right' ? '⇥' : '≡'}
718
+ </Btn>
719
+ ))}
720
+ </Group>
721
+
722
+ <div className={styles.rtePopWrap} ref={popRef}>
723
+ <Group>
724
+ <Btn title="Insert link" active={editor.isActive('link') || pop === 'link'} onClick={openLink} wide>
725
+ Link
726
+ </Btn>
727
+ <Btn title="Insert image" active={pop === 'image'} onClick={() => setPop(current => (current === 'image' ? null : 'image'))} wide>
728
+ Image
729
+ </Btn>
730
+ <Btn title="Insert table" active={pop === 'table'} onClick={() => setPop(current => (current === 'table' ? null : 'table'))} wide>
731
+ Table
732
+ </Btn>
733
+ <Btn title="Divider" onClick={() => editor.chain().focus().setHorizontalRule().run()}>
734
+ —
735
+ </Btn>
736
+ </Group>
737
+ {pop === 'link' && <LinkPopover editor={editor} close={() => setPop(null)} />}
738
+ {pop === 'image' && <ImagePopover editor={editor} close={() => setPop(null)} upload={uploadImage} />}
739
+ {pop === 'table' && <TablePopover editor={editor} close={() => setPop(null)} />}
740
+ </div>
741
+
742
+ {editor.isActive('table') && <TableTools editor={editor} />}
743
+
744
+ <Group>
745
+ <Btn title="Undo" onClick={() => editor.chain().focus().undo().run()}>
746
+ ↺
747
+ </Btn>
748
+ <Btn title="Redo" onClick={() => editor.chain().focus().redo().run()}>
749
+ ↻
750
+ </Btn>
751
+ </Group>
752
+ </div>
753
+ )
754
+ }
755
+
756
+ export default function RichEditor({
757
+ html,
758
+ onChange,
759
+ placeholder,
760
+ uploadImage,
761
+ fonts,
762
+ fontFaceCss,
763
+ baseFont,
764
+ }: {
765
+ html: string
766
+ onChange: (html: string) => void
767
+ placeholder?: string
768
+ uploadImage?: (file: File) => Promise<string>
769
+ fonts?: string[]
770
+ fontFaceCss?: string
771
+ baseFont?: BaseFont
772
+ }) {
773
+ const uploadRef = useRef(uploadImage)
774
+ const editorRef = useRef<Editor | null>(null)
775
+ useEffect(() => {
776
+ uploadRef.current = uploadImage
777
+ }, [uploadImage])
778
+
779
+ // A dropped or pasted image file is uploaded and inserted by URL. Left to the browser,
780
+ // Chrome on Windows inserts it as file:///C:/… — which renders for nobody.
781
+ const placeImages = (files: File[], pos?: number): boolean => {
782
+ const images = files.filter(file => file.type.startsWith('image/'))
783
+ if (!images.length) return false
784
+ const upload = uploadRef.current
785
+ if (!upload) return true
786
+ void (async () => {
787
+ for (const file of images) {
788
+ try {
789
+ const src = await upload(file)
790
+ const chain = editorRef.current?.chain().focus()
791
+ if (!chain) return
792
+ if (pos !== undefined) chain.setTextSelection(pos)
793
+ chain.setImage({ src }).run()
794
+ } catch (err) {
795
+ console.warn('[editor] image upload failed', err)
796
+ }
797
+ }
798
+ })()
799
+ return true
800
+ }
801
+
802
+ const editor = useEditor({
803
+ // Rendered on the client only: the editor touches the DOM on creation and would
804
+ // otherwise mismatch the server-rendered markup.
805
+ immediatelyRender: false,
806
+ extensions: [
807
+ // StarterKit ships its own link and underline; ours replace them so
808
+ // openOnClick stays off and a link in the composer is editable, not followed.
809
+ StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: false, underline: false }),
810
+ Underline,
811
+ TextStyle,
812
+ FontFamily,
813
+ FontSize,
814
+ Color,
815
+ Highlight.configure({ multicolor: true }),
816
+ Link.configure({ openOnClick: false, autolink: true }),
817
+ StyledImage.configure({ inline: false }),
818
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
819
+ Table.configure({ resizable: false }),
820
+ TableRow,
821
+ TableHeader,
822
+ TableCell,
823
+ ],
824
+ content: html || '',
825
+ editorProps: {
826
+ attributes: { class: styles.rteSurface, 'aria-label': placeholder ?? 'Message body' },
827
+ handleDrop: (view, event) =>
828
+ placeImages(Array.from(event.dataTransfer?.files ?? []), view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos),
829
+ handlePaste: (_view, event) => placeImages(Array.from(event.clipboardData?.files ?? [])),
830
+ },
831
+ onUpdate: ({ editor: instance }) => onChange(instance.getHTML()),
832
+ })
833
+
834
+ // Only push content in when it changed elsewhere — writing on every keystroke would
835
+ // reset the cursor to the start of the document.
836
+ useEffect(() => {
837
+ if (!editor) return
838
+ // An empty document reads back as <p></p>, so compare against that rather than the
839
+ // empty string — otherwise every render while empty resets the caret.
840
+ if ((html || '<p></p>') !== editor.getHTML()) editor.commands.setContent(html || '', { emitUpdate: false })
841
+ }, [html, editor])
842
+
843
+ useEffect(() => {
844
+ editorRef.current = editor
845
+ }, [editor])
846
+
847
+ if (!editor) return <div className={styles.rteLoading} />
848
+
849
+ return (
850
+ <div
851
+ className={styles.rte}
852
+ style={{ '--rte-font': fontStack(baseFont?.family ?? ''), '--rte-size': baseFont?.size || '15px' } as React.CSSProperties}
853
+ >
854
+ {fontFaceCss && <style>{fontFaceCss}</style>}
855
+ <Toolbar editor={editor} uploadImage={uploadImage} fonts={fonts} />
856
+ <EditorContent editor={editor} className={styles.rteContent} />
857
+ </div>
858
+ )
859
+ }