@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.
- package/.env.example +8 -2
- package/app/api/mail/company-dictionary/route.ts +36 -0
- package/app/api/mail/company-signature/route.ts +1 -1
- package/app/api/mail/events/ses/route.ts +88 -0
- package/app/api/mail/grammar/route.ts +52 -0
- package/app/api/mail/scheduled/route.ts +15 -1
- package/app/mail/RichEditor.tsx +450 -5
- package/app/mail/page.module.css +275 -0
- package/app/mail/page.tsx +115 -12
- package/app/mail/tabKey.ts +52 -0
- package/app/mail/writing/WritingPanel.tsx +259 -0
- package/app/mail/writing/assist.ts +139 -0
- package/app/mail/writing/checks.test.ts +48 -0
- package/app/mail/writing/checks.ts +58 -0
- package/app/mail/writing/find.ts +52 -0
- package/app/mail/writing/grammar.ts +100 -0
- package/app/mail/writing/live.ts +34 -0
- package/app/mail/writing/paste.test.ts +17 -0
- package/app/mail/writing/paste.ts +30 -0
- package/app/mail/writing/settings.ts +98 -0
- package/app/mail/writing/spell.worker.ts +40 -0
- package/app/mail/writing/spelling.ts +164 -0
- package/app/mail/writing/templates.ts +40 -0
- package/app/mail/writing/text.test.ts +88 -0
- package/app/mail/writing/text.ts +106 -0
- package/lib/dev-auth.ts +5 -3
- package/lib/dmarc-report.test.ts +18 -0
- package/lib/mail-provider.ts +1 -2
- package/lib/mailbox.ts +7 -2
- package/lib/receive.test.ts +24 -0
- package/lib/receive.ts +55 -11
- package/lib/scheduled.ts +67 -33
- package/lib/ses-send.test.ts +21 -0
- package/lib/ses-send.ts +29 -5
- package/lib/sns.ts +68 -0
- package/package.json +3 -1
- package/public/dictionaries/en-GB.LICENSE +347 -0
- package/public/dictionaries/en-GB.aff +205 -0
- package/public/dictionaries/en-GB.dic +49602 -0
- package/public/dictionaries/en-US.LICENSE +347 -0
- package/public/dictionaries/en-US.aff +205 -0
- package/public/dictionaries/en-US.dic +49569 -0
package/app/mail/page.module.css
CHANGED
|
@@ -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
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
}
|
|
4580
|
-
|
|
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
|
+
}
|