@novacraft-engineering/mailbox 0.4.25 → 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/app/api/mail/company-dictionary/route.ts +36 -0
- package/app/api/mail/company-signature/route.ts +1 -1
- package/app/api/mail/grammar/route.ts +52 -0
- package/app/mail/RichEditor.tsx +405 -6
- package/app/mail/page.module.css +275 -0
- package/app/mail/page.tsx +95 -4
- package/app/mail/tabKey.ts +6 -2
- 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/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
|
@@ -13,6 +13,9 @@ import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeI
|
|
|
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
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'
|
|
16
19
|
|
|
17
20
|
/** Which product an address belongs to, so the picker can wear its mark. */
|
|
18
21
|
function productOf(address: string): 'vela' | 'hosting' | 'person' {
|
|
@@ -354,6 +357,7 @@ type MailSettings = {
|
|
|
354
357
|
/** Whether a message in a conversation folds away, or stays open so the thread reads straight through. */
|
|
355
358
|
threadMessages?: 'collapsible' | 'open'
|
|
356
359
|
ticker?: TickerSettings
|
|
360
|
+
writing?: WritingSettings
|
|
357
361
|
notifyEmail: string
|
|
358
362
|
fonts: CustomFont[]
|
|
359
363
|
defaultFont: BaseFont
|
|
@@ -1131,7 +1135,7 @@ function formatBytes(bytes: number): string {
|
|
|
1131
1135
|
|
|
1132
1136
|
type StashHeaders = Record<string, string>
|
|
1133
1137
|
|
|
1134
|
-
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 }>> {
|
|
1135
1139
|
try {
|
|
1136
1140
|
const response = await fetch(`/api/mail/stash?kind=${kind}`, { headers })
|
|
1137
1141
|
const data = await response.json()
|
|
@@ -1141,11 +1145,11 @@ async function fetchStash(kind: 'draft', headers: StashHeaders): Promise<Array<{
|
|
|
1141
1145
|
}
|
|
1142
1146
|
}
|
|
1143
1147
|
|
|
1144
|
-
function putStash(kind: 'draft', id: string, data: unknown, headers: StashHeaders): void {
|
|
1148
|
+
function putStash(kind: 'draft' | 'template', id: string, data: unknown, headers: StashHeaders): void {
|
|
1145
1149
|
fetch('/api/mail/stash', { method: 'PUT', headers, body: JSON.stringify({ kind, id, data }) }).catch(() => {})
|
|
1146
1150
|
}
|
|
1147
1151
|
|
|
1148
|
-
function deleteStashItem(kind: 'draft', id: string, headers: StashHeaders): void {
|
|
1152
|
+
function deleteStashItem(kind: 'draft' | 'template', id: string, headers: StashHeaders): void {
|
|
1149
1153
|
fetch(`/api/mail/stash?kind=${kind}&id=${encodeURIComponent(id)}`, { method: 'DELETE', headers }).catch(() => {})
|
|
1150
1154
|
}
|
|
1151
1155
|
|
|
@@ -1827,6 +1831,8 @@ export default function DevMailPage() {
|
|
|
1827
1831
|
const [companyLogo, setCompanyLogo] = useState('')
|
|
1828
1832
|
const [signatureTab, setSignatureTab] = useState<'personal' | 'company'>('personal')
|
|
1829
1833
|
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
|
1834
|
+
const [templates, setTemplates] = useState<MailTemplate[]>([])
|
|
1835
|
+
const [companyWords, setCompanyWords] = useState<string[]>([])
|
|
1830
1836
|
useEffect(() => {
|
|
1831
1837
|
if (!isLoggedIn || !prefsLoaded) return
|
|
1832
1838
|
const timer = window.setTimeout(() => {
|
|
@@ -2907,7 +2913,7 @@ export default function DevMailPage() {
|
|
|
2907
2913
|
// "comfortable" — is the default. Treating every unrecognised value as roomy put
|
|
2908
2914
|
// six of the seven mailboxes into a reduced reader none of them had asked for.
|
|
2909
2915
|
const density: MailSettings['density'] = stored.density === 'relaxed' ? 'relaxed' : 'compact'
|
|
2910
|
-
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) }))
|
|
2911
2917
|
const prefs = (data.settings as MailSettings).prefs
|
|
2912
2918
|
if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
|
|
2913
2919
|
if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
|
|
@@ -2917,6 +2923,15 @@ export default function DevMailPage() {
|
|
|
2917
2923
|
setPrefsLoaded(true)
|
|
2918
2924
|
})
|
|
2919
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(() => {})
|
|
2920
2935
|
fetch('/api/mail/company-signature', { headers })
|
|
2921
2936
|
.then(response => response.json())
|
|
2922
2937
|
.then(data => {
|
|
@@ -3940,6 +3955,46 @@ export default function DevMailPage() {
|
|
|
3940
3955
|
[apiHeaders],
|
|
3941
3956
|
)
|
|
3942
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
|
+
|
|
3943
3998
|
const resetSignature = useCallback(async () => {
|
|
3944
3999
|
const agreed = await confirm({
|
|
3945
4000
|
title: 'Use the company signature?',
|
|
@@ -4486,6 +4541,17 @@ export default function DevMailPage() {
|
|
|
4486
4541
|
// immediately — the delay/undo window only applies to attachment-free emails.
|
|
4487
4542
|
const scheduledAt = attachable.length ? null : resolveScheduledAt()
|
|
4488
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
|
+
|
|
4489
4555
|
if (settings.confirmSend && !scheduledAt) {
|
|
4490
4556
|
const recipientList = [...compose.to, ...compose.cc, ...compose.bcc].join(', ')
|
|
4491
4557
|
const agreed = await confirm({
|
|
@@ -4791,6 +4857,16 @@ export default function DevMailPage() {
|
|
|
4791
4857
|
openReplySettings(entry)
|
|
4792
4858
|
return
|
|
4793
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
|
|
4794
4870
|
setQuickSending(true)
|
|
4795
4871
|
setReplyError('')
|
|
4796
4872
|
try {
|
|
@@ -8284,6 +8360,21 @@ export default function DevMailPage() {
|
|
|
8284
8360
|
fonts={(settings.fonts ?? []).map(font => font.name)}
|
|
8285
8361
|
fontFaceCss={fontCss}
|
|
8286
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}
|
|
8287
8378
|
/>
|
|
8288
8379
|
)}
|
|
8289
8380
|
{compose.quoteHtml && !compose.campaign.kind && (
|
package/app/mail/tabKey.ts
CHANGED
|
@@ -10,9 +10,13 @@ export const TAB_TEXT = ' '
|
|
|
10
10
|
* Whether this Tab is the second of a quick pair. The first press is remembered with a way
|
|
11
11
|
* to take its indent back, which the second press uses before letting focus move on.
|
|
12
12
|
*/
|
|
13
|
-
export function tabPress<Mark>(
|
|
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 } {
|
|
14
18
|
const now = Date.now()
|
|
15
|
-
if (now - memory.at <
|
|
19
|
+
if (now - memory.at < windowMs) {
|
|
16
20
|
const previous = memory.mark
|
|
17
21
|
memory.at = 0
|
|
18
22
|
memory.mark = null
|