domma-cms 1.6.35 → 1.6.37
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/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -0
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +15 -6
- package/plugins/_lib/call/ice.js +5 -2
- package/plugins/_lib/call/rooms.js +9 -3
- package/plugins/_lib/call/routes.js +7 -4
- package/plugins/_lib/mail/compose.js +7 -2
- package/plugins/_lib/mail/defaults.js +12 -0
- package/plugins/_lib/mail/hostGuard.js +86 -0
- package/plugins/_lib/mail/message-action.js +56 -0
- package/plugins/_lib/mail/readRoutes.js +20 -1
- package/plugins/_lib/mail/scheduler.js +2 -0
- package/plugins/_lib/mail/send.js +12 -3
- package/plugins/_lib/mail/store.js +16 -6
- package/plugins/_lib/outreach/index.js +1 -1
- package/plugins/_lib/outreach/ledger.js +10 -3
- package/plugins/_lib/outreach/sending.js +9 -3
- package/plugins/_lib/outreach/suppression.js +14 -3
- package/plugins/_lib/outreach/unsubscribe.js +13 -0
- package/plugins/_lib/public/call/call.js +3 -1
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/docs/guide.md +1 -1
- package/plugins/blog/lib/page.js +32 -0
- package/plugins/blog/plugin.js +58 -16
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +5 -4
- package/plugins/blog/tests/public.test.js +30 -1
- package/plugins/booking/CLAUDE.md +4 -1
- package/plugins/booking/docs/guide.md +1 -1
- package/plugins/booking/plugin.js +16 -21
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/api.test.js +26 -1
- package/plugins/feedback/CLAUDE.md +7 -2
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/lib/receiver.js +1 -1
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/free-tier.lock.json +42 -41
- package/plugins/mail-reader/CLAUDE.md +6 -0
- package/plugins/mail-reader/docs/guide.md +1 -0
- package/plugins/mail-reader/plugin.json +2 -2
- package/plugins/shopping-cart/CLAUDE.md +17 -1
- package/plugins/shopping-cart/docs/guide.md +1 -1
- package/plugins/shopping-cart/lib/accounts.js +8 -3
- package/plugins/shopping-cart/lib/payments.js +7 -4
- package/plugins/shopping-cart/lib/shop.js +8 -3
- package/plugins/shopping-cart/plugin.js +77 -41
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/public/account.js +2 -2
- package/plugins/shopping-cart/tests/accounts.test.js +10 -1
- package/plugins/shopping-cart/tests/api.test.js +23 -0
- package/plugins/shopping-cart/tests/pure.test.js +16 -1
- package/plugins/support/CLAUDE.md +6 -0
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/docs/guide.md +90 -0
- package/plugins/support/lib/receiver.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/server/routes/api/analytics.js +32 -0
- package/server/routes/api/collections.js +148 -15
- package/server/routes/outreach-public.js +54 -10
- package/server/server.js +5 -1
- package/server/services/authChecks.js +8 -0
- package/server/services/collectionAccess.js +191 -0
- package/server/services/collections.js +8 -2
- package/server/services/request-log.js +70 -0
package/package.json
CHANGED
|
@@ -664,6 +664,7 @@ export function openCompose({
|
|
|
664
664
|
html,
|
|
665
665
|
inReplyTo: draft.inReplyTo ?? null,
|
|
666
666
|
references: draft.references ?? [],
|
|
667
|
+
sourceMessage: draft.sourceMessage ?? null,
|
|
667
668
|
attachments: attachments.peek().map(({filename, contentType, content}) =>
|
|
668
669
|
({filename, contentType, content}))
|
|
669
670
|
};
|
|
@@ -789,6 +790,9 @@ export function openCompose({
|
|
|
789
790
|
: 'Sent. No copy could be filed in Sent.',
|
|
790
791
|
{type: result.filedTo ? 'success' : 'warning'}
|
|
791
792
|
);
|
|
793
|
+
if (result.messageAction && !result.messageAction.recorded) {
|
|
794
|
+
E.toast('Sent, but the original message could not be marked as replied to or forwarded.', {type: 'warning'});
|
|
795
|
+
}
|
|
792
796
|
onSent(result);
|
|
793
797
|
}
|
|
794
798
|
} catch (err) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
#view-container.mail-fullbleed{max-width:none}.mail-layout [hidden],.dm-slideover [hidden]{display:none!important}.mail-reader-summary{display:flex;align-items:baseline;gap:.5rem;flex-wrap:wrap}.mail-header-toggle,.mail-extras-toggle{padding:0!important;font-size:.78rem;opacity:.75;text-decoration:underline}.mail-header-toggle:hover,.mail-extras-toggle:hover{opacity:1}.mail-header-details{display:grid;grid-template-columns:max-content 1fr;gap:.15rem .75rem;margin:.5rem 0 0;font-size:.8rem}.mail-header-details dt{opacity:.65}.mail-header-details dd{margin:0;overflow-wrap:anywhere}.mail-layout{display:grid;grid-template-columns:var(--mail-col-1, 190px) auto var(--mail-col-2, 340px) auto 1fr;height:calc(100vh - 140px);min-height:320px;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 8px);overflow:hidden;background:var(--dm-surface, #fff)}.mail-folders-pane,.mail-list-pane{display:flex;flex-direction:column;border-right:1px solid var(--dm-border, #e2e8f0);min-width:0;min-height:0}.mail-reader-pane{display:flex;flex-direction:column;min-width:0;min-height:0}.mail-pane-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.5rem .75rem;font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-folders-list,.mail-list{overflow-y:auto;flex:1;min-height:0}.mail-search{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;border-bottom:1px solid var(--dm-border, #e2e8f0);color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-search input{flex:1;min-width:0;font-size:.85rem;padding:.25rem .4rem}.mail-search-note{padding:.3rem .6rem;font-size:.74rem;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-slideover-resizer{padding:0;position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.mail-slideover-resizer:hover,.is-resizing>.mail-slideover-resizer{background:var(--dm-primary, #2563eb);opacity:.7}.is-resizing{user-select:none;transition:none!important}.mail-selection{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;padding:.35rem .6rem;font-size:.78rem;background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);flex-shrink:0}.mail-selection span{flex:1;min-width:0}.mail-selection .btn{color:var(--dm-primary-text, #fff);border:1px solid currentColor;background:transparent}.mail-selection .btn:hover{background:color-mix(in srgb,var(--dm-primary-text, #fff) 15%,transparent)}.mail-item.is-selected{background:var(--dm-active-bg, rgba(15, 23, 42, .07));box-shadow:inset 3px 0 0 var(--dm-primary, #2563eb)}.mail-undo{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;font-size:.78rem;background:var(--dm-active-bg, rgba(15, 23, 42, .07));border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-undo span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-splitter{width:5px;cursor:col-resize;background:var(--dm-border, #e2e8f0);flex-shrink:0;background-clip:content-box;border-left:2px solid transparent;border-right:2px solid transparent;box-sizing:content-box}.mail-splitter:hover,.mail-layout.is-resizing .mail-splitter{background-color:var(--dm-primary, #2563eb)}.mail-layout.is-resizing{user-select:none}.mail-layout.is-resizing .mail-folders-pane,.mail-layout.is-resizing .mail-list-pane,.mail-layout.is-resizing .mail-reader-pane,.mail-layout.is-resizing iframe{pointer-events:none}.mail-unread-filter{display:flex;align-items:center;gap:.3rem;font-size:.7rem;text-transform:none;letter-spacing:0;cursor:pointer;margin-left:auto;margin-right:.25rem}.mail-unread-pill{flex-shrink:0;min-width:1.15rem;padding:0 .3rem;border-radius:999px;background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);font-size:.66rem;font-weight:700;line-height:1.35;text-align:center}.mail-folder.is-context .mail-folder-name{opacity:.6}.mail-account{display:flex;align-items:center;gap:.4rem;width:100%;padding:.45rem .6rem;border:none;background:var(--dm-surface, #fff);cursor:pointer;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--dm-text-muted, #64748b)}.mail-account:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-account.is-active{color:var(--dm-text, #1e293b);position:sticky;top:0;z-index:1;background:var(--dm-surface, #fff)}.mail-account-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-folder{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;font-size:.875rem;cursor:pointer;border:none;background:none;width:100%;text-align:left;color:var(--dm-text, #1e293b)}.mail-folder:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-folder.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07));color:var(--dm-primary, #2563eb);font-weight:600}.mail-folder.is-unselectable{opacity:.6;cursor:default}.mail-folder.is-unselectable:hover{background:none}.mail-folder-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item{display:block;width:100%;text-align:left;padding:.55rem .75rem;border:none;border-bottom:1px solid var(--dm-border, #e2e8f0);background:none;cursor:pointer;color:var(--dm-text, #1e293b)}.mail-item:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-item.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07))}.mail-item-top{display:flex;justify-content:space-between;gap:.5rem;align-items:baseline}.mail-item-from{font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item.is-unread .mail-item-from,.mail-item.is-unread .mail-item-subject{font-weight:700}.mail-item-date{font-size:.72rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-item-subject{font-size:.82rem;margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item-meta{display:flex;align-items:center;gap:.35rem;margin-top:.15rem;color:var(--dm-text-muted, #64748b)}.mail-pager{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.4rem .6rem;border-top:1px solid var(--dm-border, #e2e8f0);font-size:.75rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-reader-head{padding:.75rem 1rem;border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-reader-head:empty{display:none}.mail-reader-subject{font-size:1.05rem;font-weight:600;margin:0 0 .3rem;color:var(--dm-text, #1e293b)}.mail-reader-actions{display:flex;gap:.25rem;margin:.1rem 0 .35rem;flex-wrap:wrap}.mail-reader-meta{font-size:.8rem;color:var(--dm-text-muted, #64748b)}.mail-reader-body{flex:1;position:relative;overflow:hidden;display:flex;flex-direction:column;min-height:0;background:var(--dm-surface, #fff)}.mail-reader-body iframe{width:100%;flex:1;border:none;display:block;background:#fff}.mail-notice{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;padding:.4rem .75rem;font-size:.78rem;background:var(--dm-warning, #8a6d1f);color:var(--dm-warning-text, #fff);border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-notice .btn{color:var(--dm-warning-text, #fff);border:1px solid currentColor;background:transparent;opacity:.95}.mail-notice .btn:hover{background:color-mix(in srgb,var(--dm-warning-text, #fff) 15%,transparent);opacity:1}.mail-attachments{display:flex;flex-wrap:wrap;gap:.4rem;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border, #e2e8f0);background:var(--dm-background-alt, rgba(15, 23, 42, .03))}.mail-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;height:100%;color:var(--dm-text-muted, #94a3b8);text-align:center;padding:1rem}.mail-empty p{margin:0;font-size:.875rem}@media(max-width:900px){.mail-layout{grid-template-columns:1fr;height:auto!important}.mail-splitter{display:none}.mail-folders-pane,.mail-list-pane{border-right:none;border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-folders-list,.mail-list{max-height:240px}.mail-reader-body{min-height:60vh}}.mail-box-row{display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px)}.mail-default-badge{margin-left:.4rem;font-size:.7rem;font-weight:500;vertical-align:middle}.mail-item.is-dragging{opacity:.45}.mail-folder.is-drop-target{background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);outline:2px solid var(--dm-primary, #2563eb);outline-offset:-2px}.mail-folder.is-drop-target .mail-folder-name,.mail-folder.is-drop-target .mail-unread-pill{color:inherit}.mail-item.is-dragging *{pointer-events:none}.mail-item{position:relative}.mail-item-tick{position:absolute;left:.45rem;top:.55rem;display:inline-flex;opacity:0;color:var(--dm-text-muted, #64748b);transition:opacity .12s ease;padding:2px;margin:-2px;border-radius:var(--dm-radius-sm, 3px)}.mail-item:hover .mail-item-tick,.mail-item:focus-visible .mail-item-tick,.mail-list.is-selecting .mail-item-tick,.mail-item-tick[aria-checked=true]{opacity:1}.mail-item-tick[aria-checked=true]{color:var(--dm-primary, #2563eb)}.mail-item-tick:hover{color:var(--dm-primary, #2563eb);background:var(--dm-hover-bg)}.mail-item-top,.mail-item-subject,.mail-item-meta{padding-left:1.5rem}.mail-select-all{display:inline-flex;align-items:center;margin-right:.1rem;cursor:pointer}.mail-select-all input{cursor:pointer;margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.mail-item-meta>span{display:inline-flex}.mail-item-meta .is-high{color:var(--dm-danger, #dc2626)}.mail-item-meta .is-low{color:var(--dm-text-muted, #64748b);opacity:.7}.mail-folder.is-hidden-folder .mail-folder-name{opacity:.5;font-style:italic}.mail-thread-chip{display:inline-flex;align-items:center;margin-left:.4rem;padding:0 .35rem;border-radius:999px;font-size:.68rem;font-weight:700;line-height:1.5;color:var(--dm-text-muted, #64748b);background:var(--dm-active-bg, rgba(15, 23, 42, .07));cursor:pointer}.mail-thread-chip:hover{color:var(--dm-primary, #2563eb)}.mail-thread-chip.is-unread{background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff)}.mail-item.is-in-thread{padding-left:1.6rem;box-shadow:inset 3px 0 0 var(--dm-border, #e2e8f0)}.mail-item.is-in-thread .mail-item-from{font-size:.8rem}.mail-pane-head .btn.is-on{color:var(--dm-primary, #2563eb);background:var(--dm-active-bg, rgba(15, 23, 42, .07))}#mail-outbox-btn{font-size:.72rem;font-weight:700}#mail-outbox-btn.is-failed{color:var(--dm-danger, #dc2626)}.mail-attachment{display:inline-flex;align-items:center;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px);overflow:hidden}.mail-attachment .btn{border:none;border-radius:0}.mail-attachment-save{padding-left:.4rem!important;padding-right:.4rem!important;border-left:1px solid var(--dm-border, #e2e8f0)!important;opacity:.7}.mail-attachment-save:hover{opacity:1;color:var(--dm-primary, #2563eb)}.mail-section-heading{margin:1rem 0 .1rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b)}.mail-section-heading:first-child{margin-top:.25rem}.mail-section-form .domma-form{margin:0}.mail-section-form .domma-form-field:last-child{margin-bottom:0}.mail-item-card{margin:0}.mail-item-card .card-header{padding:.5rem .6rem}.mail-item-card .card-body{padding:.6rem}.mail-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.mail-status{min-height:1.2em}.mail-empty-line{margin:.25rem 0}.mail-editor-fallback{min-height:8rem;font-family:inherit;resize:vertical}.mail-clause{display:flex;gap:.3rem;align-items:center;flex-wrap:wrap}.mail-clause .form-select,.mail-clause .form-input{width:auto;flex:0 1 auto}.mail-clause .mail-clause-value{flex:1 1 8rem;min-width:8rem}.mail-section-list{display:flex;flex-direction:column;gap:.5rem}.mail-scroll-sm{max-height:20vh;overflow-y:auto}.mail-scroll-md{max-height:46vh;overflow-y:auto}.mail-section-form .domma-form-field:has(select) .form-label .text-danger{display:none}.mail-compose-form{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.6rem;height:100%}.mail-compose-body{display:flex;flex-direction:column;flex:1;min-height:0}.mail-compose-editor{flex:1;min-height:0}.mail-compose-textarea{flex:1;min-height:34vh;font-family:inherit;resize:vertical}.mail-attach-list{display:flex;flex-direction:column;gap:.2rem}.mail-template-row{margin:-.15rem 0 .35rem}.mail-template-picker{max-width:16rem}.mail-suggest-row{display:flex;flex-direction:column;line-height:1.25}.mail-suggest-detail{opacity:.7}.mail-rule-off{opacity:.55}.mail-panel{padding:.25rem 0 .5rem}.mail-panel-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:1rem}.mail-form-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.75rem}.mail-dialog-stack{display:flex;flex-direction:column;gap:.5rem;padding:.25rem 0}.mail-hint{margin-top:.2rem}.mail-box-sub{overflow:hidden;text-overflow:ellipsis}.mail-add-btn{align-self:flex-start;margin-top:.5rem}.mail-none{margin:0;padding:.5rem .6rem}.mail-kv{padding:.5rem 0;display:flex;flex-direction:column;gap:.35rem}.mail-kv-row{display:flex;gap:.75rem}.mail-kv-key{min-width:8.5rem;flex-shrink:0}.mail-toggle-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem}.mail-sender-list{display:flex;flex-direction:column;gap:.2rem;max-height:40vh;overflow-y:auto}.mail-folder-pick{display:flex;align-items:center;gap:.5rem;padding:.3rem .4rem;border-radius:var(--dm-radius-md, 6px)}.mail-diag-log{width:100%;height:30vh;margin-top:.5rem;font-family:monospace;font-size:.72rem}.mail-facts{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.75rem}.mail-item-more{position:absolute;right:.4rem;bottom:.35rem;display:inline-flex;align-items:center;justify-content:center;min-width:1.4rem;height:1.4rem;border-radius:var(--dm-radius-sm, 3px);font-size:1rem;line-height:1;color:var(--dm-text-muted, #64748b);background:inherit;opacity:0;transition:opacity .12s ease}.mail-item:hover .mail-item-more,.mail-item:focus-visible .mail-item-more,.mail-item.is-active .mail-item-more{opacity:1}.mail-item-more:hover{color:var(--dm-primary, #2563eb)}.mail-section-intro{display:flex;align-items:center;gap:.35rem;margin:0 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-pager-middle{display:flex;align-items:center;gap:.5rem;min-width:0}.mail-page-size{font:inherit;color:inherit;background:transparent;border:1px solid var(--dm-border, #e2e8f0);border-radius:4px;padding:.1rem .25rem}
|
|
1
|
+
#view-container.mail-fullbleed{max-width:none}.mail-layout [hidden],.dm-slideover [hidden]{display:none!important}.mail-reader-summary{display:flex;align-items:baseline;gap:.5rem;flex-wrap:wrap}.mail-header-toggle,.mail-extras-toggle{padding:0!important;font-size:.78rem;opacity:.75;text-decoration:underline}.mail-header-toggle:hover,.mail-extras-toggle:hover{opacity:1}.mail-header-details{display:grid;grid-template-columns:max-content 1fr;gap:.15rem .75rem;margin:.5rem 0 0;font-size:.8rem}.mail-header-details dt{opacity:.65}.mail-header-details dd{margin:0;overflow-wrap:anywhere}.mail-layout{display:grid;grid-template-columns:var(--mail-col-1, 190px) auto var(--mail-col-2, 340px) auto 1fr;height:calc(100vh - 140px);min-height:320px;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 8px);overflow:hidden;background:var(--dm-surface, #fff)}.mail-folders-pane,.mail-list-pane{display:flex;flex-direction:column;border-right:1px solid var(--dm-border, #e2e8f0);min-width:0;min-height:0}.mail-reader-pane{display:flex;flex-direction:column;min-width:0;min-height:0}.mail-pane-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.5rem .75rem;font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-folders-list,.mail-list{overflow-y:auto;flex:1;min-height:0}.mail-search{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;border-bottom:1px solid var(--dm-border, #e2e8f0);color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-search input{flex:1;min-width:0;font-size:.85rem;padding:.25rem .4rem}.mail-search-note{padding:.3rem .6rem;font-size:.74rem;color:var(--dm-text-muted, #64748b);border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-slideover-resizer{padding:0;position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.mail-slideover-resizer:hover,.is-resizing>.mail-slideover-resizer{background:var(--dm-primary, #2563eb);opacity:.7}.is-resizing{user-select:none;transition:none!important}.mail-selection{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;padding:.35rem .6rem;font-size:.78rem;background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);flex-shrink:0}.mail-selection span{flex:1;min-width:0}.mail-selection .btn{color:var(--dm-primary-text, #fff);border:1px solid currentColor;background:transparent}.mail-selection .btn:hover{background:color-mix(in srgb,var(--dm-primary-text, #fff) 15%,transparent)}.mail-item.is-selected{background:var(--dm-active-bg, rgba(15, 23, 42, .07));box-shadow:inset 3px 0 0 var(--dm-primary, #2563eb)}.mail-undo{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;font-size:.78rem;background:var(--dm-active-bg, rgba(15, 23, 42, .07));border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-undo span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-splitter{width:5px;cursor:col-resize;background:var(--dm-border, #e2e8f0);flex-shrink:0;background-clip:content-box;border-left:2px solid transparent;border-right:2px solid transparent;box-sizing:content-box}.mail-splitter:hover,.mail-layout.is-resizing .mail-splitter{background-color:var(--dm-primary, #2563eb)}.mail-layout.is-resizing{user-select:none}.mail-layout.is-resizing .mail-folders-pane,.mail-layout.is-resizing .mail-list-pane,.mail-layout.is-resizing .mail-reader-pane,.mail-layout.is-resizing iframe{pointer-events:none}.mail-unread-filter{display:flex;align-items:center;gap:.3rem;font-size:.7rem;text-transform:none;letter-spacing:0;cursor:pointer;margin-left:auto;margin-right:.25rem}.mail-unread-pill{flex-shrink:0;min-width:1.15rem;padding:0 .3rem;border-radius:999px;background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);font-size:.66rem;font-weight:700;line-height:1.35;text-align:center}.mail-folder.is-context .mail-folder-name{opacity:.6}.mail-account{display:flex;align-items:center;gap:.4rem;width:100%;padding:.45rem .6rem;border:none;background:var(--dm-surface, #fff);cursor:pointer;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--dm-text-muted, #64748b)}.mail-account:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-account.is-active{color:var(--dm-text, #1e293b);position:sticky;top:0;z-index:1;background:var(--dm-surface, #fff)}.mail-account-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-folder{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;font-size:.875rem;cursor:pointer;border:none;background:none;width:100%;text-align:left;color:var(--dm-text, #1e293b)}.mail-folder:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-folder.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07));color:var(--dm-primary, #2563eb);font-weight:600}.mail-folder.is-unselectable{opacity:.6;cursor:default}.mail-folder.is-unselectable:hover{background:none}.mail-folder-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item{display:block;width:100%;text-align:left;padding:.55rem .75rem;border:none;border-bottom:1px solid var(--dm-border, #e2e8f0);background:none;cursor:pointer;color:var(--dm-text, #1e293b)}.mail-item:hover{background:var(--dm-hover-bg, rgba(15, 23, 42, .04))}.mail-item.is-active{background:var(--dm-active-bg, rgba(15, 23, 42, .07))}.mail-item-top{display:flex;justify-content:space-between;gap:.5rem;align-items:baseline}.mail-item-from{font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item.is-unread .mail-item-from,.mail-item.is-unread .mail-item-subject{font-weight:700}.mail-item-date{font-size:.72rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-item-subject{font-size:.82rem;margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item-meta{display:flex;align-items:center;gap:.35rem;margin-top:.15rem;color:var(--dm-text-muted, #64748b)}.mail-pager{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.4rem .6rem;border-top:1px solid var(--dm-border, #e2e8f0);font-size:.75rem;color:var(--dm-text-muted, #64748b);flex-shrink:0}.mail-reader-head{padding:.75rem 1rem;border-bottom:1px solid var(--dm-border, #e2e8f0);flex-shrink:0}.mail-reader-head:empty{display:none}.mail-reader-subject{font-size:1.05rem;font-weight:600;margin:0 0 .3rem;color:var(--dm-text, #1e293b)}.mail-reader-actions{display:flex;gap:.25rem;margin:.1rem 0 .35rem;flex-wrap:wrap}.mail-reader-meta{font-size:.8rem;color:var(--dm-text-muted, #64748b)}.mail-reader-body{flex:1;position:relative;overflow:hidden;display:flex;flex-direction:column;min-height:0;background:var(--dm-surface, #fff)}.mail-reader-body iframe{width:100%;flex:1;border:none;display:block;background:#fff}.mail-notice{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;padding:.4rem .75rem;font-size:.78rem;background:var(--dm-warning, #8a6d1f);color:var(--dm-warning-text, #fff);border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-notice .btn{color:var(--dm-warning-text, #fff);border:1px solid currentColor;background:transparent;opacity:.95}.mail-notice .btn:hover{background:color-mix(in srgb,var(--dm-warning-text, #fff) 15%,transparent);opacity:1}.mail-attachments{display:flex;flex-wrap:wrap;gap:.4rem;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border, #e2e8f0);background:var(--dm-background-alt, rgba(15, 23, 42, .03))}.mail-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;height:100%;color:var(--dm-text-muted, #94a3b8);text-align:center;padding:1rem}.mail-empty p{margin:0;font-size:.875rem}@media(max-width:900px){.mail-layout{grid-template-columns:1fr;height:auto!important}.mail-splitter{display:none}.mail-folders-pane,.mail-list-pane{border-right:none;border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-folders-list,.mail-list{max-height:240px}.mail-reader-body{min-height:60vh}}.mail-box-row{display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px)}.mail-default-badge{margin-left:.4rem;font-size:.7rem;font-weight:500;vertical-align:middle}.mail-item.is-dragging{opacity:.45}.mail-folder.is-drop-target{background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff);outline:2px solid var(--dm-primary, #2563eb);outline-offset:-2px}.mail-folder.is-drop-target .mail-folder-name,.mail-folder.is-drop-target .mail-unread-pill{color:inherit}.mail-item.is-dragging *{pointer-events:none}.mail-item{position:relative}.mail-item-tick{position:absolute;left:.45rem;top:.55rem;display:inline-flex;opacity:0;color:var(--dm-text-muted, #64748b);transition:opacity .12s ease;padding:2px;margin:-2px;border-radius:var(--dm-radius-sm, 3px)}.mail-item:hover .mail-item-tick,.mail-item:focus-visible .mail-item-tick,.mail-list.is-selecting .mail-item-tick,.mail-item-tick[aria-checked=true]{opacity:1}.mail-item-tick[aria-checked=true]{color:var(--dm-primary, #2563eb)}.mail-item-tick:hover{color:var(--dm-primary, #2563eb);background:var(--dm-hover-bg)}.mail-item-top,.mail-item-subject,.mail-item-meta{padding-left:1.5rem}.mail-select-all{display:inline-flex;align-items:center;margin-right:.1rem;cursor:pointer}.mail-select-all input{cursor:pointer;margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.mail-item-meta>span{display:inline-flex}.mail-item-meta .is-replied{color:var(--dm-success)}.mail-item-meta .is-forwarded{color:var(--dm-info, var(--dm-primary))}.mail-item-meta .is-high{color:var(--dm-danger, #dc2626)}.mail-item-meta .is-low{color:var(--dm-text-muted, #64748b);opacity:.7}.mail-folder.is-hidden-folder .mail-folder-name{opacity:.5;font-style:italic}.mail-thread-chip{display:inline-flex;align-items:center;margin-left:.4rem;padding:0 .35rem;border-radius:999px;font-size:.68rem;font-weight:700;line-height:1.5;color:var(--dm-text-muted, #64748b);background:var(--dm-active-bg, rgba(15, 23, 42, .07));cursor:pointer}.mail-thread-chip:hover{color:var(--dm-primary, #2563eb)}.mail-thread-chip.is-unread{background:var(--dm-primary, #2563eb);color:var(--dm-primary-text, #fff)}.mail-item.is-in-thread{padding-left:1.6rem;box-shadow:inset 3px 0 0 var(--dm-border, #e2e8f0)}.mail-item.is-in-thread .mail-item-from{font-size:.8rem}.mail-pane-head .btn.is-on{color:var(--dm-primary, #2563eb);background:var(--dm-active-bg, rgba(15, 23, 42, .07))}#mail-outbox-btn{font-size:.72rem;font-weight:700}#mail-outbox-btn.is-failed{color:var(--dm-danger, #dc2626)}.mail-attachment{display:inline-flex;align-items:center;border:1px solid var(--dm-border, #e2e8f0);border-radius:var(--dm-radius-md, 6px);overflow:hidden}.mail-attachment .btn{border:none;border-radius:0}.mail-attachment-save{padding-left:.4rem!important;padding-right:.4rem!important;border-left:1px solid var(--dm-border, #e2e8f0)!important;opacity:.7}.mail-attachment-save:hover{opacity:1;color:var(--dm-primary, #2563eb)}.mail-section-heading{margin:1rem 0 .1rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted, #64748b)}.mail-section-heading:first-child{margin-top:.25rem}.mail-section-form .domma-form{margin:0}.mail-section-form .domma-form-field:last-child{margin-bottom:0}.mail-item-card{margin:0}.mail-item-card .card-header{padding:.5rem .6rem}.mail-item-card .card-body{padding:.6rem}.mail-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.mail-status{min-height:1.2em}.mail-empty-line{margin:.25rem 0}.mail-editor-fallback{min-height:8rem;font-family:inherit;resize:vertical}.mail-clause{display:flex;gap:.3rem;align-items:center;flex-wrap:wrap}.mail-clause .form-select,.mail-clause .form-input{width:auto;flex:0 1 auto}.mail-clause .mail-clause-value{flex:1 1 8rem;min-width:8rem}.mail-section-list{display:flex;flex-direction:column;gap:.5rem}.mail-scroll-sm{max-height:20vh;overflow-y:auto}.mail-scroll-md{max-height:46vh;overflow-y:auto}.mail-section-form .domma-form-field:has(select) .form-label .text-danger{display:none}.mail-compose-form{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.6rem;height:100%}.mail-compose-body{display:flex;flex-direction:column;flex:1;min-height:0}.mail-compose-editor{flex:1;min-height:0}.mail-compose-textarea{flex:1;min-height:34vh;font-family:inherit;resize:vertical}.mail-attach-list{display:flex;flex-direction:column;gap:.2rem}.mail-template-row{margin:-.15rem 0 .35rem}.mail-template-picker{max-width:16rem}.mail-suggest-row{display:flex;flex-direction:column;line-height:1.25}.mail-suggest-detail{opacity:.7}.mail-rule-off{opacity:.55}.mail-panel{padding:.25rem 0 .5rem}.mail-panel-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:1rem}.mail-form-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.75rem}.mail-dialog-stack{display:flex;flex-direction:column;gap:.5rem;padding:.25rem 0}.mail-hint{margin-top:.2rem}.mail-box-sub{overflow:hidden;text-overflow:ellipsis}.mail-add-btn{align-self:flex-start;margin-top:.5rem}.mail-none{margin:0;padding:.5rem .6rem}.mail-kv{padding:.5rem 0;display:flex;flex-direction:column;gap:.35rem}.mail-kv-row{display:flex;gap:.75rem}.mail-kv-key{min-width:8.5rem;flex-shrink:0}.mail-toggle-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem}.mail-sender-list{display:flex;flex-direction:column;gap:.2rem;max-height:40vh;overflow-y:auto}.mail-folder-pick{display:flex;align-items:center;gap:.5rem;padding:.3rem .4rem;border-radius:var(--dm-radius-md, 6px)}.mail-diag-log{width:100%;height:30vh;margin-top:.5rem;font-family:monospace;font-size:.72rem}.mail-facts{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.75rem}.mail-item-more{position:absolute;right:.4rem;bottom:.35rem;display:inline-flex;align-items:center;justify-content:center;min-width:1.4rem;height:1.4rem;border-radius:var(--dm-radius-sm, 3px);font-size:1rem;line-height:1;color:var(--dm-text-muted, #64748b);background:inherit;opacity:0;transition:opacity .12s ease}.mail-item:hover .mail-item-more,.mail-item:focus-visible .mail-item-more,.mail-item.is-active .mail-item-more{opacity:1}.mail-item-more:hover{color:var(--dm-primary, #2563eb)}.mail-section-intro{display:flex;align-items:center;gap:.35rem;margin:0 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--dm-border, #e2e8f0)}.mail-pager-middle{display:flex;align-items:center;gap:.5rem;min-width:0}.mail-page-size{font:inherit;color:inherit;background:transparent;border:1px solid var(--dm-border, #e2e8f0);border-radius:4px;padding:.1rem .25rem}
|
|
@@ -1829,7 +1829,8 @@ return {
|
|
|
1829
1829
|
// attention each time.
|
|
1830
1830
|
[message.priority === 'high', 'arrow-up', 'High priority', 'is-high'],
|
|
1831
1831
|
[message.priority === 'low', 'arrow-down', 'Low priority', 'is-low'],
|
|
1832
|
-
[flags.includes('\\Answered'), 'corner-up-left', 'Replied to', ''],
|
|
1832
|
+
[flags.includes('\\Answered') || message.repliedAt, 'corner-up-left', 'Replied to', 'is-replied'],
|
|
1833
|
+
[flags.includes('$Forwarded') || message.forwardedAt, 'corner-up-right', 'Forwarded', 'is-forwarded'],
|
|
1833
1834
|
[flags.includes('\\Flagged'), 'flag', 'Starred', ''],
|
|
1834
1835
|
[message.hasAttachment, 'paperclip', 'Has an attachment', '']
|
|
1835
1836
|
]) {
|
|
@@ -4309,9 +4310,11 @@ return {
|
|
|
4309
4310
|
* @returns {Promise<void>}
|
|
4310
4311
|
*/
|
|
4311
4312
|
async function editDraft(message) {
|
|
4313
|
+
const accountId = currentAccountId;
|
|
4314
|
+
const profile = identitySoon();
|
|
4312
4315
|
try {
|
|
4313
4316
|
const params = new URLSearchParams({
|
|
4314
|
-
account:
|
|
4317
|
+
account: accountId ?? '',
|
|
4315
4318
|
folder: message.folder ?? openFolder ?? currentFolder,
|
|
4316
4319
|
mode: 'edit'
|
|
4317
4320
|
});
|
|
@@ -4321,7 +4324,8 @@ return {
|
|
|
4321
4324
|
// is from. What it does NOT get is a second signature -
|
|
4322
4325
|
// `openCompose` reads the saved UID and treats the draft as
|
|
4323
4326
|
// already written.
|
|
4324
|
-
openCompose(composeOptions(await
|
|
4327
|
+
openCompose(composeOptions(await profile, {
|
|
4328
|
+
accountId,
|
|
4325
4329
|
draft: result.draft,
|
|
4326
4330
|
mode: 'edit',
|
|
4327
4331
|
// Saving or sending changes which UIDs are in Drafts, so
|
|
@@ -4559,14 +4563,17 @@ return {
|
|
|
4559
4563
|
* @returns {Promise<void>}
|
|
4560
4564
|
*/
|
|
4561
4565
|
async function compose(message, mode, {quote = null} = {}) {
|
|
4566
|
+
const accountId = currentAccountId;
|
|
4567
|
+
const profile = identitySoon();
|
|
4562
4568
|
try {
|
|
4563
4569
|
const params = new URLSearchParams({
|
|
4564
|
-
account:
|
|
4570
|
+
account: accountId ?? '',
|
|
4565
4571
|
folder: message.folder ?? openFolder ?? currentFolder,
|
|
4566
4572
|
mode
|
|
4567
4573
|
});
|
|
4568
4574
|
const result = await api(`${BASE}/messages/${message.uid}/draft?${params}`);
|
|
4569
|
-
openCompose(composeOptions(await
|
|
4575
|
+
openCompose(composeOptions(await profile, {
|
|
4576
|
+
accountId,
|
|
4570
4577
|
// Quote in reply: just the selected text under the
|
|
4571
4578
|
// attribution line, not the whole message.
|
|
4572
4579
|
draft: quote ? quoteSelection(result.draft, quote) : result.draft,
|
|
@@ -5002,7 +5009,9 @@ return {
|
|
|
5002
5009
|
const result = await api(`${BASE}/messages?${params}`);
|
|
5003
5010
|
if (!here() || currentAccountId !== account || currentFolder !== folder ||
|
|
5004
5011
|
page !== 1 || searchQuery || selected.size) return;
|
|
5005
|
-
const signature = rows => JSON.stringify(rows.map(m => [
|
|
5012
|
+
const signature = rows => JSON.stringify(rows.map(m => [
|
|
5013
|
+
m.uid, m.flags, m.repliedAt ?? null, m.forwardedAt ?? null
|
|
5014
|
+
]));
|
|
5006
5015
|
const snapshot = JSON.stringify([account, folder, result.total, signature(result.messages)]);
|
|
5007
5016
|
const changed = threadsOn
|
|
5008
5017
|
? snapshot !== observedThreadPage
|
package/plugins/_lib/call/ice.js
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* {
|
|
7
7
|
* "stun": ["stun:stun.l.google.com:19302"],
|
|
8
8
|
* "turn": {"urls": ["turn:turn.example.org:3478?transport=udp", "turn:turn.example.org:3478?transport=tcp"],
|
|
9
|
-
* "secret": "<coturn static-auth-secret>", "ttlHours":
|
|
9
|
+
* "secret": "<coturn static-auth-secret>", "ttlHours": 4}
|
|
10
10
|
* }
|
|
11
11
|
*
|
|
12
12
|
* TURN credentials are coturn's time-limited kind (use-auth-secret): the
|
|
13
13
|
* username is "<expiry>:<label>" and the password an HMAC of it, so no
|
|
14
14
|
* account is ever stored and a copied credential stops working on its own.
|
|
15
|
+
* The default life is 4 hours (was 12): it is made when a person is let in,
|
|
16
|
+
* and a call rarely runs longer; a shorter one limits what a leaked credential
|
|
17
|
+
* is worth as a relay.
|
|
15
18
|
*
|
|
16
19
|
* @module _lib/call/ice
|
|
17
20
|
*/
|
|
@@ -32,7 +35,7 @@ export function readCallConfig(root = process.cwd()) {
|
|
|
32
35
|
const stun = list(raw?.stun);
|
|
33
36
|
const turnUrls = list(raw?.turn?.urls);
|
|
34
37
|
const secret = typeof raw?.turn?.secret === 'string' ? raw.turn.secret : '';
|
|
35
|
-
const ttlHours = Math.max(1, Math.min(48, Number(raw?.turn?.ttlHours) ||
|
|
38
|
+
const ttlHours = Math.max(1, Math.min(48, Number(raw?.turn?.ttlHours) || 4));
|
|
36
39
|
return {stun: stun.length ? stun : DEFAULT_STUN, turn: turnUrls.length && secret ? {urls: turnUrls, secret, ttlHours} : null};
|
|
37
40
|
} catch {
|
|
38
41
|
return {stun: DEFAULT_STUN, turn: null};
|
|
@@ -132,8 +132,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
132
132
|
const item = quiet ? undefined : system(room, person, 'joined', `${person.name} joined`);
|
|
133
133
|
tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person), ...(item && {item})});
|
|
134
134
|
// `chat` is the history so far (their own "joined" line included), so a late joiner sees what was said.
|
|
135
|
+
// The ICE servers (TURN credentials) come HERE, not in the join reply: a guest still waiting, or turned
|
|
136
|
+
// away, never holds a relay credential. Made at admission, so its life starts now.
|
|
137
|
+
const ice = typeof person.ice === 'function' ? person.ice() : person.ice;
|
|
135
138
|
push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording,
|
|
136
|
-
chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c}))});
|
|
139
|
+
chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c})), ...(ice ? {iceServers: ice} : {})});
|
|
137
140
|
tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
|
|
138
141
|
}
|
|
139
142
|
|
|
@@ -149,9 +152,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
149
152
|
* `canInvite`: this person may use the Tool's "Add people" (routes.js decides; see member()), with
|
|
150
153
|
* `inviteOptions` (what of it). `seeHistory: false`: when let in they get only the chat from their own
|
|
151
154
|
* "joined" line on - not what was said before (someone added to a conversation's call is not shown it).
|
|
155
|
+
* `ice`: the ICE servers for this person (an array, or a function making them) - handed over in their
|
|
156
|
+
* `admitted` event, never before: a waiting or turned-away guest holds no TURN credential.
|
|
152
157
|
*/
|
|
153
158
|
join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = '', history, canInvite = false,
|
|
154
|
-
inviteOptions = null, seeHistory = true}) {
|
|
159
|
+
inviteOptions = null, seeHistory = true, ice = null}) {
|
|
155
160
|
let room = rooms.get(key);
|
|
156
161
|
if (!room) {
|
|
157
162
|
const seeded = (Array.isArray(history) ? history : []).map(cleanItem).filter(Boolean).slice(-HISTORY_MAX);
|
|
@@ -177,7 +182,8 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
177
182
|
if (admitted(room).length >= room.max && (host || autoAdmit)) throw new CallError('full', `This call is full (${room.max} people).`);
|
|
178
183
|
const id = newId(9);
|
|
179
184
|
const person = {id, secret: newId(24), identity: String(identity || ''), who: whoOf(identity, id), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
|
|
180
|
-
host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()
|
|
185
|
+
host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now(),
|
|
186
|
+
ice: ice || null};
|
|
181
187
|
room.people.set(person.id, person);
|
|
182
188
|
if (host || autoAdmit) {
|
|
183
189
|
// admit() also tells every host - this one included - who is already waiting.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* nothing: only a Tool that mounts this, and says who may join which room,
|
|
5
5
|
* has calls.
|
|
6
6
|
*
|
|
7
|
-
* POST {prefix}/:room/join {name, ...credentials} -> {id, secret, host, state,
|
|
7
|
+
* POST {prefix}/:room/join {name, ...credentials} -> {id, secret, host, state, ...} (ICE servers come in the `admitted` event)
|
|
8
8
|
* GET {prefix}/:room/poll?since=N -> {events, state} (long-poll, 25 s)
|
|
9
9
|
* POST {prefix}/:room/signal {to, data}
|
|
10
10
|
* POST {prefix}/:room/chat {text}
|
|
@@ -118,12 +118,15 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
|
|
|
118
118
|
try {
|
|
119
119
|
const canInvite = inviting && (typeof r.mayInvite === 'boolean' ? r.mayInvite : r.host === true);
|
|
120
120
|
const inviteOptions = canInvite ? inviteOptionsOf(r) : null;
|
|
121
|
+
// The ICE servers (a TURN credential good for ttlHours, when configured) go in this person's `admitted`
|
|
122
|
+
// event, made at admission - not in this reply, which a guest still waiting, or about to be turned away,
|
|
123
|
+
// also gets. Before, anyone holding a call link had a relay credential for the TTL.
|
|
124
|
+
const hostname = request.hostname;
|
|
121
125
|
const joined = rooms.join(key, {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
|
|
122
126
|
recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || '', history, canInvite,
|
|
123
|
-
inviteOptions, seeHistory: r.history !== false});
|
|
127
|
+
inviteOptions, seeHistory: r.history !== false, ice: () => iceServers(config(), hostname)});
|
|
124
128
|
// What "Add people" offers this person: {search, typed, copyLink, external} (invite.js inviteOptionsOf).
|
|
125
|
-
return reply.send({...joined,
|
|
126
|
-
...(inviteOptions ? {inviteOptions} : {})});
|
|
129
|
+
return reply.send({...joined, closesAt: r.closesAt || null, ...(inviteOptions ? {inviteOptions} : {})});
|
|
127
130
|
} catch (err) { return fail(reply, err); }
|
|
128
131
|
});
|
|
129
132
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* @module _lib/mail/compose
|
|
11
11
|
*/
|
|
12
12
|
import {readPriority} from './priority.js';
|
|
13
|
+
import {normaliseMessageSource, readMessageSource} from './message-action.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Addresses to reply to.
|
|
@@ -162,11 +163,13 @@ export function quoteHtml(message, html, text, sanitise) {
|
|
|
162
163
|
* @param {{message: object, self: string, mode: 'reply'|'replyAll'|'forward', body: string}} options
|
|
163
164
|
* @returns {object} a draft the compose window can open with
|
|
164
165
|
*/
|
|
165
|
-
export function buildDraft({message, self, mode, body, html = null, sanitise = null}) {
|
|
166
|
+
export function buildDraft({message, self, mode, body, html = null, sanitise = null, sourceMessage = null}) {
|
|
166
167
|
const htmlQuote = sanitise ? quoteHtml(message, html, body, sanitise) : null;
|
|
168
|
+
const source = normaliseMessageSource(sourceMessage);
|
|
167
169
|
|
|
168
170
|
if (mode === 'forward') {
|
|
169
171
|
return {
|
|
172
|
+
...(source ? {sourceMessage: source} : {}),
|
|
170
173
|
to: [],
|
|
171
174
|
cc: [],
|
|
172
175
|
subject: replySubject(message.subject, 'Fwd'),
|
|
@@ -185,6 +188,7 @@ export function buildDraft({message, self, mode, body, html = null, sanitise = n
|
|
|
185
188
|
: {to: replyRecipients(message), cc: []};
|
|
186
189
|
|
|
187
190
|
return {
|
|
191
|
+
...(source ? {sourceMessage: source} : {}),
|
|
188
192
|
to,
|
|
189
193
|
cc,
|
|
190
194
|
subject: replySubject(message.subject),
|
|
@@ -239,7 +243,8 @@ export function editableDraft(message, uid = null) {
|
|
|
239
243
|
content: file.content ?? '',
|
|
240
244
|
size: file.size ?? 0
|
|
241
245
|
})),
|
|
242
|
-
savedUid: uid
|
|
246
|
+
savedUid: uid,
|
|
247
|
+
sourceMessage: readMessageSource(message.headers)
|
|
243
248
|
};
|
|
244
249
|
}
|
|
245
250
|
|
|
@@ -40,6 +40,18 @@ export default {
|
|
|
40
40
|
*/
|
|
41
41
|
allowInsecureTLS: false,
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Let a mailbox or outgoing server be on a private, loopback or link-local
|
|
45
|
+
* address.
|
|
46
|
+
*
|
|
47
|
+
* Off, deliberately: "test these details" makes this server open TCP to the
|
|
48
|
+
* host typed in and report refused / no answer / unknown - a port scanner of
|
|
49
|
+
* the server's own network behind the Email permission. The site's own
|
|
50
|
+
* configured outgoing server (config/site.json smtp.host) is always allowed.
|
|
51
|
+
* Turn on only for a site whose mail server really is on its LAN.
|
|
52
|
+
*/
|
|
53
|
+
allowPrivateMailHosts: false,
|
|
54
|
+
|
|
43
55
|
/** Largest inline image embedded into a rendered body, in bytes. */
|
|
44
56
|
inlineImageMaxBytes: 2_000_000,
|
|
45
57
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* May this server open a connection to that mail host?
|
|
3
|
+
*
|
|
4
|
+
* `/accounts/test`, saving a mailbox and the outgoing-server routes make the
|
|
5
|
+
* CMS open TCP to whatever host:port the caller typed, and `describeError()`
|
|
6
|
+
* answers "refused", "did not respond" or "could not be resolved" - a port
|
|
7
|
+
* scanner of the server's own network behind the `email` permission
|
|
8
|
+
* (127.0.0.1:27018 is the site's Mongo, :8192 the manager). So a private,
|
|
9
|
+
* loopback or link-local host is refused unless it is the host the site's own
|
|
10
|
+
* outgoing mail already goes to (config/site.json smtp.host - the LAN relay
|
|
11
|
+
* the admin chose) or `allowPrivateMailHosts` is set for a site whose mail
|
|
12
|
+
* server really is on its LAN.
|
|
13
|
+
*
|
|
14
|
+
* The pure half is `isPrivateAddress` / `isLocalName` in unsubscribe.js; this
|
|
15
|
+
* module adds the lookup, which the tests inject.
|
|
16
|
+
*
|
|
17
|
+
* @module _lib/mail/hostGuard
|
|
18
|
+
*/
|
|
19
|
+
import dns from 'node:dns/promises';
|
|
20
|
+
import net from 'node:net';
|
|
21
|
+
import {isLocalName, isPrivateAddress} from './unsubscribe.js';
|
|
22
|
+
|
|
23
|
+
const REFUSED = 'That server is on this machine\'s own network, so the site will not connect to it. '
|
|
24
|
+
+ 'Use the mail server\'s public name, or ask the site administrator to allow private mail hosts.';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalise a typed host for comparison: case, trailing dot, IPv6 brackets.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} host
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function normaliseHost(host) {
|
|
33
|
+
return String(host ?? '').trim().toLowerCase().replace(/\.$/, '').replace(/^\[(.*)\]$/, '$1');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check one host.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} host
|
|
40
|
+
* @param {object} [options]
|
|
41
|
+
* @param {boolean} [options.allowPrivate] - the site's `allowPrivateMailHosts` setting
|
|
42
|
+
* @param {string} [options.ownSmtpHost] - the site's configured outgoing server; always allowed
|
|
43
|
+
* @param {(host: string) => Promise<{address: string}[]>} [options.lookup] - DNS, injectable
|
|
44
|
+
* @returns {Promise<{ok: true} | {ok: false, error: string}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function checkMailHost(host, {allowPrivate = false, ownSmtpHost = '', lookup} = {}) {
|
|
47
|
+
const name = normaliseHost(host);
|
|
48
|
+
if (!name) return {ok: false, error: 'A server is required.'};
|
|
49
|
+
if (allowPrivate) return {ok: true};
|
|
50
|
+
if (ownSmtpHost && normaliseHost(ownSmtpHost) === name) return {ok: true};
|
|
51
|
+
|
|
52
|
+
if (net.isIP(name)) {
|
|
53
|
+
return isPrivateAddress(name) ? {ok: false, error: REFUSED} : {ok: true};
|
|
54
|
+
}
|
|
55
|
+
if (isLocalName(name)) return {ok: false, error: REFUSED};
|
|
56
|
+
|
|
57
|
+
let answers;
|
|
58
|
+
try {
|
|
59
|
+
answers = await (lookup ?? ((h) => dns.lookup(h, {all: true})))(name);
|
|
60
|
+
} catch {
|
|
61
|
+
// Unresolvable: let the connection attempt say so in its own words.
|
|
62
|
+
// The check is about where a name points, not whether it exists.
|
|
63
|
+
return {ok: true};
|
|
64
|
+
}
|
|
65
|
+
// Every answer must be public: a name with one private A record among
|
|
66
|
+
// public ones is exactly the rebinding trick the check exists for.
|
|
67
|
+
const addresses = (answers ?? []).map(a => (typeof a === 'string' ? a : a?.address)).filter(Boolean);
|
|
68
|
+
if (addresses.some(isPrivateAddress)) return {ok: false, error: REFUSED};
|
|
69
|
+
return {ok: true};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The site's own outgoing server, from config/site.json `smtp.host` (what
|
|
74
|
+
* core `createTransport` sends with), read through a getter so a test can
|
|
75
|
+
* hand in its own.
|
|
76
|
+
*
|
|
77
|
+
* @param {(section: string) => any} getConfig
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function siteSmtpHost(getConfig) {
|
|
81
|
+
try {
|
|
82
|
+
return String(getConfig('site')?.smtp?.host ?? '').trim();
|
|
83
|
+
} catch {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Remember replies and forwards on the original message after delivery. */
|
|
2
|
+
export const SOURCE_HEADER = 'X-Domma-Message-Source';
|
|
3
|
+
|
|
4
|
+
export function normaliseMessageSource(source) {
|
|
5
|
+
if (!source || !['reply', 'forward'].includes(source.action)) return null;
|
|
6
|
+
const uid = Number(source.uid);
|
|
7
|
+
const folder = typeof source.folder === 'string' ? source.folder : '';
|
|
8
|
+
const accountId = typeof source.accountId === 'string' ? source.accountId : '';
|
|
9
|
+
const messageId = typeof source.messageId === 'string' ? source.messageId : null;
|
|
10
|
+
if (!Number.isSafeInteger(uid) || uid < 1 || !folder || folder.length > 1024
|
|
11
|
+
|| /[\r\n\0]/.test(folder) || !accountId || accountId.length > 128
|
|
12
|
+
|| (messageId && (messageId.length > 1024 || /[\r\n\0]/.test(messageId)))) return null;
|
|
13
|
+
return {accountId, folder, uid, action: source.action, messageId};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readMessageSource(headers) {
|
|
17
|
+
const value = typeof headers?.get === 'function'
|
|
18
|
+
? headers.get(SOURCE_HEADER.toLowerCase()) : headers?.[SOURCE_HEADER.toLowerCase()];
|
|
19
|
+
if (typeof value !== 'string' || value.length > 4096) return null;
|
|
20
|
+
try {
|
|
21
|
+
return normaliseMessageSource(JSON.parse(Buffer.from(value, 'base64url').toString('utf8')));
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Best effort: recording a delivered message must never turn it into a failed send. */
|
|
26
|
+
export async function recordMessageAction({source, account, pool, poolKey, connection, store = null}) {
|
|
27
|
+
source = normaliseMessageSource(source);
|
|
28
|
+
if (!source || source.accountId !== account?.id) return null;
|
|
29
|
+
const flag = source.action === 'reply' ? '\\Answered' : '$Forwarded';
|
|
30
|
+
let flags = null;
|
|
31
|
+
let error = null;
|
|
32
|
+
let sourceVerified = false;
|
|
33
|
+
try {
|
|
34
|
+
flags = await pool.withMailbox(poolKey, connection, source.folder, async client => {
|
|
35
|
+
const original = await client.fetchOne(source.uid, {envelope: true, flags: true}, {uid: true});
|
|
36
|
+
if (!original || (source.messageId && original.envelope?.messageId !== source.messageId)) {
|
|
37
|
+
throw new Error('The original message is no longer in that folder.');
|
|
38
|
+
}
|
|
39
|
+
sourceVerified = true;
|
|
40
|
+
await client.messageFlagsAdd({uid: String(source.uid)}, [flag], {uid: true});
|
|
41
|
+
const after = await client.fetchOne(source.uid, {flags: true}, {uid: true});
|
|
42
|
+
const recorded = [...(after?.flags ?? [])];
|
|
43
|
+
if (!recorded.includes(flag)) throw new Error('The mail server did not keep the message marker.');
|
|
44
|
+
return recorded;
|
|
45
|
+
}, {writable: true});
|
|
46
|
+
} catch (err) { error = err.message; }
|
|
47
|
+
|
|
48
|
+
let mirrored = false;
|
|
49
|
+
if (store && sourceVerified) {
|
|
50
|
+
try {
|
|
51
|
+
if (flags) await store.setFlags(account.id, source.folder, [{uid: source.uid, flags}]);
|
|
52
|
+
mirrored = await store.setMessageAction(account.id, source.folder, source.uid, source.action);
|
|
53
|
+
} catch (err) { error ??= err.message; }
|
|
54
|
+
}
|
|
55
|
+
return {...source, flags, recorded: Boolean(flags || mirrored), error: flags || mirrored ? null : error};
|
|
56
|
+
}
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
import {simpleParser} from 'mailparser';
|
|
29
29
|
|
|
30
30
|
import {listUsers} from '../../../server/services/users.js';
|
|
31
|
+
import {getConfig} from '../../../server/config.js';
|
|
32
|
+
import {checkMailHost, siteSmtpHost} from './hostGuard.js';
|
|
31
33
|
import * as accounts from './accounts.js';
|
|
32
34
|
import * as bodyTokens from './bodyTokens.js';
|
|
33
35
|
import {buildDiagnostics} from './diagnostics.js';
|
|
@@ -207,6 +209,11 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
207
209
|
});
|
|
208
210
|
}
|
|
209
211
|
|
|
212
|
+
/** Where this server may be asked to connect - see hostGuard.js. */
|
|
213
|
+
const guardHost = (host) => checkMailHost(host, {allowPrivate: config.allowPrivateMailHosts === true, ownSmtpHost: siteSmtpHost(getConfig)});
|
|
214
|
+
// Each test is a connection attempt whose answer says what is listening: a few a minute per address is plenty.
|
|
215
|
+
const testLimit = {config: {rateLimit: {max: 10, timeWindow: '1 minute'}}};
|
|
216
|
+
|
|
210
217
|
/** GET /accounts - every mailbox this user owns, without passwords. */
|
|
211
218
|
fastify.get('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
|
|
212
219
|
const uid = userId(request);
|
|
@@ -221,6 +228,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
221
228
|
|
|
222
229
|
const result = accounts.validate(request.body ?? {}, null);
|
|
223
230
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
231
|
+
const where = await guardHost(result.value.host);
|
|
232
|
+
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
224
233
|
|
|
225
234
|
const saved = await accounts.saveAccount(uid, null, result.value);
|
|
226
235
|
return {account: accounts.toPublic(saved)};
|
|
@@ -236,6 +245,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
236
245
|
|
|
237
246
|
const result = accounts.validate(request.body ?? {}, existing);
|
|
238
247
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
248
|
+
const where = await guardHost(result.value.host);
|
|
249
|
+
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
239
250
|
|
|
240
251
|
const saved = await accounts.saveAccount(uid, request.params.id, result.value);
|
|
241
252
|
// The pooled connection was opened with the old details.
|
|
@@ -271,7 +282,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
271
282
|
* password field is blank and `id` names an existing mailbox, the stored
|
|
272
283
|
* password is used - otherwise editing a hostname would mean retyping it.
|
|
273
284
|
*/
|
|
274
|
-
fastify.post('/accounts/test', {preHandler: [authenticate]}, async (request, reply) => {
|
|
285
|
+
fastify.post('/accounts/test', {preHandler: [authenticate], ...testLimit}, async (request, reply) => {
|
|
275
286
|
const uid = userId(request);
|
|
276
287
|
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
277
288
|
|
|
@@ -281,6 +292,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
281
292
|
|
|
282
293
|
const result = accounts.validate(body, existing);
|
|
283
294
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
295
|
+
const where = await guardHost(result.value.host);
|
|
296
|
+
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
284
297
|
|
|
285
298
|
let pass = result.value.password;
|
|
286
299
|
if (!pass) {
|
|
@@ -711,6 +724,12 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
711
724
|
reply.code(404).send({error: 'That message is no longer in this folder.'});
|
|
712
725
|
return null;
|
|
713
726
|
}
|
|
727
|
+
// The same ceiling as /messages/:uid and /body: without it a hostile
|
|
728
|
+
// 200 MB message cost a 200 MB buffer plus mailparser output per click.
|
|
729
|
+
if (fetched.source.length > config.maxMessageBytes) {
|
|
730
|
+
reply.code(413).send({error: 'That message is too large to open its attachments.'});
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
714
733
|
|
|
715
734
|
const parsed = await simpleParser(fetched.source);
|
|
716
735
|
const attachment = parsed.attachments?.[index];
|
|
@@ -15,6 +15,7 @@ import nodemailer from 'nodemailer';
|
|
|
15
15
|
import {bareAddress, formatFrom} from '../admin/mail/identity.js';
|
|
16
16
|
import {describeError} from './imapPool.js';
|
|
17
17
|
import {normalisePriority, priorityHeaders} from './priority.js';
|
|
18
|
+
import {normaliseMessageSource, recordMessageAction, SOURCE_HEADER} from './message-action.js';
|
|
18
19
|
|
|
19
20
|
// MailComposer is not on nodemailer's public export, but it is the piece that
|
|
20
21
|
// turns a message object into the bytes that go on the wire - which is what a
|
|
@@ -191,7 +192,7 @@ class SkipFiling extends Error {}
|
|
|
191
192
|
*/
|
|
192
193
|
export async function sendMessage({
|
|
193
194
|
smtp, draft, pool, poolKey, connection, sentFolder = null, allowInsecureTLS = false,
|
|
194
|
-
replaces = null, draftsFolder = null, headers = {}, fileCopy = true
|
|
195
|
+
replaces = null, draftsFolder = null, headers = {}, fileCopy = true, account = null, store = null
|
|
195
196
|
}) {
|
|
196
197
|
// The header carries the display name; the envelope never can. They are
|
|
197
198
|
// built from the same stored address so they cannot name two different
|
|
@@ -229,6 +230,10 @@ export async function sendMessage({
|
|
|
229
230
|
});
|
|
230
231
|
transport.close();
|
|
231
232
|
|
|
233
|
+
const messageAction = await recordMessageAction({
|
|
234
|
+
source: draft.sourceMessage, account, pool, poolKey, connection, store
|
|
235
|
+
});
|
|
236
|
+
|
|
232
237
|
let filedTo = null;
|
|
233
238
|
let fileError = null;
|
|
234
239
|
try {
|
|
@@ -260,7 +265,8 @@ export async function sendMessage({
|
|
|
260
265
|
rejected: info.rejected ?? [],
|
|
261
266
|
filedTo,
|
|
262
267
|
fileError,
|
|
263
|
-
draftRemoved
|
|
268
|
+
draftRemoved,
|
|
269
|
+
messageAction
|
|
264
270
|
};
|
|
265
271
|
}
|
|
266
272
|
|
|
@@ -315,9 +321,12 @@ export async function saveDraft({
|
|
|
315
321
|
}) {
|
|
316
322
|
// Written into the draft, not applied when it is eventually sent: a draft
|
|
317
323
|
// opened on the phone should show the same headers it will go out with.
|
|
324
|
+
const source = normaliseMessageSource(draft.sourceMessage);
|
|
318
325
|
const raw = await buildRaw(draft, from, {
|
|
319
326
|
replyTo,
|
|
320
|
-
readReceiptTo: draft.readReceipt ? (replyTo || bareAddress(from)) : ''
|
|
327
|
+
readReceiptTo: draft.readReceipt ? (replyTo || bareAddress(from)) : '',
|
|
328
|
+
// Private draft metadata: it is omitted from SMTP and the Sent copy.
|
|
329
|
+
headers: source ? {[SOURCE_HEADER]: Buffer.from(JSON.stringify(source)).toString('base64url')} : {}
|
|
321
330
|
});
|
|
322
331
|
|
|
323
332
|
const destination = draftsFolder ?? await findSpecialFolder(pool, poolKey, connection, '\\Drafts');
|
|
@@ -249,7 +249,7 @@ export function createStore(connection) {
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/**
|
|
252
|
-
* Store
|
|
252
|
+
* Store envelopes while retaining the local reply and forward history.
|
|
253
253
|
*
|
|
254
254
|
* @param {string} accountId
|
|
255
255
|
* @param {string} path
|
|
@@ -262,15 +262,14 @@ export function createStore(connection) {
|
|
|
262
262
|
const now = new Date();
|
|
263
263
|
const rooted = await inheritThreadRoots(database, accountId, messages);
|
|
264
264
|
const operations = rooted.map(message => ({
|
|
265
|
-
|
|
265
|
+
updateOne: {
|
|
266
266
|
filter: {_id: messageId(accountId, path, message.uid)},
|
|
267
|
-
|
|
267
|
+
update: {$set: {
|
|
268
268
|
...message,
|
|
269
|
-
_id: messageId(accountId, path, message.uid),
|
|
270
269
|
accountId,
|
|
271
270
|
folder: path,
|
|
272
271
|
fetchedAt: now
|
|
273
|
-
},
|
|
272
|
+
}},
|
|
274
273
|
upsert: true
|
|
275
274
|
}
|
|
276
275
|
}));
|
|
@@ -350,6 +349,17 @@ export function createStore(connection) {
|
|
|
350
349
|
return result.modifiedCount ?? 0;
|
|
351
350
|
}
|
|
352
351
|
|
|
352
|
+
/** Keep action history even when a server refuses the $Forwarded keyword. */
|
|
353
|
+
async function setMessageAction(accountId, path, uid, action) {
|
|
354
|
+
if (!['reply', 'forward'].includes(action)) return false;
|
|
355
|
+
const database = await connect();
|
|
356
|
+
const field = action === 'reply' ? 'repliedAt' : 'forwardedAt';
|
|
357
|
+
const result = await database.collection(MESSAGES).updateOne(
|
|
358
|
+
{_id: messageId(accountId, path, uid)}, {$set: {[field]: new Date()}}
|
|
359
|
+
);
|
|
360
|
+
return result.matchedCount > 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
353
363
|
/**
|
|
354
364
|
* Forget messages that are no longer on the server.
|
|
355
365
|
*
|
|
@@ -1012,7 +1022,7 @@ export function createStore(connection) {
|
|
|
1012
1022
|
return {
|
|
1013
1023
|
connect,
|
|
1014
1024
|
getFolder, saveFolder,
|
|
1015
|
-
putMessages, setFlags, removeMessages, clearFolder,
|
|
1025
|
+
putMessages, setFlags, setMessageAction, removeMessages, clearFolder,
|
|
1016
1026
|
storedUids, listMessages, listThreads, searchMessages, countUnread, listUnread, forgetAccount,
|
|
1017
1027
|
queueSend, listQueuedSends, claimDueSends, finishSend, cancelSend, getQueuedSend,
|
|
1018
1028
|
queueSnooze, listSnoozed, claimDueSnoozes, finishSnooze, wakeSnooze,
|