domma-cms 1.6.36 → 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/mail/compose.js +7 -2
- package/plugins/_lib/mail/message-action.js +56 -0
- 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/server/routes/outreach-public.js +54 -10
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
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
@@ -12,4 +12,4 @@ export {createLedger} from './ledger.js';
|
|
|
12
12
|
export {createSuppression, normaliseEmail} from './suppression.js';
|
|
13
13
|
export {escapeHtml, escapeAttr, outreachBaseUrl, renderInvite, sendBatch} from './sending.js';
|
|
14
14
|
export {dayOf, codeState, closedReason} from './expiry.js';
|
|
15
|
-
export {UNSUBSCRIBE_PATH, mintSecret, signEmail, unsubscribeUrl, verifyUnsubscribe} from './unsubscribe.js';
|
|
15
|
+
export {UNSUBSCRIBE_PATH, mintSecret, signEmail, unsubscribeHeaders, unsubscribeUrl, verifyUnsubscribe} from './unsubscribe.js';
|
|
@@ -25,8 +25,15 @@ export function createLedger(col, {slug = 'outreach-invites', refField = 'ref',
|
|
|
25
25
|
return entries;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Rows for one campaign/survey/journey. The filter does the narrowing in
|
|
30
|
+
* the adapter (a Mongo query, or the file adapter's in-memory match) so a
|
|
31
|
+
* per-hit lookup no longer reads every invite on the site; the in-memory
|
|
32
|
+
* filter stays because a test's fake store ignores `opts.filter`.
|
|
33
|
+
*/
|
|
34
|
+
async function invitesFor(refId, extra = {}) {
|
|
35
|
+
const {entries} = await col.listEntries(slug, {limit: 100000, filter: {[refField]: refId, ...extra}});
|
|
36
|
+
return entries.filter(e => e.data[refField] === refId);
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/** Issue pending invites for contacts that don't already have one. Returns [{id, contactId, token}]. */
|
|
@@ -83,7 +90,7 @@ export function createLedger(col, {slug = 'outreach-invites', refField = 'ref',
|
|
|
83
90
|
/** Find an invite by submitted code (any format). Null if unknown. */
|
|
84
91
|
async function findByCode(refId, code) {
|
|
85
92
|
const hash = hashToken(normaliseCode(code));
|
|
86
|
-
const invites = await invitesFor(refId);
|
|
93
|
+
const invites = await invitesFor(refId, {tokenHash: hash});
|
|
87
94
|
return invites.find(e => e.data.tokenHash === hash) ?? null;
|
|
88
95
|
}
|
|
89
96
|
|
|
@@ -97,11 +97,15 @@ export function renderInvite(template, contact, link, opts = {}) {
|
|
|
97
97
|
*
|
|
98
98
|
* @param {Array<{invite: {id: string}, contact: {name?: string, email: string}, token: string}>} recipients
|
|
99
99
|
* @param {{subject?: string, body?: string}} template
|
|
100
|
-
*
|
|
100
|
+
* `headersFor(recipient)` returns extra message headers - the List-Unsubscribe
|
|
101
|
+
* pair (unsubscribe.js unsubscribeHeaders) when the message carries an
|
|
102
|
+
* unsubscribe link - so a mail client can offer its own opt-out button.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} deps {sendEmail, transport, from, fromName, linkFor(recipient), suppressed?: Set<string>, render?: opts for renderInvite, headersFor?: (recipient) => object}
|
|
101
105
|
* @returns {Promise<Array<{inviteId: string, ok: boolean, suppressed?: true}>>}
|
|
102
106
|
*/
|
|
103
107
|
export async function sendBatch(recipients, template, deps) {
|
|
104
|
-
const {sendEmail, transport, from, fromName, linkFor, suppressed = null, render = {}} = deps;
|
|
108
|
+
const {sendEmail, transport, from, fromName, linkFor, suppressed = null, render = {}, headersFor = null} = deps;
|
|
105
109
|
const results = [];
|
|
106
110
|
for (const r of recipients) {
|
|
107
111
|
if (suppressed && suppressed.has(normaliseEmail(r.contact.email))) {
|
|
@@ -110,8 +114,10 @@ export async function sendBatch(recipients, template, deps) {
|
|
|
110
114
|
}
|
|
111
115
|
const link = linkFor(r);
|
|
112
116
|
const msg = renderInvite(template, r.contact, link, render);
|
|
117
|
+
const headers = typeof headersFor === 'function' ? headersFor(r) : null;
|
|
113
118
|
try {
|
|
114
|
-
await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text
|
|
119
|
+
await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text,
|
|
120
|
+
...(headers && Object.keys(headers).length ? {headers} : {})});
|
|
115
121
|
results.push({inviteId: r.invite.id, ok: true});
|
|
116
122
|
} catch {
|
|
117
123
|
results.push({inviteId: r.invite.id, ok: false});
|
|
@@ -30,17 +30,28 @@ export function createSuppression(col, {slug = 'outreach-unsubscribes'} = {}) {
|
|
|
30
30
|
return new Set((await rows()).map(e => normaliseEmail(e.data.email)).filter(Boolean));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The rows for one address, narrowed by the adapter: `_contains` is
|
|
35
|
+
* case-insensitive on both adapters (older rows may not be lower-cased),
|
|
36
|
+
* and the exact comparison here keeps a fake store that ignores the
|
|
37
|
+
* filter honest. One address, one small read - not the whole list.
|
|
38
|
+
*/
|
|
39
|
+
async function rowsFor(want) {
|
|
40
|
+
const {entries} = await col.listEntries(slug, {limit: 100000, filter: {email_contains: want}}).catch(() => ({entries: []}));
|
|
41
|
+
return entries.filter(e => normaliseEmail(e.data.email) === want);
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
async function isSuppressed(email) {
|
|
34
45
|
const want = normaliseEmail(email);
|
|
35
46
|
if (!want) return false;
|
|
36
|
-
return (await
|
|
47
|
+
return (await rowsFor(want)).length > 0;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
/** Add an address (idempotent). `source` says which Tool took the opt-out. */
|
|
40
51
|
async function add(email, source = 'outreach') {
|
|
41
52
|
const want = normaliseEmail(email);
|
|
42
53
|
if (!want) return null;
|
|
43
|
-
const existing = (await
|
|
54
|
+
const existing = (await rowsFor(want))[0];
|
|
44
55
|
if (existing) return existing;
|
|
45
56
|
return col.createEntry(slug, {email: want, at: new Date().toISOString(), source}, {source});
|
|
46
57
|
}
|
|
@@ -49,7 +60,7 @@ export function createSuppression(col, {slug = 'outreach-unsubscribes'} = {}) {
|
|
|
49
60
|
async function remove(email) {
|
|
50
61
|
if (typeof col.deleteEntry !== 'function') return false;
|
|
51
62
|
const want = normaliseEmail(email);
|
|
52
|
-
const hits =
|
|
63
|
+
const hits = want ? await rowsFor(want) : [];
|
|
53
64
|
for (const e of hits) await col.deleteEntry(slug, e.id).catch(() => {});
|
|
54
65
|
return hits.length > 0;
|
|
55
66
|
}
|
|
@@ -31,6 +31,19 @@ export function unsubscribeUrl(baseUrl, secret, email) {
|
|
|
31
31
|
return `${baseUrl}${UNSUBSCRIBE_PATH}?e=${e}&k=${signEmail(secret, email)}`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The RFC 8058 headers for a message that carries `url`: mail clients show
|
|
36
|
+
* their own Unsubscribe button and POST `List-Unsubscribe=One-Click` to the
|
|
37
|
+
* URL, which the engine's route treats as the confirmed opt-out. Gmail and
|
|
38
|
+
* Yahoo require them of bulk senders.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} url - unsubscribeUrl()
|
|
41
|
+
* @returns {{'List-Unsubscribe': string, 'List-Unsubscribe-Post': string}}
|
|
42
|
+
*/
|
|
43
|
+
export function unsubscribeHeaders(url) {
|
|
44
|
+
return {'List-Unsubscribe': `<${url}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'};
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
/** The verified address from ?e=&k=, or null. Constant-time on the signature. */
|
|
35
48
|
export function verifyUnsubscribe(secret, e, k) {
|
|
36
49
|
let email = '';
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The unsubscribe page every outreach email links to:
|
|
3
3
|
*
|
|
4
|
-
* GET
|
|
4
|
+
* GET /campaign-unsubscribe?e=<base64url address>&k=<HMAC> a confirm page
|
|
5
|
+
* POST /campaign-unsubscribe (e, k as form fields or query) the opt-out
|
|
5
6
|
*
|
|
6
7
|
* Core's, not the sending Tool's, so the link in an email already sent keeps
|
|
7
8
|
* working when that Tool lapses or is removed (PECR). The address goes on the
|
|
8
9
|
* shared suppression list (`outreach-unsubscribes`), which every outreach
|
|
9
10
|
* Tool checks before it sends.
|
|
10
11
|
*
|
|
12
|
+
* Only the POST changes anything. Mail security scanners (Safe Links,
|
|
13
|
+
* Mimecast, Proofpoint) and mailbox previews GET every link in a message, so a
|
|
14
|
+
* GET that opted out unsubscribed people who never clicked - and silenced
|
|
15
|
+
* Surveys and Onboard for them too, the list being shared. The GET shows one
|
|
16
|
+
* button; the POST is also what a mail client's own Unsubscribe button sends
|
|
17
|
+
* (RFC 8058 one-click: the List-Unsubscribe URL with the body
|
|
18
|
+
* `List-Unsubscribe=One-Click`), so both land on the same handler.
|
|
19
|
+
*
|
|
11
20
|
* A Campaigns Pro older than this engine registers the same path itself, and
|
|
12
21
|
* plugins register before core: then core stands aside rather than collide.
|
|
13
22
|
*
|
|
@@ -19,6 +28,7 @@ import {UNSUBSCRIBE_PATH, verifyUnsubscribe} from '../../plugins/_lib/outreach/u
|
|
|
19
28
|
import {getUnsubscribeSecret} from '../services/outreachUnsubscribe.js';
|
|
20
29
|
|
|
21
30
|
const SLUG = 'outreach-unsubscribes';
|
|
31
|
+
const FORM_TYPE = 'application/x-www-form-urlencoded';
|
|
22
32
|
|
|
23
33
|
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
24
34
|
|
|
@@ -26,6 +36,15 @@ function page(title, bodyHtml) {
|
|
|
26
36
|
return `<!DOCTYPE html><html lang="en-GB"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="robots" content="noindex"><title>${esc(title)}</title><link rel="stylesheet" href="/dist/domma/domma.css"><style>main{max-width:36rem;margin:4rem auto;padding:0 1.25rem;line-height:1.6}</style></head><body><main>${bodyHtml}</main></body></html>`;
|
|
27
37
|
}
|
|
28
38
|
|
|
39
|
+
const badLink = () => page('Unsubscribe', '<h1>That link is not right.</h1><p>Use the unsubscribe link from one of our emails.</p>');
|
|
40
|
+
|
|
41
|
+
/** The confirm page: the same e and k, posted back by one button. */
|
|
42
|
+
function confirmPage(email, e, k) {
|
|
43
|
+
return page('Unsubscribe', `<h1>Unsubscribe?</h1><p>${esc(email)} will get no more campaign or survey emails from this site.</p>`
|
|
44
|
+
+ `<form method="post" action="${UNSUBSCRIBE_PATH}"><input type="hidden" name="e" value="${esc(e)}"><input type="hidden" name="k" value="${esc(k)}">`
|
|
45
|
+
+ `<button type="submit" class="btn btn-primary">Unsubscribe</button></form>`);
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
/** The suppression list's collection - made if no outreach Tool ever made it. */
|
|
30
49
|
async function ensureList() {
|
|
31
50
|
try {
|
|
@@ -43,6 +62,14 @@ async function ensureList() {
|
|
|
43
62
|
}
|
|
44
63
|
}
|
|
45
64
|
|
|
65
|
+
/** The e and k of a request: the posted form first, else the URL (a one-click POST keeps them there). */
|
|
66
|
+
export function linkParams(req) {
|
|
67
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
68
|
+
const query = req.query || {};
|
|
69
|
+
const pick = (v) => (typeof v === 'string' ? v : '');
|
|
70
|
+
return {e: pick(body.e) || pick(query.e), k: pick(body.k) || pick(query.k)};
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
/**
|
|
47
74
|
* @param {import('fastify').FastifyInstance} fastify
|
|
48
75
|
* @param {{col?: object, secret?: string}} [opts] - tests inject a collections service and a secret
|
|
@@ -52,17 +79,34 @@ export async function outreachPublicRoutes(fastify, opts = {}) {
|
|
|
52
79
|
const col = opts.col || {createEntry, deleteEntry, listEntries};
|
|
53
80
|
const suppression = createSuppression(col);
|
|
54
81
|
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
// The engine parses JSON only; the confirm form and one-click POSTs are
|
|
83
|
+
// form-encoded. Scoped to these routes, and tiny: two short fields.
|
|
84
|
+
if (!fastify.hasContentTypeParser(FORM_TYPE)) {
|
|
85
|
+
fastify.addContentTypeParser(FORM_TYPE, {parseAs: 'string', bodyLimit: 4096}, (req, body, done) => {
|
|
86
|
+
try { done(null, Object.fromEntries(new URLSearchParams(body))); } catch (err) { done(err); }
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const verified = (req) => {
|
|
91
|
+
const {e, k} = linkParams(req);
|
|
57
92
|
let email = null;
|
|
58
|
-
try { email = verifyUnsubscribe(opts.secret || getUnsubscribeSecret(),
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
93
|
+
try { email = verifyUnsubscribe(opts.secret || getUnsubscribeSecret(), e, k); } catch { email = null; }
|
|
94
|
+
return {email, e, k};
|
|
95
|
+
};
|
|
96
|
+
const html = (reply) => reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer').type('text/html; charset=utf-8');
|
|
97
|
+
|
|
98
|
+
fastify.get(UNSUBSCRIBE_PATH, async (req, reply) => {
|
|
99
|
+
const {email, e, k} = verified(req);
|
|
100
|
+
if (!email) return html(reply).code(400).send(badLink());
|
|
101
|
+
return html(reply).send(confirmPage(email, e, k));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
fastify.post(UNSUBSCRIBE_PATH, async (req, reply) => {
|
|
105
|
+
const {email} = verified(req);
|
|
106
|
+
if (!email) return html(reply).code(400).send(badLink());
|
|
63
107
|
if (!opts.col) await ensureList();
|
|
64
108
|
await suppression.add(email, 'unsubscribe-link');
|
|
65
|
-
return reply
|
|
109
|
+
return html(reply).send(page('Unsubscribed',
|
|
66
110
|
`<h1>You are unsubscribed.</h1><p>${esc(email)} will get no more campaign or survey emails from this site.</p>`));
|
|
67
111
|
});
|
|
68
112
|
}
|