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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.36",
3
+ "version": "1.6.37",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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: currentAccountId ?? '',
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 identitySoon(), {
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: currentAccountId ?? '',
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 identitySoon(), {
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 => [m.uid, m.flags]));
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
+ }
@@ -82,6 +82,8 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
82
82
  try {
83
83
  const result = await sendMessage({
84
84
  smtp,
85
+ account,
86
+ store,
85
87
  draft: item.draft,
86
88
  pool,
87
89
  poolKey: `${item.userId}:${item.accountId}`,
@@ -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 a batch of envelopes, replacing any already held for those UIDs.
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
- replaceOne: {
265
+ updateOne: {
266
266
  filter: {_id: messageId(accountId, path, message.uid)},
267
- replacement: {
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
- async function invitesFor(refId) {
29
- return (await allInvites()).filter(e => e.data[refField] === refId);
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
- * @param {object} deps {sendEmail, transport, from, fromName, linkFor(recipient), suppressed?: Set<string>, render?: opts for renderInvite}
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 suppressedSet()).has(want);
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 rows()).find(e => normaliseEmail(e.data.email) === want);
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 = (await rows()).filter(e => normaliseEmail(e.data.email) === want);
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 one-click unsubscribe page every outreach email links to:
2
+ * The unsubscribe page every outreach email links to:
3
3
  *
4
- * GET /campaign-unsubscribe?e=<base64url address>&k=<HMAC>
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
- fastify.get(UNSUBSCRIBE_PATH, async (req, reply) => {
56
- reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer');
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(), req.query?.e, req.query?.k); } catch { email = null; }
59
- if (!email) {
60
- return reply.code(400).type('text/html; charset=utf-8')
61
- .send(page('Unsubscribe', '<h1>That link is not right.</h1><p>Use the unsubscribe link from one of our emails.</p>'));
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.type('text/html; charset=utf-8').send(page('Unsubscribed',
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
  }