@novacraft-engineering/mailbox 0.4.24 → 0.4.26

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 (42) hide show
  1. package/.env.example +8 -2
  2. package/app/api/mail/company-dictionary/route.ts +36 -0
  3. package/app/api/mail/company-signature/route.ts +1 -1
  4. package/app/api/mail/events/ses/route.ts +88 -0
  5. package/app/api/mail/grammar/route.ts +52 -0
  6. package/app/api/mail/scheduled/route.ts +15 -1
  7. package/app/mail/RichEditor.tsx +450 -5
  8. package/app/mail/page.module.css +275 -0
  9. package/app/mail/page.tsx +115 -12
  10. package/app/mail/tabKey.ts +52 -0
  11. package/app/mail/writing/WritingPanel.tsx +259 -0
  12. package/app/mail/writing/assist.ts +139 -0
  13. package/app/mail/writing/checks.test.ts +48 -0
  14. package/app/mail/writing/checks.ts +58 -0
  15. package/app/mail/writing/find.ts +52 -0
  16. package/app/mail/writing/grammar.ts +100 -0
  17. package/app/mail/writing/live.ts +34 -0
  18. package/app/mail/writing/paste.test.ts +17 -0
  19. package/app/mail/writing/paste.ts +30 -0
  20. package/app/mail/writing/settings.ts +98 -0
  21. package/app/mail/writing/spell.worker.ts +40 -0
  22. package/app/mail/writing/spelling.ts +164 -0
  23. package/app/mail/writing/templates.ts +40 -0
  24. package/app/mail/writing/text.test.ts +88 -0
  25. package/app/mail/writing/text.ts +106 -0
  26. package/lib/dev-auth.ts +5 -3
  27. package/lib/dmarc-report.test.ts +18 -0
  28. package/lib/mail-provider.ts +1 -2
  29. package/lib/mailbox.ts +7 -2
  30. package/lib/receive.test.ts +24 -0
  31. package/lib/receive.ts +55 -11
  32. package/lib/scheduled.ts +67 -33
  33. package/lib/ses-send.test.ts +21 -0
  34. package/lib/ses-send.ts +29 -5
  35. package/lib/sns.ts +68 -0
  36. package/package.json +3 -1
  37. package/public/dictionaries/en-GB.LICENSE +347 -0
  38. package/public/dictionaries/en-GB.aff +205 -0
  39. package/public/dictionaries/en-GB.dic +49602 -0
  40. package/public/dictionaries/en-US.LICENSE +347 -0
  41. package/public/dictionaries/en-US.aff +205 -0
  42. package/public/dictionaries/en-US.dic +49569 -0
@@ -7262,3 +7262,278 @@
7262
7262
  .fieldBad,
7263
7263
  .msgBad { animation: none; }
7264
7264
  }
7265
+
7266
+ /* Writing tools */
7267
+ .rteBody {
7268
+ position: relative;
7269
+ display: flex;
7270
+ flex-direction: column;
7271
+ flex: 1;
7272
+ min-height: 0;
7273
+ }
7274
+ .rteCorner {
7275
+ position: absolute;
7276
+ top: 8px;
7277
+ right: 12px;
7278
+ z-index: 5;
7279
+ display: flex;
7280
+ gap: 4px;
7281
+ }
7282
+ .rteCornerBtn {
7283
+ display: inline-flex;
7284
+ align-items: center;
7285
+ gap: 2px;
7286
+ height: 24px;
7287
+ padding: 0 8px;
7288
+ border: 1px solid var(--nc-line-1);
7289
+ border-radius: 999px;
7290
+ background: var(--nc-ink-2);
7291
+ color: var(--nc-fg-3);
7292
+ font: 600 11px/1 var(--nc-font-sans);
7293
+ cursor: pointer;
7294
+ }
7295
+ .rteCornerBtn:hover { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
7296
+ .rteCornerOn { color: var(--nc-violet-500); border-color: var(--nc-violet-300); }
7297
+ .rteWordCount {
7298
+ align-self: flex-end;
7299
+ padding: 4px 18px 8px;
7300
+ color: var(--nc-fg-4);
7301
+ font: 500 11px/1 var(--nc-font-sans);
7302
+ font-variant-numeric: tabular-nums;
7303
+ }
7304
+ .rteContent :global(.rteMisspelt) {
7305
+ text-decoration: underline wavy var(--nc-danger);
7306
+ text-decoration-skip-ink: none;
7307
+ text-underline-offset: 3px;
7308
+ cursor: pointer;
7309
+ }
7310
+ .rteContent :global(.rteRepeat) {
7311
+ text-decoration: underline wavy var(--nc-warn);
7312
+ text-underline-offset: 3px;
7313
+ cursor: pointer;
7314
+ }
7315
+ .rteContent :global(.rteGrammar) {
7316
+ text-decoration: underline wavy #3B82F6;
7317
+ text-underline-offset: 3px;
7318
+ cursor: pointer;
7319
+ }
7320
+ .rteContent :global(.rteFind) { background: color-mix(in srgb, var(--nc-warn) 30%, transparent); border-radius: 2px; }
7321
+ .rteContent :global(.rteFindCurrent) { background: color-mix(in srgb, var(--nc-warn) 70%, transparent); border-radius: 2px; }
7322
+
7323
+ .rteSuggest {
7324
+ position: absolute;
7325
+ z-index: 45;
7326
+ min-width: 180px;
7327
+ max-width: 300px;
7328
+ display: flex;
7329
+ flex-direction: column;
7330
+ padding: 6px;
7331
+ border: 1px solid var(--nc-line-1);
7332
+ border-radius: 10px;
7333
+ background: var(--nc-ink-2);
7334
+ box-shadow: 0 14px 36px var(--nc-line-soft);
7335
+ }
7336
+ .rteSuggestPick {
7337
+ padding: 6px 10px;
7338
+ border: 0;
7339
+ border-radius: 6px;
7340
+ background: transparent;
7341
+ color: var(--nc-fg-1);
7342
+ font: 600 13px/1.3 var(--nc-font-sans);
7343
+ text-align: left;
7344
+ cursor: pointer;
7345
+ }
7346
+ .rteSuggestPick:hover { background: var(--nc-ink-3); }
7347
+ .rteSuggestNote { margin: 4px 10px 6px; color: var(--nc-fg-3); font: 400 12px/1.4 var(--nc-font-sans); }
7348
+ .rteSuggestActions {
7349
+ display: flex;
7350
+ flex-wrap: wrap;
7351
+ gap: 4px;
7352
+ margin-top: 4px;
7353
+ padding-top: 6px;
7354
+ border-top: 1px solid var(--nc-line-1);
7355
+ }
7356
+ .rteSuggestActions button {
7357
+ padding: 4px 8px;
7358
+ border: 0;
7359
+ border-radius: 6px;
7360
+ background: transparent;
7361
+ color: var(--nc-fg-3);
7362
+ font: 500 12px/1.2 var(--nc-font-sans);
7363
+ cursor: pointer;
7364
+ }
7365
+ .rteSuggestActions button:hover { background: var(--nc-ink-3); color: var(--nc-fg-1); }
7366
+
7367
+ .rteFindBar {
7368
+ display: flex;
7369
+ flex-wrap: wrap;
7370
+ align-items: center;
7371
+ gap: 6px;
7372
+ padding: 6px 18px;
7373
+ border-bottom: 1px solid var(--nc-line-1);
7374
+ background: var(--nc-ink-1);
7375
+ }
7376
+ .rteFindInput {
7377
+ flex: 1 1 140px;
7378
+ min-width: 0;
7379
+ height: 28px;
7380
+ padding: 0 8px;
7381
+ border: 1px solid var(--nc-line-1);
7382
+ border-radius: 6px;
7383
+ background: var(--nc-ink-2);
7384
+ color: var(--nc-fg-1);
7385
+ font: 400 13px/1 var(--nc-font-sans);
7386
+ }
7387
+ .rteFindCount { min-width: 64px; color: var(--nc-fg-3); font: 500 12px/1 var(--nc-font-sans); font-variant-numeric: tabular-nums; }
7388
+ .rteFindBtn {
7389
+ height: 28px;
7390
+ padding: 0 10px;
7391
+ border: 1px solid var(--nc-line-1);
7392
+ border-radius: 6px;
7393
+ background: var(--nc-ink-2);
7394
+ color: var(--nc-fg-2);
7395
+ font: 500 12px/1 var(--nc-font-sans);
7396
+ cursor: pointer;
7397
+ }
7398
+ .rteFindBtn:disabled { opacity: 0.45; cursor: default; }
7399
+
7400
+ .wpPanel {
7401
+ position: absolute;
7402
+ top: 38px;
7403
+ right: 12px;
7404
+ z-index: 44;
7405
+ width: min(360px, calc(100% - 24px));
7406
+ max-height: min(520px, 70vh);
7407
+ display: flex;
7408
+ flex-direction: column;
7409
+ border: 1px solid var(--nc-line-1);
7410
+ border-radius: 12px;
7411
+ background: var(--nc-ink-2);
7412
+ box-shadow: 0 18px 44px var(--nc-line-soft);
7413
+ }
7414
+ .wpHead { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; }
7415
+ .wpTitle { color: var(--nc-fg-1); font: 700 13px/1 var(--nc-font-sans); }
7416
+ .wpClose { border: 0; background: transparent; color: var(--nc-fg-3); font-size: 18px; line-height: 1; cursor: pointer; }
7417
+ .wpTabs { display: flex; gap: 2px; padding: 4px 8px 8px; border-bottom: 1px solid var(--nc-line-1); overflow-x: auto; }
7418
+ .wpTab {
7419
+ flex: none;
7420
+ padding: 5px 8px;
7421
+ border: 0;
7422
+ border-radius: 6px;
7423
+ background: transparent;
7424
+ color: var(--nc-fg-3);
7425
+ font: 600 11.5px/1 var(--nc-font-sans);
7426
+ cursor: pointer;
7427
+ }
7428
+ .wpTabOn { background: var(--nc-ink-3); color: var(--nc-fg-1); }
7429
+ .wpBody { display: flex; flex-direction: column; gap: 2px; padding: 8px 12px 12px; overflow-y: auto; }
7430
+ .wpRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; cursor: pointer; }
7431
+ .wpText { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
7432
+ .wpLabel { color: var(--nc-fg-1); font: 600 12.5px/1.3 var(--nc-font-sans); }
7433
+ .wpHint { color: var(--nc-fg-4); font: 400 11.5px/1.35 var(--nc-font-sans); }
7434
+ .wpIndent { margin-left: 12px; padding-left: 10px; border-left: 2px solid var(--nc-line-1); }
7435
+ .wpSwitch { flex: none; width: 16px; height: 16px; accent-color: var(--nc-violet-500); cursor: pointer; }
7436
+ .wpNumber, .wpInput {
7437
+ height: 28px;
7438
+ padding: 0 8px;
7439
+ border: 1px solid var(--nc-line-1);
7440
+ border-radius: 6px;
7441
+ background: var(--nc-ink-1);
7442
+ color: var(--nc-fg-1);
7443
+ font: 400 12.5px/1 var(--nc-font-sans);
7444
+ }
7445
+ .wpNumber { width: 56px; }
7446
+ .wpInput { width: 100%; }
7447
+ .wpNote { margin: 2px 0 6px; color: var(--nc-fg-3); font: 400 12px/1.4 var(--nc-font-sans); }
7448
+ .wpTemplate { display: flex; align-items: center; gap: 6px; }
7449
+ .wpTemplateName {
7450
+ flex: 1;
7451
+ display: flex;
7452
+ align-items: center;
7453
+ justify-content: space-between;
7454
+ gap: 8px;
7455
+ padding: 7px 8px;
7456
+ border: 0;
7457
+ border-radius: 6px;
7458
+ background: transparent;
7459
+ color: var(--nc-fg-1);
7460
+ font: 600 12.5px/1.2 var(--nc-font-sans);
7461
+ text-align: left;
7462
+ cursor: pointer;
7463
+ }
7464
+ .wpTemplateName:hover { background: var(--nc-ink-3); }
7465
+ .wpShortcut { color: var(--nc-fg-4); font: 500 11px/1 var(--nc-font-mono); }
7466
+ .wpRemove { border: 0; background: transparent; color: var(--nc-fg-4); font-size: 16px; cursor: pointer; }
7467
+ .wpRemove:hover { color: var(--nc-danger); }
7468
+ .wpSave { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--nc-line-1); }
7469
+ .wpButton {
7470
+ align-self: flex-start;
7471
+ height: 28px;
7472
+ padding: 0 12px;
7473
+ border: 0;
7474
+ border-radius: 6px;
7475
+ background: var(--nc-violet-500);
7476
+ color: #fff;
7477
+ font: 600 12px/1 var(--nc-font-sans);
7478
+ cursor: pointer;
7479
+ }
7480
+ .wpButton:disabled { opacity: 0.45; cursor: default; }
7481
+ .wpChips { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 10px; }
7482
+ .wpChip {
7483
+ display: inline-flex;
7484
+ align-items: center;
7485
+ gap: 4px;
7486
+ padding: 3px 4px 3px 8px;
7487
+ border-radius: 999px;
7488
+ background: var(--nc-ink-3);
7489
+ color: var(--nc-fg-2);
7490
+ font: 500 12px/1 var(--nc-font-sans);
7491
+ }
7492
+ .wpChip button { border: 0; background: transparent; color: var(--nc-fg-4); cursor: pointer; font-size: 13px; line-height: 1; }
7493
+ .wpKeys { display: flex; flex-direction: column; gap: 6px; margin: 0; }
7494
+ .wpKeyRow { display: grid; grid-template-columns: minmax(110px, auto) 1fr; gap: 10px; align-items: baseline; }
7495
+ .wpKeyRow dt { margin: 0; }
7496
+ .wpKeyRow dd { margin: 0; color: var(--nc-fg-3); font: 400 12px/1.35 var(--nc-font-sans); }
7497
+ .wpKeyRow kbd {
7498
+ padding: 2px 6px;
7499
+ border: 1px solid var(--nc-line-1);
7500
+ border-radius: 4px;
7501
+ background: var(--nc-ink-1);
7502
+ color: var(--nc-fg-1);
7503
+ font: 500 11px/1.4 var(--nc-font-mono);
7504
+ white-space: nowrap;
7505
+ }
7506
+ .sendIssues { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; }
7507
+
7508
+ .rteGear { font-size: 17px; line-height: 1; }
7509
+ .wpSelect {
7510
+ display: inline-flex;
7511
+ align-items: center;
7512
+ gap: 6px;
7513
+ height: 28px;
7514
+ padding: 0 8px;
7515
+ border: 1px solid var(--nc-line-1);
7516
+ border-radius: 6px;
7517
+ background: var(--nc-ink-1);
7518
+ color: var(--nc-fg-1);
7519
+ font: 500 12.5px/1 var(--nc-font-sans);
7520
+ cursor: pointer;
7521
+ }
7522
+ /* The app's own scrollbar, as on the message list: thin, violet, inset. */
7523
+ .wpTabs, .wpBody, .rteContent, .rteSuggest {
7524
+ scrollbar-width: thin;
7525
+ scrollbar-color: color-mix(in srgb, var(--nc-violet-500) 55%, transparent) transparent;
7526
+ }
7527
+ .wpTabs::-webkit-scrollbar, .wpBody::-webkit-scrollbar, .rteContent::-webkit-scrollbar, .rteSuggest::-webkit-scrollbar { width: 10px; height: 8px; }
7528
+ .wpTabs::-webkit-scrollbar-track, .wpBody::-webkit-scrollbar-track, .rteContent::-webkit-scrollbar-track, .rteSuggest::-webkit-scrollbar-track { background: transparent; }
7529
+ .wpTabs::-webkit-scrollbar-thumb, .wpBody::-webkit-scrollbar-thumb, .rteContent::-webkit-scrollbar-thumb, .rteSuggest::-webkit-scrollbar-thumb {
7530
+ background: color-mix(in srgb, var(--nc-violet-500) 45%, transparent);
7531
+ background-clip: padding-box;
7532
+ border: 3px solid transparent;
7533
+ border-radius: 999px;
7534
+ }
7535
+ .wpTabs::-webkit-scrollbar-thumb:hover, .wpBody::-webkit-scrollbar-thumb:hover, .rteContent::-webkit-scrollbar-thumb:hover, .rteSuggest::-webkit-scrollbar-thumb:hover {
7536
+ background: color-mix(in srgb, var(--nc-violet-500) 78%, transparent);
7537
+ background-clip: padding-box;
7538
+ }
7539
+ .wpTabs { padding-bottom: 6px; }
package/app/mail/page.tsx CHANGED
@@ -12,6 +12,10 @@ import RichEditor from './RichEditor'
12
12
  import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel, healGooglePrivateImages } from '@/lib/email-html'
13
13
  import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
14
14
  import MailSelect, { GLYPH } from './MailSelect'
15
+ import { handleComposeTab } from './tabKey'
16
+ import { writingSettingsFrom, type WritingSettings } from './writing/settings'
17
+ import { sendIssues, type SendIssue } from './writing/checks'
18
+ import { firstNameFromAddress, type MailTemplate } from './writing/templates'
15
19
 
16
20
  /** Which product an address belongs to, so the picker can wear its mark. */
17
21
  function productOf(address: string): 'vela' | 'hosting' | 'person' {
@@ -353,6 +357,7 @@ type MailSettings = {
353
357
  /** Whether a message in a conversation folds away, or stays open so the thread reads straight through. */
354
358
  threadMessages?: 'collapsible' | 'open'
355
359
  ticker?: TickerSettings
360
+ writing?: WritingSettings
356
361
  notifyEmail: string
357
362
  fonts: CustomFont[]
358
363
  defaultFont: BaseFont
@@ -1130,7 +1135,7 @@ function formatBytes(bytes: number): string {
1130
1135
 
1131
1136
  type StashHeaders = Record<string, string>
1132
1137
 
1133
- async function fetchStash(kind: 'draft', headers: StashHeaders): Promise<Array<{ id: string; data: unknown; updatedAt: string }>> {
1138
+ async function fetchStash(kind: 'draft' | 'template', headers: StashHeaders): Promise<Array<{ id: string; data: unknown; updatedAt: string }>> {
1134
1139
  try {
1135
1140
  const response = await fetch(`/api/mail/stash?kind=${kind}`, { headers })
1136
1141
  const data = await response.json()
@@ -1140,11 +1145,11 @@ async function fetchStash(kind: 'draft', headers: StashHeaders): Promise<Array<{
1140
1145
  }
1141
1146
  }
1142
1147
 
1143
- function putStash(kind: 'draft', id: string, data: unknown, headers: StashHeaders): void {
1148
+ function putStash(kind: 'draft' | 'template', id: string, data: unknown, headers: StashHeaders): void {
1144
1149
  fetch('/api/mail/stash', { method: 'PUT', headers, body: JSON.stringify({ kind, id, data }) }).catch(() => {})
1145
1150
  }
1146
1151
 
1147
- function deleteStashItem(kind: 'draft', id: string, headers: StashHeaders): void {
1152
+ function deleteStashItem(kind: 'draft' | 'template', id: string, headers: StashHeaders): void {
1148
1153
  fetch(`/api/mail/stash?kind=${kind}&id=${encodeURIComponent(id)}`, { method: 'DELETE', headers }).catch(() => {})
1149
1154
  }
1150
1155
 
@@ -1826,6 +1831,8 @@ export default function DevMailPage() {
1826
1831
  const [companyLogo, setCompanyLogo] = useState('')
1827
1832
  const [signatureTab, setSignatureTab] = useState<'personal' | 'company'>('personal')
1828
1833
  const [prefsLoaded, setPrefsLoaded] = useState(false)
1834
+ const [templates, setTemplates] = useState<MailTemplate[]>([])
1835
+ const [companyWords, setCompanyWords] = useState<string[]>([])
1829
1836
  useEffect(() => {
1830
1837
  if (!isLoggedIn || !prefsLoaded) return
1831
1838
  const timer = window.setTimeout(() => {
@@ -2906,7 +2913,7 @@ export default function DevMailPage() {
2906
2913
  // "comfortable" — is the default. Treating every unrecognised value as roomy put
2907
2914
  // six of the seven mailboxes into a reduced reader none of them had asked for.
2908
2915
  const density: MailSettings['density'] = stored.density === 'relaxed' ? 'relaxed' : 'compact'
2909
- setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel', ticker: tickerSettingsFrom(stored.ticker), threadMessages: stored.threadMessages === 'open' ? 'open' : 'collapsible' }))
2916
+ setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel', ticker: tickerSettingsFrom(stored.ticker), threadMessages: stored.threadMessages === 'open' ? 'open' : 'collapsible', writing: writingSettingsFrom(stored.writing) }))
2910
2917
  const prefs = (data.settings as MailSettings).prefs
2911
2918
  if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
2912
2919
  if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
@@ -2916,6 +2923,15 @@ export default function DevMailPage() {
2916
2923
  setPrefsLoaded(true)
2917
2924
  })
2918
2925
  .catch(() => {})
2926
+ fetchStash('template', headers).then(items =>
2927
+ setTemplates(items.map(item => ({ ...(item.data as MailTemplate), id: item.id })).filter(template => typeof template.html === 'string')),
2928
+ )
2929
+ fetch('/api/mail/company-dictionary', { headers })
2930
+ .then(response => response.json())
2931
+ .then(data => {
2932
+ if (data.ok && Array.isArray(data.words)) setCompanyWords(data.words)
2933
+ })
2934
+ .catch(() => {})
2919
2935
  fetch('/api/mail/company-signature', { headers })
2920
2936
  .then(response => response.json())
2921
2937
  .then(data => {
@@ -3939,6 +3955,46 @@ export default function DevMailPage() {
3939
3955
  [apiHeaders],
3940
3956
  )
3941
3957
 
3958
+ const writingSettings = useMemo(() => writingSettingsFrom(settings.writing), [settings.writing])
3959
+ const saveWriting = useCallback((next: WritingSettings) => saveSettings({ ...settings, writing: next }), [saveSettings, settings])
3960
+ const saveTemplate = useCallback(
3961
+ (template: MailTemplate) => {
3962
+ setTemplates(list => [...list.filter(entry => entry.id !== template.id), template])
3963
+ putStash('template', template.id, template, apiHeaders())
3964
+ },
3965
+ [apiHeaders],
3966
+ )
3967
+ const deleteTemplate = useCallback(
3968
+ (id: string) => {
3969
+ setTemplates(list => list.filter(entry => entry.id !== id))
3970
+ deleteStashItem('template', id, apiHeaders())
3971
+ },
3972
+ [apiHeaders],
3973
+ )
3974
+ const saveCompanyWords = useCallback(
3975
+ (words: string[]) => {
3976
+ setCompanyWords(words)
3977
+ fetch('/api/mail/company-dictionary', { method: 'PUT', headers: apiHeaders(), body: JSON.stringify({ words }) }).catch(() => {})
3978
+ },
3979
+ [apiHeaders],
3980
+ )
3981
+ const confirmIssues = useCallback(
3982
+ async (issues: SendIssue[]) =>
3983
+ issues.length === 0 ||
3984
+ confirm({
3985
+ title: 'Before you send',
3986
+ body: (
3987
+ <ul className={styles.sendIssues}>
3988
+ {issues.map(issue => (
3989
+ <li key={issue.kind}>{issue.message}</li>
3990
+ ))}
3991
+ </ul>
3992
+ ),
3993
+ confirmLabel: 'Send anyway',
3994
+ }),
3995
+ [confirm],
3996
+ )
3997
+
3942
3998
  const resetSignature = useCallback(async () => {
3943
3999
  const agreed = await confirm({
3944
4000
  title: 'Use the company signature?',
@@ -4485,6 +4541,17 @@ export default function DevMailPage() {
4485
4541
  // immediately — the delay/undo window only applies to attachment-free emails.
4486
4542
  const scheduledAt = attachable.length ? null : resolveScheduledAt()
4487
4543
 
4544
+ const issues = sendIssues(
4545
+ {
4546
+ body: htmlToPlainText(compose.htmlDirty && compose.htmlSource.trim() ? compose.htmlSource : compose.bodyHtml || compose.markdown),
4547
+ recipients: [...compose.to, ...compose.cc, ...compose.bcc].map(address => ({ email: address })),
4548
+ attachmentCount: attachments.length,
4549
+ ownDomains: LOGIN_DOMAINS,
4550
+ },
4551
+ writingSettings,
4552
+ )
4553
+ if (!(await confirmIssues(issues))) return
4554
+
4488
4555
  if (settings.confirmSend && !scheduledAt) {
4489
4556
  const recipientList = [...compose.to, ...compose.cc, ...compose.bcc].join(', ')
4490
4557
  const agreed = await confirm({
@@ -4570,14 +4637,22 @@ export default function DevMailPage() {
4570
4637
  }
4571
4638
 
4572
4639
  useEffect(() => {
4573
- if (undo && undo.sendAt <= now) {
4574
- setUndo(null)
4575
- setSentFlash('Sent')
4576
- window.setTimeout(() => setSentFlash(''), 2500)
4577
- loadSent()
4578
- loadQueued()
4579
- }
4580
- }, [undo, now, loadSent])
4640
+ if (!undo || undo.sendAt > now) return
4641
+ const id = undo.id
4642
+ setUndo(null)
4643
+ setSentFlash('Sending…')
4644
+ // The window closing is the moment to send, not the next minute's dispatcher run.
4645
+ // Saying "Sent" before the message had left is what prompted a second send.
4646
+ fetch('/api/mail/scheduled', { method: 'POST', headers: apiHeaders(), body: JSON.stringify({ id }) })
4647
+ .then(response => response.json().catch(() => null))
4648
+ .then(data => setSentFlash(data?.ok ? 'Sent' : 'Queued — it will go within a minute'))
4649
+ .catch(() => setSentFlash('Queued — it will go within a minute'))
4650
+ .finally(() => {
4651
+ window.setTimeout(() => setSentFlash(''), 2500)
4652
+ loadSent()
4653
+ loadQueued()
4654
+ })
4655
+ }, [undo, now, loadSent, loadQueued, apiHeaders])
4581
4656
 
4582
4657
  const cancelScheduled = async (id: string) => {
4583
4658
  try {
@@ -4782,6 +4857,16 @@ export default function DevMailPage() {
4782
4857
  openReplySettings(entry)
4783
4858
  return
4784
4859
  }
4860
+ const issues = sendIssues(
4861
+ {
4862
+ body: quickReply.trim() ? quickReply : htmlToPlainText(replyHtml),
4863
+ recipients: [...draft.to, ...draft.cc, ...draft.bcc].map(address => ({ email: address })),
4864
+ attachmentCount: attachments.length,
4865
+ ownDomains: LOGIN_DOMAINS,
4866
+ },
4867
+ { ...writingSettings, checkEmptyBody: writingSettings.checkEmptyBody && replyBar !== 'forward' },
4868
+ )
4869
+ if (!(await confirmIssues(issues))) return
4785
4870
  setQuickSending(true)
4786
4871
  setReplyError('')
4787
4872
  try {
@@ -6852,7 +6937,9 @@ export default function DevMailPage() {
6852
6937
  if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
6853
6938
  event.preventDefault()
6854
6939
  sendQuickReply(inbound)
6940
+ return
6855
6941
  }
6942
+ handleComposeTab(event)
6856
6943
  }}
6857
6944
  placeholder={replyBar === 'forward' ? 'Add a note to go with the forwarded message…' : `Reply to ${parseAddress(quickReplyTarget(inbound).from) || 'sender'}…`}
6858
6945
  rows={1}
@@ -6862,6 +6949,7 @@ export default function DevMailPage() {
6862
6949
  <textarea
6863
6950
  className={`${styles.quickReplyInput} ${styles.quickReplyHtml}`}
6864
6951
  value={replyHtml}
6952
+ onKeyDown={event => handleComposeTab(event, ' ')}
6865
6953
  onChange={event => { setReplyHtml(event.target.value); setReplyHtmlDirty(true) }}
6866
6954
  onPaste={event => { pasteAttachments(event) }}
6867
6955
  placeholder="Advanced: edit the message HTML directly. Changes here replace what you wrote."
@@ -8272,6 +8360,21 @@ export default function DevMailPage() {
8272
8360
  fonts={(settings.fonts ?? []).map(font => font.name)}
8273
8361
  fontFaceCss={fontCss}
8274
8362
  baseFont={defaultFont}
8363
+ writing={writingSettings}
8364
+ onWritingChange={saveWriting}
8365
+ templates={templates}
8366
+ onSaveTemplate={saveTemplate}
8367
+ onDeleteTemplate={deleteTemplate}
8368
+ templateContext={{
8369
+ firstName: firstNameFromAddress(compose.to[0]),
8370
+ email: compose.to[0],
8371
+ senderName: compose.fromName || settings.senderName || account?.name || undefined,
8372
+ }}
8373
+ companyWords={companyWords}
8374
+ onCompanyWords={isAdmin ? saveCompanyWords : undefined}
8375
+ isAdmin={isAdmin}
8376
+ onSubmit={() => void sendEmail()}
8377
+ requestHeaders={apiHeaders}
8275
8378
  />
8276
8379
  )}
8277
8380
  {compose.quoteHtml && !compose.campaign.kind && (
@@ -0,0 +1,52 @@
1
+ import type { KeyboardEvent } from 'react'
2
+
3
+ /** Two presses closer together than this leave the field instead of indenting twice. */
4
+ export const DOUBLE_TAB_MS = 400
5
+
6
+ /** Four no-break spaces: a tab character collapses to a single space in most mail clients. */
7
+ export const TAB_TEXT = '    '
8
+
9
+ /**
10
+ * Whether this Tab is the second of a quick pair. The first press is remembered with a way
11
+ * to take its indent back, which the second press uses before letting focus move on.
12
+ */
13
+ export function tabPress<Mark>(
14
+ memory: { at: number; mark: Mark | null },
15
+ mark: () => Mark,
16
+ windowMs: number = DOUBLE_TAB_MS,
17
+ ): { leaving: boolean; previous: Mark | null } {
18
+ const now = Date.now()
19
+ if (now - memory.at < windowMs) {
20
+ const previous = memory.mark
21
+ memory.at = 0
22
+ memory.mark = null
23
+ return { leaving: true, previous }
24
+ }
25
+ memory.at = now
26
+ memory.mark = mark()
27
+ return { leaving: false, previous: null }
28
+ }
29
+
30
+ const textareaMemory = new WeakMap<HTMLTextAreaElement, { at: number; mark: { value: string; caret: number } | null }>()
31
+
32
+ /** Tab indents inside a composing textarea; a quick second Tab removes that indent and moves on. */
33
+ export function handleComposeTab(event: KeyboardEvent<HTMLTextAreaElement>, indent: string = TAB_TEXT) {
34
+ if (event.key !== 'Tab' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return
35
+ const field = event.currentTarget
36
+ let memory = textareaMemory.get(field)
37
+ if (!memory) {
38
+ memory = { at: 0, mark: null }
39
+ textareaMemory.set(field, memory)
40
+ }
41
+ const { leaving, previous } = tabPress(memory, () => ({ value: '', caret: -1 }))
42
+ if (leaving) {
43
+ if (previous && previous.value === field.value && field.selectionStart === previous.caret) {
44
+ field.setSelectionRange(previous.caret - indent.length, previous.caret)
45
+ document.execCommand('delete')
46
+ }
47
+ return
48
+ }
49
+ event.preventDefault()
50
+ document.execCommand('insertText', false, indent)
51
+ memory.mark = { value: field.value, caret: field.selectionStart }
52
+ }