@novacraft-engineering/mailbox 0.2.0 → 0.3.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.
@@ -12,7 +12,7 @@ function fromAddressOf(email: SentEmail): string {
12
12
 
13
13
  export const runtime = 'nodejs'
14
14
 
15
- type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string }
15
+ type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string; attachmentCount: number }
16
16
 
17
17
  const KNOWN_AUTO_SUBJECTS = new Set([`Reset your ${BRAND.name} Mail password`])
18
18
 
@@ -65,6 +65,7 @@ export async function GET(req: Request) {
65
65
  createdAt: item.createdAt,
66
66
  scheduledAt: null,
67
67
  lastEvent: item.lastEvent ?? '',
68
+ attachmentCount: item.attachmentCount ?? 0,
68
69
  }))
69
70
 
70
71
  // Sent mail is scoped the same way the inbox is: to the signed-in mailbox, whatever
@@ -106,7 +107,7 @@ export async function GET(req: Request) {
106
107
  size: null,
107
108
  read: true,
108
109
  starred: Boolean(flagOf[email.id]?.starred),
109
- hasAttachment: false,
110
+ hasAttachment: (email.attachmentCount ?? 0) > 0,
110
111
  labels: [],
111
112
  folder: flagOf[email.id]?.trashed ? 'trash' : flagOf[email.id]?.archived ? 'archive' : 'sent',
112
113
  }),
@@ -37,7 +37,7 @@ export async function GET(req: Request) {
37
37
  // Counting scans the whole match. Unfiltered, the sidebar already has this number
38
38
  // from countFolders, so only a search — whose match nothing else knows — pays for it.
39
39
  threadId,
40
- withTotal: !cursor && Boolean(text),
40
+ withTotal: !cursor && Boolean(text || params.get('from') || params.get('to') || params.get('label') || params.get('unread') || params.get('starred') || params.get('attachment')),
41
41
  unread: params.get('unread') === '1' ? true : undefined,
42
42
  starred: params.get('starred') === '1' ? true : undefined,
43
43
  hasAttachment: params.get('attachment') === '1' || undefined,
@@ -12,6 +12,16 @@ export function anchoredMenuStyle(anchor: HTMLElement): React.CSSProperties {
12
12
  : { ...base, top: rect.bottom + 6 }
13
13
  }
14
14
 
15
+ export const GLYPH = {
16
+ font: <span className={styles.selectGlyphSerif}>A</span>,
17
+ size: 'tT',
18
+ spacing: (
19
+ <svg viewBox="0 0 12 12" fill="none" aria-hidden>
20
+ <path d="M3 2v8M1.6 3.6L3 2l1.4 1.6M1.6 8.4L3 10l1.4-1.6M6.5 4.2h4M6.5 7.8h4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
21
+ </svg>
22
+ ),
23
+ }
24
+
15
25
  export const CHEVRON = (
16
26
  <svg className={styles.selectChevron} viewBox="0 0 12 8" fill="none" aria-hidden>
17
27
  <path d="M1 1l5 5 5-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
@@ -28,11 +38,10 @@ export default function MailSelect({
28
38
  editable = false,
29
39
  optionStyle,
30
40
  prefix,
31
- compact = false,
32
- title,
41
+ label,
33
42
  }: {
34
43
  value: string
35
- options: Array<{ value: string; label: string }>
44
+ options: Array<{ value: string; label: string; closed?: string }>
36
45
  onChange: (value: string) => void
37
46
  ariaLabel: string
38
47
  buttonClassName?: string
@@ -40,11 +49,8 @@ export default function MailSelect({
40
49
  /** Typed values are accepted too: committed on Enter or when the field loses focus. */
41
50
  editable?: boolean
42
51
  optionStyle?: (value: string) => React.CSSProperties
43
- /** A quiet glyph before the value, so three selects in a row can be told apart. */
44
52
  prefix?: React.ReactNode
45
- /** Closed, show only the glyph; the value stays in the title and the menu. */
46
- compact?: boolean
47
- title?: string
53
+ label?: string
48
54
  }) {
49
55
  const [open, setOpen] = useState(false)
50
56
  const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
@@ -72,24 +78,26 @@ export default function MailSelect({
72
78
  }, [open])
73
79
 
74
80
  const current = options.find(option => option.value === value)
81
+ const shown = current?.closed ?? current?.label
75
82
  const toggle = () => {
76
83
  if (!open && anchorRef.current) setMenuStyle(anchoredMenuStyle(anchorRef.current))
77
84
  setOpen(prev => !prev)
78
85
  }
79
86
  const commitTyped = () => {
80
- if (typed !== null && typed !== (current?.label ?? value)) onChange(typed.trim())
87
+ if (typed !== null && typed !== (shown ?? value)) onChange(typed.trim())
81
88
  setTyped(null)
82
89
  }
83
90
 
84
91
  return (
85
92
  <span className={styles.select} ref={wrapRef}>
86
93
  {editable ? (
87
- <span ref={anchorRef as React.RefObject<HTMLSpanElement>} className={`${styles.selectBtn} ${styles.selectEditable} ${buttonClassName ?? ''}`} title={title}>
94
+ <span ref={anchorRef as React.RefObject<HTMLSpanElement>} className={`${styles.selectBtn} ${styles.selectEditable} ${buttonClassName ?? ''}`}>
88
95
  {prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
96
+ {label && <span className={styles.selectLabel}>{label}</span>}
89
97
  <input
90
98
  className={styles.selectInput}
91
99
  aria-label={ariaLabel}
92
- value={typed ?? current?.label ?? value}
100
+ value={typed ?? shown ?? value}
93
101
  placeholder={placeholder}
94
102
  onFocus={() => { if (!open) toggle() }}
95
103
  onChange={event => setTyped(event.target.value)}
@@ -113,12 +121,12 @@ export default function MailSelect({
113
121
  className={`${styles.selectBtn} ${buttonClassName ?? ''}`}
114
122
  aria-haspopup="listbox"
115
123
  aria-expanded={open}
116
- aria-label={compact ? `${ariaLabel}: ${current?.label ?? placeholder}` : ariaLabel}
117
- title={title ?? (compact ? `${ariaLabel}: ${current?.label ?? placeholder}` : undefined)}
124
+ aria-label={ariaLabel}
118
125
  onClick={toggle}
119
126
  >
120
127
  {prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
121
- {!compact && <span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{current?.label ?? placeholder}</span>}
128
+ {label && <span className={styles.selectLabel}>{label}</span>}
129
+ <span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{shown ?? placeholder}</span>
122
130
  {CHEVRON}
123
131
  </button>
124
132
  )}
@@ -10,7 +10,7 @@ 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
12
  import { LINE_SPACINGS, BUILTIN_FONTS, FONT_SIZES, fontStack, lineSpacingOf, paragraphGap, type BaseFont } from '@/lib/fonts'
13
- import MailSelect from './MailSelect'
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'
@@ -631,12 +631,10 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
631
631
  <MailSelect
632
632
  buttonClassName={styles.rteSelect}
633
633
  ariaLabel="Font"
634
- placeholder={`${defaultFamily} (default)`}
635
- prefix={<span className={styles.selectGlyphSerif}>A</span>}
636
- compact
637
- title={`Font: ${currentFamily || `${defaultFamily} (default)`}`}
634
+ label="Font"
635
+ prefix={GLYPH.font}
638
636
  value={currentFamily}
639
- options={[{ value: '', label: `${defaultFamily} (default)` }, ...[...BUILTIN_FONTS, ...fonts.filter(font => !BUILTIN_FONTS.includes(font))].map(font => ({ value: font, label: font }))]}
637
+ options={[{ value: '', label: `${defaultFamily} (default)`, closed: defaultFamily }, ...[...BUILTIN_FONTS, ...fonts.filter(font => !BUILTIN_FONTS.includes(font))].map(font => ({ value: font, label: font }))]}
640
638
  optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
641
639
  onChange={family => {
642
640
  if (family) editor.chain().focus().setFontFamily(family).run()
@@ -646,12 +644,12 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
646
644
  <MailSelect
647
645
  buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
648
646
  ariaLabel="Font size"
647
+ label="Size"
649
648
  placeholder={defaultSize}
650
- prefix="tT"
651
- title={`Size — default ${defaultSize}`}
649
+ prefix={GLYPH.size}
652
650
  editable
653
651
  value={(editor.getAttributes('textStyle').fontSize as string | undefined) ?? ''}
654
- options={[{ value: '', label: `${defaultSize} (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', '') }))]}
655
653
  onChange={raw => {
656
654
  const size = /^\d+(\.\d+)?$/.test(raw) ? `${raw}px` : raw
657
655
  if (!size || /default/i.test(size)) editor.chain().focus().unsetFontSize().run()
@@ -660,13 +658,13 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
660
658
  />
661
659
  <MailSelect
662
660
  buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
663
- ariaLabel="Line spacing"
661
+ ariaLabel="Line height"
662
+ label="Line height"
664
663
  placeholder={defaultSpacing}
665
- prefix="↕"
666
- title={`Line spacing — default ${defaultSpacing}`}
664
+ prefix={GLYPH.spacing}
667
665
  editable
668
666
  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) }))]}
667
+ options={[{ value: '', label: `${defaultSpacing} (default)`, closed: defaultSpacing }, ...LINE_SPACINGS.map(spacing => ({ value: String(spacing), label: String(spacing) }))]}
670
668
  onChange={raw => {
671
669
  const spacing = parseFloat(raw)
672
670
  if (!raw.trim() || /default/i.test(raw)) editor.chain().focus().updateAttributes('paragraph', { lineSpacing: null }).run()
@@ -3,17 +3,49 @@
3
3
  import { useEffect, useRef, type ReactNode } from 'react'
4
4
  import styles from './page.module.css'
5
5
 
6
+ export const TICKER_SPOTS = [
7
+ { group: 'Mail list', on: false, spots: [['listFrom', 'Sender'], ['listSubject', 'Subject'], ['listSnippet', 'Preview']] },
8
+ { group: 'Conversation', on: true, spots: [['threadFrom', 'Sender'], ['threadSnippet', 'Preview']] },
9
+ { group: 'Reader', on: true, spots: [['readerFrom', 'From'], ['readerTo', 'To and Cc']] },
10
+ { group: 'Attachments', on: true, spots: [['attachName', 'File names in the row'], ['attachChip', 'File names in the composer']] },
11
+ { group: 'Reply bar', on: true, spots: [['replyRecipients', 'Recipients']] },
12
+ ] as const
13
+
14
+ export type TickerSpot = (typeof TICKER_SPOTS)[number]['spots'][number][0]
15
+ export type TickerSettings = Partial<Record<TickerSpot, boolean>>
16
+
17
+ const DEFAULT_ON = new Map<string, boolean>(TICKER_SPOTS.flatMap(group => group.spots.map(([spot]) => [spot, group.on])))
18
+
19
+ export function tickerDefault(spot: TickerSpot): boolean {
20
+ return DEFAULT_ON.get(spot) ?? true
21
+ }
22
+
23
+ export function tickerSettingsFrom(raw: unknown): TickerSettings {
24
+ const kept: TickerSettings = {}
25
+ if (raw && typeof raw === 'object') {
26
+ for (const [spot, value] of Object.entries(raw)) {
27
+ if (DEFAULT_ON.has(spot) && typeof value === 'boolean') kept[spot as TickerSpot] = value
28
+ }
29
+ }
30
+ return kept
31
+ }
32
+
6
33
  /**
7
34
  * One line of text that, when it does not fit, glides back and forth instead of ending in
8
35
  * an ellipsis. Measured on mount and whenever the host or its text changes size.
9
36
  */
10
- export default function Ticker({ className, title, children }: { className?: string; title?: string; children: ReactNode }) {
37
+ export default function Ticker({ className, title, enabled = true, children }: { className?: string; title?: string; enabled?: boolean; children: ReactNode }) {
11
38
  const host = useRef<HTMLSpanElement>(null)
12
39
  const text = useRef<HTMLSpanElement>(null)
13
40
  useEffect(() => {
14
41
  const outer = host.current
15
42
  const inner = text.current
16
43
  if (!outer || !inner) return
44
+ if (!enabled) {
45
+ delete outer.dataset.ticker
46
+ outer.style.removeProperty('--ticker-shift')
47
+ return
48
+ }
17
49
  const measure = () => {
18
50
  const shift = inner.scrollWidth - outer.clientWidth
19
51
  if (shift > 4) {
@@ -31,7 +63,7 @@ export default function Ticker({ className, title, children }: { className?: str
31
63
  observer.observe(outer)
32
64
  observer.observe(inner)
33
65
  return () => observer.disconnect()
34
- }, [children])
66
+ }, [children, enabled])
35
67
  return (
36
68
  <span ref={host} className={`${className ?? ''} ${styles.tickerHost}`} title={title}>
37
69
  <span ref={text} className={styles.tickerText}>{children}</span>
@@ -196,6 +196,7 @@
196
196
  .folderActive {
197
197
  background: var(--nc-ink-3);
198
198
  color: var(--nc-fg-1);
199
+ box-shadow: inset 0 0 0 1.5px var(--nc-violet-500);
199
200
  }
200
201
  .folder svg { width: 16px; height: 16px; flex-shrink: 0; }
201
202
  /* Total for the folder — plain and muted. */
@@ -527,6 +528,12 @@
527
528
  cursor: pointer;
528
529
  }
529
530
  .crossFolderHint:hover { border-color: var(--nc-violet-500); }
531
+ .emptyNudge {
532
+ align-self: center;
533
+ margin: 2px 0 0;
534
+ padding: 8px 14px;
535
+ font-size: 13px;
536
+ }
530
537
  .refreshBtn svg { width: 14px; height: 14px; }
531
538
  .filterChip {
532
539
  margin-left: auto;
@@ -1681,7 +1688,7 @@
1681
1688
  border-radius: 6px;
1682
1689
  background: var(--nc-ink-1);
1683
1690
  color: var(--nc-fg-1);
1684
- font: 500 12px/1 var(--nc-font-sans);
1691
+ font: 500 11px/1 var(--nc-font-sans);
1685
1692
  }
1686
1693
  .rteSwatches { display: flex; gap: 3px; }
1687
1694
  .rteSwatch {
@@ -2221,13 +2228,22 @@
2221
2228
  .selectBtn:hover { border-color: var(--nc-line-2); color: var(--nc-fg-1); }
2222
2229
  .selectGlyph {
2223
2230
  flex-shrink: 0;
2224
- font: 600 11px/1 var(--nc-font-mono);
2225
- letter-spacing: .02em;
2226
- color: var(--nc-fg-4);
2231
+ display: inline-flex;
2232
+ align-items: center;
2233
+ justify-content: center;
2234
+ width: 16px;
2235
+ height: 16px;
2236
+ font: 700 11px/1 var(--nc-font-sans);
2237
+ letter-spacing: .01em;
2238
+ color: var(--nc-fg-3);
2239
+ }
2240
+ .selectGlyph svg { width: 13px; height: 13px; }
2241
+ .selectGlyphSerif { font: 700 13px/1 Georgia, 'Times New Roman', serif; }
2242
+ .selectLabel {
2243
+ flex-shrink: 0;
2244
+ color: var(--nc-fg-3);
2245
+ white-space: nowrap;
2227
2246
  }
2228
- .selectGlyphSerif { font: 600 14px/1 Georgia, 'Times New Roman', serif; color: var(--nc-fg-3); }
2229
- .selectEditable .selectGlyph { margin-left: 9px; }
2230
- .selectEditable .selectGlyph + .selectInput { padding-left: 6px; }
2231
2247
  .selectBtn > span {
2232
2248
  overflow: hidden;
2233
2249
  text-overflow: ellipsis;
@@ -2270,6 +2286,7 @@
2270
2286
  .selectOptionOn { background: var(--nc-ink-4); color: var(--nc-violet-200); }
2271
2287
 
2272
2288
  .toolbar .selectBtn { padding: 7px 10px; font-size: 12px; }
2289
+ .selectBtn.rteSelect { font-size: 11.5px; }
2273
2290
  .campaignSelectBtn {
2274
2291
  width: 100%;
2275
2292
  justify-content: space-between;
@@ -2550,6 +2567,40 @@
2550
2567
  overflow: hidden;
2551
2568
  background: transparent;
2552
2569
  }
2570
+ .quoteLine {
2571
+ display: flex;
2572
+ align-items: center;
2573
+ gap: 10px;
2574
+ width: 100%;
2575
+ margin: 2px 0 14px;
2576
+ padding: 0;
2577
+ border: 0;
2578
+ background: transparent;
2579
+ cursor: pointer;
2580
+ }
2581
+ .quoteLineRule {
2582
+ flex: 1;
2583
+ height: 1px;
2584
+ background: var(--nc-line-1);
2585
+ }
2586
+ .quoteLineLabel {
2587
+ display: inline-flex;
2588
+ align-items: center;
2589
+ gap: 5px;
2590
+ flex-shrink: 0;
2591
+ padding: 4px 11px;
2592
+ border: 1px solid var(--nc-line-1);
2593
+ border-radius: 999px;
2594
+ background: var(--nc-ink-2);
2595
+ color: var(--nc-fg-3);
2596
+ font: 500 11.5px/1 var(--nc-font-sans);
2597
+ transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out);
2598
+ }
2599
+ .quoteLineLabel svg { width: 11px; height: 8px; }
2600
+ .quoteLine:hover .quoteLineLabel { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
2601
+ .quoteLine[aria-expanded='true'] .quoteLineLabel svg { transform: rotate(180deg); }
2602
+
2603
+ .threadMsgStatic .threadMsgHead { cursor: default; }
2553
2604
  .threadMsg + .threadMsg { border-top: 1px solid var(--nc-line-1); }
2554
2605
  .threadMsgOpen .threadMsgHead { border-bottom: 1px solid var(--nc-line-1); }
2555
2606
  .threadMsgHead {
@@ -2591,6 +2642,34 @@
2591
2642
  text-overflow: ellipsis;
2592
2643
  white-space: nowrap;
2593
2644
  }
2645
+ .threadMsgRecips {
2646
+ display: flex;
2647
+ align-items: baseline;
2648
+ justify-content: flex-start;
2649
+ gap: 8px;
2650
+ width: 100%;
2651
+ min-width: 0;
2652
+ margin-top: 3px;
2653
+ overflow: hidden;
2654
+ }
2655
+ .threadMsgRecipsText {
2656
+ /* Hugs the addresses so Details sits beside them rather than out at the far margin. */
2657
+ flex: 0 1 auto;
2658
+ min-width: 0;
2659
+ font: 400 11.5px/1.35 var(--nc-font-sans);
2660
+ color: var(--nc-fg-3);
2661
+ }
2662
+ .threadMsgDetails {
2663
+ flex-shrink: 0;
2664
+ padding: 0;
2665
+ border: 0;
2666
+ background: transparent;
2667
+ color: var(--nc-violet-500);
2668
+ font: 500 11px/1.35 var(--nc-font-sans);
2669
+ cursor: pointer;
2670
+ }
2671
+ .threadMsgDetails:hover { text-decoration: underline; }
2672
+
2594
2673
  .threadMsgDate {
2595
2674
  font: 400 11px/1 var(--nc-font-mono);
2596
2675
  color: var(--nc-fg-3);
@@ -3935,6 +4014,18 @@
3935
4014
  font: 500 14px/1.4 var(--nc-font-sans);
3936
4015
  color: var(--nc-fg-1);
3937
4016
  }
4017
+ .tickerGroup {
4018
+ display: flex;
4019
+ flex-direction: column;
4020
+ gap: 6px;
4021
+ margin-top: 12px;
4022
+ }
4023
+ .tickerGroupName {
4024
+ font: 600 11px/1 var(--nc-font-mono);
4025
+ letter-spacing: .04em;
4026
+ text-transform: uppercase;
4027
+ color: var(--nc-fg-3);
4028
+ }
3938
4029
  .settingsToggle input {
3939
4030
  width: 16px;
3940
4031
  height: 16px;
@@ -6592,20 +6683,23 @@
6592
6683
  .fontInput { flex: 1 1 160px; height: 34px; padding: 0 10px; border: 1px solid var(--nc-line-1); border-radius: 8px; background: var(--nc-ink-1); color: var(--nc-fg-1); font: 500 13px/1 var(--nc-font-sans); }
6593
6684
  .fontUpload { height: 34px; display: inline-flex; align-items: center; padding: 0 12px; border: 1px solid var(--nc-line-1); border-radius: 8px; font: 500 13px/1 var(--nc-font-sans); color: var(--nc-fg-1); cursor: pointer; }
6594
6685
 
6595
- .selectEditable { padding: 0 6px 0 0; gap: 0; }
6596
- .selectInput {
6686
+ .selectEditable { padding: 0 6px 0 8px; gap: 6px; }
6687
+ .selectBtn .selectInput {
6597
6688
  width: 3.5em;
6598
6689
  min-width: 0;
6599
6690
  height: 100%;
6600
- padding: 0 0 0 10px;
6691
+ padding: 0;
6601
6692
  border: 0;
6693
+ border-radius: 0;
6602
6694
  background: transparent;
6695
+ box-shadow: none;
6696
+ appearance: none;
6603
6697
  color: inherit;
6604
6698
  font: inherit;
6605
6699
  outline: none;
6606
6700
  }
6607
- /* Wide enough for the word it actually shows: at 3.5em "Default" arrived as "Defi". */
6608
- .rteSizeSelect .selectInput { padding-left: 8px; width: 6em; }
6701
+ .selectBtn .selectInput:focus { border: 0; box-shadow: none; }
6702
+ .rteSizeSelect .selectInput { width: 3.2em; }
6609
6703
  .selectChevronBtn { display: inline-flex; align-items: center; padding: 0 4px; border: 0; background: transparent; color: inherit; cursor: pointer; }
6610
6704
  .mailboxDomainSelect.selectBtn { padding: 4px 6px; background: transparent; border: 0; }
6611
6705