@novacraft-engineering/mailbox 0.1.0 → 0.2.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.
- 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 +14 -3
- package/app/mail/RichEditor.tsx +168 -52
- package/app/mail/Ticker.tsx +40 -0
- package/app/mail/page.module.css +624 -70
- package/app/mail/page.tsx +1059 -244
- 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,7 +9,7 @@ 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'
|
|
12
|
+
import { LINE_SPACINGS, BUILTIN_FONTS, FONT_SIZES, fontStack, lineSpacingOf, paragraphGap, type BaseFont } from '@/lib/fonts'
|
|
13
13
|
import MailSelect from './MailSelect'
|
|
14
14
|
import { Color } from '@tiptap/extension-color'
|
|
15
15
|
import Highlight from '@tiptap/extension-highlight'
|
|
@@ -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,18 @@ 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
|
-
placeholder=
|
|
615
|
-
|
|
616
|
-
|
|
634
|
+
placeholder={`${defaultFamily} (default)`}
|
|
635
|
+
prefix={<span className={styles.selectGlyphSerif}>A</span>}
|
|
636
|
+
compact
|
|
637
|
+
title={`Font: ${currentFamily || `${defaultFamily} (default)`}`}
|
|
638
|
+
value={currentFamily}
|
|
639
|
+
options={[{ value: '', label: `${defaultFamily} (default)` }, ...[...BUILTIN_FONTS, ...fonts.filter(font => !BUILTIN_FONTS.includes(font))].map(font => ({ value: font, label: font }))]}
|
|
617
640
|
optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
|
|
618
641
|
onChange={family => {
|
|
619
642
|
if (family) editor.chain().focus().setFontFamily(family).run()
|
|
@@ -623,16 +646,33 @@ function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadIm
|
|
|
623
646
|
<MailSelect
|
|
624
647
|
buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
|
|
625
648
|
ariaLabel="Font size"
|
|
626
|
-
placeholder=
|
|
649
|
+
placeholder={defaultSize}
|
|
650
|
+
prefix="tT"
|
|
651
|
+
title={`Size — default ${defaultSize}`}
|
|
627
652
|
editable
|
|
628
653
|
value={(editor.getAttributes('textStyle').fontSize as string | undefined) ?? ''}
|
|
629
|
-
options={[{ value: '', label:
|
|
654
|
+
options={[{ value: '', label: `${defaultSize} (default)` }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
|
|
630
655
|
onChange={raw => {
|
|
631
656
|
const size = /^\d+(\.\d+)?$/.test(raw) ? `${raw}px` : raw
|
|
632
|
-
if (!size || size
|
|
657
|
+
if (!size || /default/i.test(size)) editor.chain().focus().unsetFontSize().run()
|
|
633
658
|
else if (/^\d+(\.\d+)?(px|pt|em|rem|%)$/.test(size)) editor.chain().focus().setFontSize(size).run()
|
|
634
659
|
}}
|
|
635
660
|
/>
|
|
661
|
+
<MailSelect
|
|
662
|
+
buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
|
|
663
|
+
ariaLabel="Line spacing"
|
|
664
|
+
placeholder={defaultSpacing}
|
|
665
|
+
prefix="↕"
|
|
666
|
+
title={`Line spacing — default ${defaultSpacing}`}
|
|
667
|
+
editable
|
|
668
|
+
value={editor.getAttributes('paragraph').lineSpacing ? String(editor.getAttributes('paragraph').lineSpacing) : ''}
|
|
669
|
+
options={[{ value: '', label: `${defaultSpacing} (default)` }, ...LINE_SPACINGS.map(spacing => ({ value: String(spacing), label: String(spacing) }))]}
|
|
670
|
+
onChange={raw => {
|
|
671
|
+
const spacing = parseFloat(raw)
|
|
672
|
+
if (!raw.trim() || /default/i.test(raw)) editor.chain().focus().updateAttributes('paragraph', { lineSpacing: null }).run()
|
|
673
|
+
else if (isSpacing(spacing)) editor.chain().focus().updateAttributes('paragraph', { lineSpacing: spacing }).run()
|
|
674
|
+
}}
|
|
675
|
+
/>
|
|
636
676
|
</Group>
|
|
637
677
|
|
|
638
678
|
<Group>
|
|
@@ -650,46 +690,57 @@ function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadIm
|
|
|
650
690
|
</Btn>
|
|
651
691
|
</Group>
|
|
652
692
|
|
|
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
|
-
|
|
693
|
+
{phone ? (
|
|
694
|
+
<Group>
|
|
695
|
+
<Btn title="Colours" active={palette} onClick={() => setPalette(open => !open)}>
|
|
696
|
+
<span className={styles.rteColourDot} aria-hidden />
|
|
697
|
+
</Btn>
|
|
698
|
+
<Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}>
|
|
699
|
+
✕
|
|
700
|
+
</Btn>
|
|
701
|
+
</Group>
|
|
702
|
+
) : (
|
|
703
|
+
<Group>
|
|
704
|
+
<span className={styles.rteSwatches} role="group" aria-label="Text colour">
|
|
705
|
+
{TEXT_COLOURS.map(colour => (
|
|
706
|
+
<button
|
|
707
|
+
key={colour}
|
|
708
|
+
type="button"
|
|
709
|
+
title={`Text ${colour}`}
|
|
710
|
+
aria-label={`Text colour ${colour}`}
|
|
711
|
+
className={styles.rteSwatch}
|
|
712
|
+
style={{ background: colour }}
|
|
713
|
+
onMouseDown={event => event.preventDefault()}
|
|
714
|
+
onClick={() => editor.chain().focus().setColor(colour).run()}
|
|
715
|
+
/>
|
|
716
|
+
))}
|
|
717
|
+
<label className={styles.rteSwatchCustom} title="Any other colour">
|
|
718
|
+
<input
|
|
719
|
+
type="color"
|
|
720
|
+
aria-label="Choose any text colour"
|
|
721
|
+
onChange={event => editor.chain().focus().setColor(event.target.value).run()}
|
|
722
|
+
/>
|
|
723
|
+
</label>
|
|
724
|
+
</span>
|
|
725
|
+
<span className={styles.rteSwatches} role="group" aria-label="Highlight">
|
|
726
|
+
{HIGHLIGHTS.map(colour => (
|
|
727
|
+
<button
|
|
728
|
+
key={colour}
|
|
729
|
+
type="button"
|
|
730
|
+
title={`Highlight ${colour}`}
|
|
731
|
+
aria-label={`Highlight ${colour}`}
|
|
732
|
+
className={`${styles.rteSwatch} ${styles.rteSwatchHi}`}
|
|
733
|
+
style={{ background: colour }}
|
|
734
|
+
onMouseDown={event => event.preventDefault()}
|
|
735
|
+
onClick={() => editor.chain().focus().toggleHighlight({ color: colour }).run()}
|
|
736
|
+
/>
|
|
737
|
+
))}
|
|
738
|
+
</span>
|
|
739
|
+
<Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}>
|
|
740
|
+
✕
|
|
741
|
+
</Btn>
|
|
742
|
+
</Group>
|
|
743
|
+
)}
|
|
693
744
|
|
|
694
745
|
<Group>
|
|
695
746
|
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
|
@@ -750,9 +801,73 @@ function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadIm
|
|
|
750
801
|
</Btn>
|
|
751
802
|
</Group>
|
|
752
803
|
</div>
|
|
804
|
+
{phone && (
|
|
805
|
+
<div className={`${styles.rtePalette} ${palette ? styles.rtePaletteOpen : ''}`} aria-hidden={!palette}>
|
|
806
|
+
<span className={styles.rtePaletteGroup} role="group" aria-label="Text colour">
|
|
807
|
+
{TEXT_COLOURS.slice(0, 6).map(colour => (
|
|
808
|
+
<button
|
|
809
|
+
key={colour}
|
|
810
|
+
type="button"
|
|
811
|
+
title={`Text ${colour}`}
|
|
812
|
+
aria-label={`Text colour ${colour}`}
|
|
813
|
+
className={styles.rteSwatch}
|
|
814
|
+
style={{ background: colour }}
|
|
815
|
+
tabIndex={palette ? 0 : -1}
|
|
816
|
+
onMouseDown={event => event.preventDefault()}
|
|
817
|
+
onClick={() => editor.chain().focus().setColor(colour).run()}
|
|
818
|
+
/>
|
|
819
|
+
))}
|
|
820
|
+
<label className={styles.rteSwatchCustom} title="Any other colour">
|
|
821
|
+
<input type="color" aria-label="Choose any text colour" tabIndex={palette ? 0 : -1} onChange={event => editor.chain().focus().setColor(event.target.value).run()} />
|
|
822
|
+
</label>
|
|
823
|
+
</span>
|
|
824
|
+
<span className={styles.rtePaletteGroup} role="group" aria-label="Highlight">
|
|
825
|
+
{HIGHLIGHTS.slice(0, 4).map(colour => (
|
|
826
|
+
<button
|
|
827
|
+
key={colour}
|
|
828
|
+
type="button"
|
|
829
|
+
title={`Highlight ${colour}`}
|
|
830
|
+
aria-label={`Highlight ${colour}`}
|
|
831
|
+
className={`${styles.rteSwatch} ${styles.rteSwatchHi}`}
|
|
832
|
+
style={{ background: colour }}
|
|
833
|
+
tabIndex={palette ? 0 : -1}
|
|
834
|
+
onMouseDown={event => event.preventDefault()}
|
|
835
|
+
onClick={() => editor.chain().focus().toggleHighlight({ color: colour }).run()}
|
|
836
|
+
/>
|
|
837
|
+
))}
|
|
838
|
+
</span>
|
|
839
|
+
</div>
|
|
840
|
+
)}
|
|
841
|
+
</>
|
|
753
842
|
)
|
|
754
843
|
}
|
|
755
844
|
|
|
845
|
+
const isSpacing = (value: number) => Number.isFinite(value) && value >= 0.8 && value <= 3
|
|
846
|
+
|
|
847
|
+
const LineSpacing = Extension.create({
|
|
848
|
+
name: 'lineSpacing',
|
|
849
|
+
addGlobalAttributes() {
|
|
850
|
+
return [
|
|
851
|
+
{
|
|
852
|
+
types: ['paragraph'],
|
|
853
|
+
attributes: {
|
|
854
|
+
lineSpacing: {
|
|
855
|
+
default: null,
|
|
856
|
+
parseHTML: (element: HTMLElement) => {
|
|
857
|
+
const value = parseFloat(element.style.lineHeight)
|
|
858
|
+
return isSpacing(value) ? value : null
|
|
859
|
+
},
|
|
860
|
+
renderHTML: (attributes: Record<string, unknown>) => {
|
|
861
|
+
const value = attributes.lineSpacing as number | null
|
|
862
|
+
return value ? { style: `line-height:${value};margin:0 0 ${paragraphGap(value)};` } : {}
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
]
|
|
868
|
+
},
|
|
869
|
+
})
|
|
870
|
+
|
|
756
871
|
export default function RichEditor({
|
|
757
872
|
html,
|
|
758
873
|
onChange,
|
|
@@ -811,6 +926,7 @@ export default function RichEditor({
|
|
|
811
926
|
TextStyle,
|
|
812
927
|
FontFamily,
|
|
813
928
|
FontSize,
|
|
929
|
+
LineSpacing,
|
|
814
930
|
Color,
|
|
815
931
|
Highlight.configure({ multicolor: true }),
|
|
816
932
|
Link.configure({ openOnClick: false, autolink: true }),
|
|
@@ -849,10 +965,10 @@ export default function RichEditor({
|
|
|
849
965
|
return (
|
|
850
966
|
<div
|
|
851
967
|
className={styles.rte}
|
|
852
|
-
style={{ '--rte-font': fontStack(baseFont?.family ?? ''), '--rte-size': baseFont?.size || '15px' } as React.CSSProperties}
|
|
968
|
+
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
969
|
>
|
|
854
970
|
{fontFaceCss && <style>{fontFaceCss}</style>}
|
|
855
|
-
<Toolbar editor={editor} uploadImage={uploadImage} fonts={fonts} />
|
|
971
|
+
<Toolbar editor={editor} uploadImage={uploadImage} fonts={fonts} baseFont={baseFont} />
|
|
856
972
|
<EditorContent editor={editor} className={styles.rteContent} />
|
|
857
973
|
</div>
|
|
858
974
|
)
|
|
@@ -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
|
+
}
|