domma-cms 1.6.36 → 1.6.38

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.
Files changed (84) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/package.json +2 -2
  4. package/plugins/_lib/admin/mail/compose-window.js +4 -0
  5. package/plugins/_lib/admin/mail/mail.css +1 -1
  6. package/plugins/_lib/admin/mail/reader-view.js +15 -6
  7. package/plugins/_lib/mail/compose.js +7 -2
  8. package/plugins/_lib/mail/message-action.js +56 -0
  9. package/plugins/_lib/mail/scheduler.js +2 -0
  10. package/plugins/_lib/mail/send.js +12 -3
  11. package/plugins/_lib/mail/store.js +16 -6
  12. package/plugins/_lib/outreach/index.js +1 -1
  13. package/plugins/_lib/outreach/ledger.js +10 -3
  14. package/plugins/_lib/outreach/sending.js +9 -3
  15. package/plugins/_lib/outreach/suppression.js +14 -3
  16. package/plugins/_lib/outreach/unsubscribe.js +13 -0
  17. package/plugins/blog/CLAUDE.md +11 -1
  18. package/plugins/blog/lib/cache.js +43 -0
  19. package/plugins/blog/lib/page.js +28 -9
  20. package/plugins/blog/plugin.js +14 -13
  21. package/plugins/blog/plugin.json +1 -1
  22. package/plugins/blog/plugin.public.js +1 -1
  23. package/plugins/blog/tests/cache.test.js +65 -0
  24. package/plugins/booking/CLAUDE.md +14 -2
  25. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  26. package/plugins/booking/admin/views/diary.js +153 -152
  27. package/plugins/booking/admin/views/people.js +64 -66
  28. package/plugins/booking/admin/views/settings.js +31 -32
  29. package/plugins/booking/plugin.json +1 -1
  30. package/plugins/booking/tests/admin-diary.test.js +9 -1
  31. package/plugins/feedback/CLAUDE.md +55 -0
  32. package/plugins/feedback/admin/css/index.css +16 -1
  33. package/plugins/feedback/admin/lib/kit.js +106 -0
  34. package/plugins/feedback/admin/lib/settings.js +107 -0
  35. package/plugins/feedback/admin/views/feedback.js +4 -1
  36. package/plugins/feedback/admin/views/inbox.js +11 -2
  37. package/plugins/feedback/admin/views/send.js +74 -7
  38. package/plugins/feedback/docs/guide.md +9 -9
  39. package/plugins/feedback/lib/attachments.js +133 -0
  40. package/plugins/feedback/lib/cache.js +43 -0
  41. package/plugins/feedback/lib/receiver.js +36 -6
  42. package/plugins/feedback/lib/sender.js +70 -6
  43. package/plugins/feedback/lib/shape.js +111 -6
  44. package/plugins/feedback/plugin.js +171 -17
  45. package/plugins/feedback/plugin.json +2 -2
  46. package/plugins/feedback/tests/api.test.js +125 -3
  47. package/plugins/feedback/tests/settings.test.js +59 -0
  48. package/plugins/feedback/tests/shape.test.js +64 -0
  49. package/plugins/free-tier.lock.json +73 -61
  50. package/plugins/mail-reader/CLAUDE.md +15 -4
  51. package/plugins/mail-reader/plugin.json +1 -1
  52. package/plugins/shopping-cart/CLAUDE.md +28 -2
  53. package/plugins/shopping-cart/admin/css/index.css +3 -0
  54. package/plugins/shopping-cart/admin/views/orders.js +42 -10
  55. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  56. package/plugins/shopping-cart/docs/guide.md +7 -3
  57. package/plugins/shopping-cart/lib/payments.js +15 -2
  58. package/plugins/shopping-cart/lib/refunds.js +51 -0
  59. package/plugins/shopping-cart/lib/sales.js +15 -8
  60. package/plugins/shopping-cart/lib/shop.js +38 -3
  61. package/plugins/shopping-cart/plugin.js +47 -19
  62. package/plugins/shopping-cart/plugin.json +2 -2
  63. package/plugins/shopping-cart/tests/api.test.js +52 -0
  64. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  65. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  66. package/plugins/support/CLAUDE.md +51 -0
  67. package/plugins/support/admin/css/index.css +16 -1
  68. package/plugins/support/admin/lib/kit.js +192 -8
  69. package/plugins/support/admin/lib/settings.js +123 -0
  70. package/plugins/support/admin/views/desk.js +55 -12
  71. package/plugins/support/admin/views/inbox.js +17 -4
  72. package/plugins/support/admin/views/support.js +4 -1
  73. package/plugins/support/docs/guide.md +7 -9
  74. package/plugins/support/lib/attachments.js +133 -0
  75. package/plugins/support/lib/cache.js +43 -0
  76. package/plugins/support/lib/receiver.js +45 -10
  77. package/plugins/support/lib/sender.js +81 -10
  78. package/plugins/support/lib/shape.js +153 -12
  79. package/plugins/support/plugin.js +159 -29
  80. package/plugins/support/plugin.json +2 -2
  81. package/plugins/support/tests/api.test.js +125 -3
  82. package/plugins/support/tests/settings.test.js +68 -0
  83. package/plugins/support/tests/shape.test.js +51 -1
  84. package/server/routes/outreach-public.js +54 -10
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools CSS v0.51.11
2
+ * Domma Tools CSS v0.51.12
3
3
  * Dynamic Object Manipulation & Modeling API
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-12T06:05:33.864Z
6
- * Commit: a53de2f
5
+ * Built: 2026-10-13T13:46:21.895Z
6
+ * Commit: ce9f5f1
7
7
  */
8
8
 
9
9
  /*!
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools v0.51.11
2
+ * Domma Tools v0.51.12
3
3
  * Developer tools: Theme Roller & Page Roller
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-12T06:05:20.912Z
6
- * Commit: a53de2f
5
+ * Built: 2026-10-13T13:46:07.193Z
6
+ * Commit: ce9f5f1
7
7
  *
8
8
  * Requires: domma.min.js
9
9
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.36",
3
+ "version": "1.6.38",
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",
@@ -84,7 +84,7 @@
84
84
  "@fastify/rate-limit": "^10.3.0",
85
85
  "@fastify/static": "9.1.1",
86
86
  "bcryptjs": "^3.0.3",
87
- "domma-js": "0.51.11",
87
+ "domma-js": "0.51.12",
88
88
  "dotenv": "^17.2.3",
89
89
  "fastify": "5.8.5",
90
90
  "gray-matter": "^4.0.3",
@@ -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 = '';
@@ -20,7 +20,8 @@ Public-facing blog for the CMS. Supports posts with categories, tags, and option
20
20
  - `blocks/*.html|css` - the shipped layouts, copied into content/blocks/ once
21
21
  - `public/blog.js`, `public/blog.css` - the browser half (listing, right-click menu, post extras)
22
22
  - `public/samples/*.svg` - sample covers
23
- - `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`
23
+ - `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`, `tests/cache.test.js`
24
+ - `lib/cache.js` - PURE: the 30 s read cache (forgotten on every write route)
24
25
  - `admin/views/blog.js` - post list (KPIs + status/category/search filters)
25
26
  - `admin/views/post-editor.js` - create / edit a post
26
27
  - `admin/views/categories.js`, `admin/views/comments.js`, `admin/views/settings.js`
@@ -329,3 +330,12 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
329
330
  tested), needs a plausible email, and refuses a post that does not exist or is not visible (404) - each of those
330
331
  used to raise a staff notification.
331
332
  - `ASSET_V` / the preview `assetV` are 1.9.6 (they were 1.6.x while the plugin was 1.9.5 - keep them with the version).
333
+
334
+ ## 1.9.7 (2026-10-13 Tool sweep - caching)
335
+
336
+ - **Whole-collection reads are cached 30 s** (`lib/cache.js`, pure, `tests/cache.test.js`): `loadEntries()` in
337
+ lib/page.js (public pages, shortcodes, the sitemap source, the `/comments/pending-count` badge) and
338
+ `resolveAuthorName()` (one user lookup per author per window). The promise is kept, so concurrent misses share one
339
+ read. **Every non-GET route of this plugin forgets it in an `onSend` hook** - before the writer's response leaves -
340
+ so the admin's own next read, and the public POST /comments, are fresh. The TTL only covers writes made outside
341
+ the plugin (Collections tool, Blog Pro). Callers share the cached array: filter/sort copies, never mutate it.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A short memory for whole-collection reads.
3
+ *
4
+ * Every public page view and every sidebar badge poll (once a minute from
5
+ * every open admin tab) read ALL posts, categories and comments from the
6
+ * adapter. The data changes only when someone writes, so a read is kept for a
7
+ * little while and every write route of this plugin throws the memory away
8
+ * (`forget()`, from an onSend hook in plugin.js - before the writer's own
9
+ * response leaves, so their next read is fresh). The TTL is the safety net
10
+ * for writes this plugin never sees (the Collections tool, a Pro take-over).
11
+ *
12
+ * PURE - no I/O of its own; the loader is handed in. Tested in tests/cache.test.js.
13
+ *
14
+ * @module blog/lib/cache
15
+ */
16
+
17
+ export const CACHE_MS = 30_000;
18
+
19
+ /**
20
+ * @param {{ttl?: number, clock?: () => number}} [opts]
21
+ * @returns {{get: (key: string, load: () => Promise<any>) => Promise<any>, forget: () => void, size: number}}
22
+ */
23
+ export function createCache({ttl = CACHE_MS, clock = Date.now} = {}) {
24
+ // key -> {at, value: Promise}. The PROMISE is kept, not the result, so
25
+ // concurrent misses share one read, and a write landing while a read is in
26
+ // flight clears the map before that read can be stored - nothing stale
27
+ // survives a forget().
28
+ const kept = new Map();
29
+ return {
30
+ get(key, load) {
31
+ const hit = kept.get(key);
32
+ if (hit && clock() - hit.at < ttl) return hit.value;
33
+ const value = Promise.resolve().then(load);
34
+ const entry = {at: clock(), value};
35
+ kept.set(key, entry);
36
+ // A failed read is not remembered: the next caller tries again.
37
+ value.catch(() => { if (kept.get(key) === entry) kept.delete(key); });
38
+ return value;
39
+ },
40
+ forget() { kept.clear(); },
41
+ get size() { return kept.size; }
42
+ };
43
+ }