@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.
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 +23 -4
  18. package/app/mail/RichEditor.tsx +167 -53
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +643 -76
  21. package/app/mail/page.tsx +1062 -247
  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,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
- 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
+ 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
- placeholder="Size"
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: 'Default' }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
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 === 'Default') editor.chain().focus().unsetFontSize().run()
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
- <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>
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
+ }