@novacraft-engineering/mailbox 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/app/api/mail/accessors/route.ts +28 -24
- package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
- package/app/api/mail/emails/[id]/route.ts +17 -9
- package/app/api/mail/emails/route.ts +35 -4
- package/app/api/mail/inbox/counts/route.ts +5 -4
- package/app/api/mail/inbox/route.ts +41 -262
- package/app/api/mail/maintenance/received/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +8 -2
- package/app/api/mail/request-reset/route.ts +29 -25
- package/app/api/mail/signature-logo/route.ts +33 -8
- package/app/api/mail/threads/route.ts +1 -1
- package/app/brand/[file]/placeholder.ts +70 -0
- package/app/brand/[file]/route.ts +7 -1
- package/app/layout.tsx +10 -2
- package/app/mail/MailSelect.tsx +23 -4
- package/app/mail/RichEditor.tsx +167 -53
- package/app/mail/Ticker.tsx +40 -0
- package/app/mail/page.module.css +643 -76
- package/app/mail/page.tsx +1062 -247
- package/app/mail/search.ts +1 -1
- package/app/share/[id]/page.tsx +5 -3
- package/app/share/[id]/share.module.css +5 -5
- package/lib/email-html.ts +9 -1
- package/lib/emails/index.ts +26 -0
- package/lib/emails/templates/action.html +47 -0
- package/lib/fonts.ts +7 -1
- package/lib/mail-provider.ts +50 -5
- package/lib/mailbox.ts +303 -34
- package/lib/receive.ts +318 -0
- package/lib/sent-access.ts +22 -0
- package/lib/ses-send.ts +49 -0
- package/package.json +2 -2
- package/public/brand/README.md +5 -1
- package/scripts/sample-files.mjs +208 -28
- package/scripts/seed-dev.mjs +69 -26
- package/tools/README.md +1 -1
package/app/mail/RichEditor.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
-
import { EditorContent, NodeViewWrapper, ReactNodeViewRenderer, useEditor, type Editor, type NodeViewProps } from '@tiptap/react'
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
4
|
+
import { EditorContent, Extension, NodeViewWrapper, ReactNodeViewRenderer, useEditor, type Editor, type NodeViewProps } from '@tiptap/react'
|
|
5
5
|
import StarterKit from '@tiptap/starter-kit'
|
|
6
6
|
import Underline from '@tiptap/extension-underline'
|
|
7
7
|
import Link from '@tiptap/extension-link'
|
|
@@ -9,8 +9,8 @@ import Image from '@tiptap/extension-image'
|
|
|
9
9
|
import { CLIENT_BRAND } from '@/lib/brand.client'
|
|
10
10
|
import TextAlign from '@tiptap/extension-text-align'
|
|
11
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'
|
|
12
|
+
import { LINE_SPACINGS, BUILTIN_FONTS, FONT_SIZES, fontStack, lineSpacingOf, paragraphGap, type BaseFont } from '@/lib/fonts'
|
|
13
|
+
import MailSelect, { GLYPH } from './MailSelect'
|
|
14
14
|
import { Color } from '@tiptap/extension-color'
|
|
15
15
|
import Highlight from '@tiptap/extension-highlight'
|
|
16
16
|
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
|
|
@@ -19,6 +19,19 @@ import styles from './page.module.css'
|
|
|
19
19
|
|
|
20
20
|
const TEXT_COLOURS = ['#030712', '#b91c1c', '#1d4ed8', '#047857', '#b45309', '#6d28d9', '#6b7280']
|
|
21
21
|
const HIGHLIGHTS = ['#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#FED7AA']
|
|
22
|
+
const PHONE = '(max-width: 720px)'
|
|
23
|
+
function usePhone(): boolean {
|
|
24
|
+
return useSyncExternalStore(
|
|
25
|
+
onChange => {
|
|
26
|
+
const media = window.matchMedia(PHONE)
|
|
27
|
+
media.addEventListener('change', onChange)
|
|
28
|
+
return () => media.removeEventListener('change', onChange)
|
|
29
|
+
},
|
|
30
|
+
() => window.matchMedia(PHONE).matches,
|
|
31
|
+
() => false,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
function Group({ children }: { children: React.ReactNode }) {
|
|
23
36
|
return <div className={styles.rteGroup}>{children}</div>
|
|
24
37
|
}
|
|
@@ -576,9 +589,15 @@ function TableTools({ editor }: { editor: Editor }) {
|
|
|
576
589
|
|
|
577
590
|
type Pop = 'link' | 'image' | 'table' | null
|
|
578
591
|
|
|
579
|
-
function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadImage?: (file: File) => Promise<string>; fonts?: string[] }) {
|
|
592
|
+
function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor; uploadImage?: (file: File) => Promise<string>; fonts?: string[]; baseFont?: BaseFont }) {
|
|
580
593
|
const [pop, setPop] = useState<Pop>(null)
|
|
581
594
|
const popRef = useRef<HTMLDivElement>(null)
|
|
595
|
+
const phone = usePhone()
|
|
596
|
+
const [palette, setPalette] = useState(false)
|
|
597
|
+
const currentFamily = (editor.getAttributes('textStyle').fontFamily as string | undefined)?.replace(/^['"]|['"]$/g, '') ?? ''
|
|
598
|
+
const defaultFamily = baseFont?.family || 'Arial'
|
|
599
|
+
const defaultSize = (baseFont?.size || '15px').replace('px', '')
|
|
600
|
+
const defaultSpacing = String(lineSpacingOf(baseFont))
|
|
582
601
|
|
|
583
602
|
useEffect(() => {
|
|
584
603
|
if (!pop) return
|
|
@@ -606,14 +625,16 @@ function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadIm
|
|
|
606
625
|
}, [editor])
|
|
607
626
|
|
|
608
627
|
return (
|
|
628
|
+
<>
|
|
609
629
|
<div className={styles.rteToolbar} role="toolbar" aria-label="Formatting">
|
|
610
630
|
<Group>
|
|
611
631
|
<MailSelect
|
|
612
632
|
buttonClassName={styles.rteSelect}
|
|
613
633
|
ariaLabel="Font"
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
634
|
+
label="Font"
|
|
635
|
+
prefix={GLYPH.font}
|
|
636
|
+
value={currentFamily}
|
|
637
|
+
options={[{ value: '', label: `${defaultFamily} (default)`, closed: defaultFamily }, ...[...BUILTIN_FONTS, ...fonts.filter(font => !BUILTIN_FONTS.includes(font))].map(font => ({ value: font, label: font }))]}
|
|
617
638
|
optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
|
|
618
639
|
onChange={family => {
|
|
619
640
|
if (family) editor.chain().focus().setFontFamily(family).run()
|
|
@@ -623,16 +644,33 @@ function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadIm
|
|
|
623
644
|
<MailSelect
|
|
624
645
|
buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
|
|
625
646
|
ariaLabel="Font size"
|
|
626
|
-
|
|
647
|
+
label="Size"
|
|
648
|
+
placeholder={defaultSize}
|
|
649
|
+
prefix={GLYPH.size}
|
|
627
650
|
editable
|
|
628
651
|
value={(editor.getAttributes('textStyle').fontSize as string | undefined) ?? ''}
|
|
629
|
-
options={[{ value: '', label:
|
|
652
|
+
options={[{ value: '', label: `${defaultSize} (default)`, closed: defaultSize }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
|
|
630
653
|
onChange={raw => {
|
|
631
654
|
const size = /^\d+(\.\d+)?$/.test(raw) ? `${raw}px` : raw
|
|
632
|
-
if (!size || size
|
|
655
|
+
if (!size || /default/i.test(size)) editor.chain().focus().unsetFontSize().run()
|
|
633
656
|
else if (/^\d+(\.\d+)?(px|pt|em|rem|%)$/.test(size)) editor.chain().focus().setFontSize(size).run()
|
|
634
657
|
}}
|
|
635
658
|
/>
|
|
659
|
+
<MailSelect
|
|
660
|
+
buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
|
|
661
|
+
ariaLabel="Line height"
|
|
662
|
+
label="Line height"
|
|
663
|
+
placeholder={defaultSpacing}
|
|
664
|
+
prefix={GLYPH.spacing}
|
|
665
|
+
editable
|
|
666
|
+
value={editor.getAttributes('paragraph').lineSpacing ? String(editor.getAttributes('paragraph').lineSpacing) : ''}
|
|
667
|
+
options={[{ value: '', label: `${defaultSpacing} (default)`, closed: defaultSpacing }, ...LINE_SPACINGS.map(spacing => ({ value: String(spacing), label: String(spacing) }))]}
|
|
668
|
+
onChange={raw => {
|
|
669
|
+
const spacing = parseFloat(raw)
|
|
670
|
+
if (!raw.trim() || /default/i.test(raw)) editor.chain().focus().updateAttributes('paragraph', { lineSpacing: null }).run()
|
|
671
|
+
else if (isSpacing(spacing)) editor.chain().focus().updateAttributes('paragraph', { lineSpacing: spacing }).run()
|
|
672
|
+
}}
|
|
673
|
+
/>
|
|
636
674
|
</Group>
|
|
637
675
|
|
|
638
676
|
<Group>
|
|
@@ -650,46 +688,57 @@ function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadIm
|
|
|
650
688
|
</Btn>
|
|
651
689
|
</Group>
|
|
652
690
|
|
|
653
|
-
|
|
654
|
-
<
|
|
655
|
-
{
|
|
656
|
-
<
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
<
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
691
|
+
{phone ? (
|
|
692
|
+
<Group>
|
|
693
|
+
<Btn title="Colours" active={palette} onClick={() => setPalette(open => !open)}>
|
|
694
|
+
<span className={styles.rteColourDot} aria-hidden />
|
|
695
|
+
</Btn>
|
|
696
|
+
<Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}>
|
|
697
|
+
✕
|
|
698
|
+
</Btn>
|
|
699
|
+
</Group>
|
|
700
|
+
) : (
|
|
701
|
+
<Group>
|
|
702
|
+
<span className={styles.rteSwatches} role="group" aria-label="Text colour">
|
|
703
|
+
{TEXT_COLOURS.map(colour => (
|
|
704
|
+
<button
|
|
705
|
+
key={colour}
|
|
706
|
+
type="button"
|
|
707
|
+
title={`Text ${colour}`}
|
|
708
|
+
aria-label={`Text colour ${colour}`}
|
|
709
|
+
className={styles.rteSwatch}
|
|
710
|
+
style={{ background: colour }}
|
|
711
|
+
onMouseDown={event => event.preventDefault()}
|
|
712
|
+
onClick={() => editor.chain().focus().setColor(colour).run()}
|
|
713
|
+
/>
|
|
714
|
+
))}
|
|
715
|
+
<label className={styles.rteSwatchCustom} title="Any other colour">
|
|
716
|
+
<input
|
|
717
|
+
type="color"
|
|
718
|
+
aria-label="Choose any text colour"
|
|
719
|
+
onChange={event => editor.chain().focus().setColor(event.target.value).run()}
|
|
720
|
+
/>
|
|
721
|
+
</label>
|
|
722
|
+
</span>
|
|
723
|
+
<span className={styles.rteSwatches} role="group" aria-label="Highlight">
|
|
724
|
+
{HIGHLIGHTS.map(colour => (
|
|
725
|
+
<button
|
|
726
|
+
key={colour}
|
|
727
|
+
type="button"
|
|
728
|
+
title={`Highlight ${colour}`}
|
|
729
|
+
aria-label={`Highlight ${colour}`}
|
|
730
|
+
className={`${styles.rteSwatch} ${styles.rteSwatchHi}`}
|
|
731
|
+
style={{ background: colour }}
|
|
732
|
+
onMouseDown={event => event.preventDefault()}
|
|
733
|
+
onClick={() => editor.chain().focus().toggleHighlight({ color: colour }).run()}
|
|
734
|
+
/>
|
|
735
|
+
))}
|
|
736
|
+
</span>
|
|
737
|
+
<Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}>
|
|
738
|
+
✕
|
|
739
|
+
</Btn>
|
|
740
|
+
</Group>
|
|
741
|
+
)}
|
|
693
742
|
|
|
694
743
|
<Group>
|
|
695
744
|
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
|
@@ -750,9 +799,73 @@ function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadIm
|
|
|
750
799
|
</Btn>
|
|
751
800
|
</Group>
|
|
752
801
|
</div>
|
|
802
|
+
{phone && (
|
|
803
|
+
<div className={`${styles.rtePalette} ${palette ? styles.rtePaletteOpen : ''}`} aria-hidden={!palette}>
|
|
804
|
+
<span className={styles.rtePaletteGroup} role="group" aria-label="Text colour">
|
|
805
|
+
{TEXT_COLOURS.slice(0, 6).map(colour => (
|
|
806
|
+
<button
|
|
807
|
+
key={colour}
|
|
808
|
+
type="button"
|
|
809
|
+
title={`Text ${colour}`}
|
|
810
|
+
aria-label={`Text colour ${colour}`}
|
|
811
|
+
className={styles.rteSwatch}
|
|
812
|
+
style={{ background: colour }}
|
|
813
|
+
tabIndex={palette ? 0 : -1}
|
|
814
|
+
onMouseDown={event => event.preventDefault()}
|
|
815
|
+
onClick={() => editor.chain().focus().setColor(colour).run()}
|
|
816
|
+
/>
|
|
817
|
+
))}
|
|
818
|
+
<label className={styles.rteSwatchCustom} title="Any other colour">
|
|
819
|
+
<input type="color" aria-label="Choose any text colour" tabIndex={palette ? 0 : -1} onChange={event => editor.chain().focus().setColor(event.target.value).run()} />
|
|
820
|
+
</label>
|
|
821
|
+
</span>
|
|
822
|
+
<span className={styles.rtePaletteGroup} role="group" aria-label="Highlight">
|
|
823
|
+
{HIGHLIGHTS.slice(0, 4).map(colour => (
|
|
824
|
+
<button
|
|
825
|
+
key={colour}
|
|
826
|
+
type="button"
|
|
827
|
+
title={`Highlight ${colour}`}
|
|
828
|
+
aria-label={`Highlight ${colour}`}
|
|
829
|
+
className={`${styles.rteSwatch} ${styles.rteSwatchHi}`}
|
|
830
|
+
style={{ background: colour }}
|
|
831
|
+
tabIndex={palette ? 0 : -1}
|
|
832
|
+
onMouseDown={event => event.preventDefault()}
|
|
833
|
+
onClick={() => editor.chain().focus().toggleHighlight({ color: colour }).run()}
|
|
834
|
+
/>
|
|
835
|
+
))}
|
|
836
|
+
</span>
|
|
837
|
+
</div>
|
|
838
|
+
)}
|
|
839
|
+
</>
|
|
753
840
|
)
|
|
754
841
|
}
|
|
755
842
|
|
|
843
|
+
const isSpacing = (value: number) => Number.isFinite(value) && value >= 0.8 && value <= 3
|
|
844
|
+
|
|
845
|
+
const LineSpacing = Extension.create({
|
|
846
|
+
name: 'lineSpacing',
|
|
847
|
+
addGlobalAttributes() {
|
|
848
|
+
return [
|
|
849
|
+
{
|
|
850
|
+
types: ['paragraph'],
|
|
851
|
+
attributes: {
|
|
852
|
+
lineSpacing: {
|
|
853
|
+
default: null,
|
|
854
|
+
parseHTML: (element: HTMLElement) => {
|
|
855
|
+
const value = parseFloat(element.style.lineHeight)
|
|
856
|
+
return isSpacing(value) ? value : null
|
|
857
|
+
},
|
|
858
|
+
renderHTML: (attributes: Record<string, unknown>) => {
|
|
859
|
+
const value = attributes.lineSpacing as number | null
|
|
860
|
+
return value ? { style: `line-height:${value};margin:0 0 ${paragraphGap(value)};` } : {}
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
]
|
|
866
|
+
},
|
|
867
|
+
})
|
|
868
|
+
|
|
756
869
|
export default function RichEditor({
|
|
757
870
|
html,
|
|
758
871
|
onChange,
|
|
@@ -811,6 +924,7 @@ export default function RichEditor({
|
|
|
811
924
|
TextStyle,
|
|
812
925
|
FontFamily,
|
|
813
926
|
FontSize,
|
|
927
|
+
LineSpacing,
|
|
814
928
|
Color,
|
|
815
929
|
Highlight.configure({ multicolor: true }),
|
|
816
930
|
Link.configure({ openOnClick: false, autolink: true }),
|
|
@@ -849,10 +963,10 @@ export default function RichEditor({
|
|
|
849
963
|
return (
|
|
850
964
|
<div
|
|
851
965
|
className={styles.rte}
|
|
852
|
-
style={{ '--rte-font': fontStack(baseFont?.family ?? ''), '--rte-size': baseFont?.size || '15px' } as React.CSSProperties}
|
|
966
|
+
style={{ '--rte-font': fontStack(baseFont?.family ?? ''), '--rte-size': baseFont?.size || '15px', '--rte-lead': String(lineSpacingOf(baseFont)), '--rte-gap': paragraphGap(lineSpacingOf(baseFont)) } as React.CSSProperties}
|
|
853
967
|
>
|
|
854
968
|
{fontFaceCss && <style>{fontFaceCss}</style>}
|
|
855
|
-
<Toolbar editor={editor} uploadImage={uploadImage} fonts={fonts} />
|
|
969
|
+
<Toolbar editor={editor} uploadImage={uploadImage} fonts={fonts} baseFont={baseFont} />
|
|
856
970
|
<EditorContent editor={editor} className={styles.rteContent} />
|
|
857
971
|
</div>
|
|
858
972
|
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
4
|
+
import styles from './page.module.css'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One line of text that, when it does not fit, glides back and forth instead of ending in
|
|
8
|
+
* an ellipsis. Measured on mount and whenever the host or its text changes size.
|
|
9
|
+
*/
|
|
10
|
+
export default function Ticker({ className, title, children }: { className?: string; title?: string; children: ReactNode }) {
|
|
11
|
+
const host = useRef<HTMLSpanElement>(null)
|
|
12
|
+
const text = useRef<HTMLSpanElement>(null)
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const outer = host.current
|
|
15
|
+
const inner = text.current
|
|
16
|
+
if (!outer || !inner) return
|
|
17
|
+
const measure = () => {
|
|
18
|
+
const shift = inner.scrollWidth - outer.clientWidth
|
|
19
|
+
if (shift > 4) {
|
|
20
|
+
outer.dataset.ticker = 'on'
|
|
21
|
+
outer.style.setProperty('--ticker-shift', `${shift}px`)
|
|
22
|
+
outer.style.setProperty('--ticker-duration', `${Math.min(18, Math.max(6, shift / 20))}s`)
|
|
23
|
+
outer.style.setProperty('--ticker-delay', `-${(inner.textContent?.length ?? 0) % 7}s`)
|
|
24
|
+
} else {
|
|
25
|
+
delete outer.dataset.ticker
|
|
26
|
+
outer.style.removeProperty('--ticker-shift')
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
measure()
|
|
30
|
+
const observer = new ResizeObserver(measure)
|
|
31
|
+
observer.observe(outer)
|
|
32
|
+
observer.observe(inner)
|
|
33
|
+
return () => observer.disconnect()
|
|
34
|
+
}, [children])
|
|
35
|
+
return (
|
|
36
|
+
<span ref={host} className={`${className ?? ''} ${styles.tickerHost}`} title={title}>
|
|
37
|
+
<span ref={text} className={styles.tickerText}>{children}</span>
|
|
38
|
+
</span>
|
|
39
|
+
)
|
|
40
|
+
}
|