@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.
Files changed (38) hide show
  1. package/README.md +7 -2
  2. package/app/api/mail/accessors/route.ts +28 -24
  3. package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
  4. package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
  5. package/app/api/mail/emails/[id]/route.ts +17 -9
  6. package/app/api/mail/emails/route.ts +35 -4
  7. package/app/api/mail/inbox/counts/route.ts +5 -4
  8. package/app/api/mail/inbox/route.ts +41 -262
  9. package/app/api/mail/maintenance/received/route.ts +64 -0
  10. package/app/api/mail/pixel/[id]/route.ts +8 -2
  11. package/app/api/mail/request-reset/route.ts +29 -25
  12. package/app/api/mail/signature-logo/route.ts +33 -8
  13. package/app/api/mail/threads/route.ts +1 -1
  14. package/app/brand/[file]/placeholder.ts +70 -0
  15. package/app/brand/[file]/route.ts +7 -1
  16. package/app/layout.tsx +10 -2
  17. package/app/mail/MailSelect.tsx +14 -3
  18. package/app/mail/RichEditor.tsx +168 -52
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +624 -70
  21. package/app/mail/page.tsx +1059 -244
  22. package/app/mail/search.ts +1 -1
  23. package/app/share/[id]/page.tsx +5 -3
  24. package/app/share/[id]/share.module.css +5 -5
  25. package/lib/email-html.ts +9 -1
  26. package/lib/emails/index.ts +26 -0
  27. package/lib/emails/templates/action.html +47 -0
  28. package/lib/fonts.ts +7 -1
  29. package/lib/mail-provider.ts +50 -5
  30. package/lib/mailbox.ts +303 -34
  31. package/lib/receive.ts +318 -0
  32. package/lib/sent-access.ts +22 -0
  33. package/lib/ses-send.ts +49 -0
  34. package/package.json +2 -2
  35. package/public/brand/README.md +5 -1
  36. package/scripts/sample-files.mjs +208 -28
  37. package/scripts/seed-dev.mjs +69 -26
  38. package/tools/README.md +1 -1
@@ -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="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 }))]}
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="Size"
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: 'Default' }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
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 === 'Default') editor.chain().focus().unsetFontSize().run()
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
- <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
+ {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
+ }