domma-cms 1.6.35 → 1.6.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/compose-window.js +4 -0
  3. package/plugins/_lib/admin/mail/mail.css +1 -1
  4. package/plugins/_lib/admin/mail/reader-view.js +15 -6
  5. package/plugins/_lib/call/ice.js +5 -2
  6. package/plugins/_lib/call/rooms.js +9 -3
  7. package/plugins/_lib/call/routes.js +7 -4
  8. package/plugins/_lib/mail/compose.js +7 -2
  9. package/plugins/_lib/mail/defaults.js +12 -0
  10. package/plugins/_lib/mail/hostGuard.js +86 -0
  11. package/plugins/_lib/mail/message-action.js +56 -0
  12. package/plugins/_lib/mail/readRoutes.js +20 -1
  13. package/plugins/_lib/mail/scheduler.js +2 -0
  14. package/plugins/_lib/mail/send.js +12 -3
  15. package/plugins/_lib/mail/store.js +16 -6
  16. package/plugins/_lib/outreach/index.js +1 -1
  17. package/plugins/_lib/outreach/ledger.js +10 -3
  18. package/plugins/_lib/outreach/sending.js +9 -3
  19. package/plugins/_lib/outreach/suppression.js +14 -3
  20. package/plugins/_lib/outreach/unsubscribe.js +13 -0
  21. package/plugins/_lib/public/call/call.js +3 -1
  22. package/plugins/blog/CLAUDE.md +11 -1
  23. package/plugins/blog/docs/guide.md +1 -1
  24. package/plugins/blog/lib/page.js +32 -0
  25. package/plugins/blog/plugin.js +58 -16
  26. package/plugins/blog/plugin.json +2 -2
  27. package/plugins/blog/plugin.public.js +5 -4
  28. package/plugins/blog/tests/public.test.js +30 -1
  29. package/plugins/booking/CLAUDE.md +4 -1
  30. package/plugins/booking/docs/guide.md +1 -1
  31. package/plugins/booking/plugin.js +16 -21
  32. package/plugins/booking/plugin.json +2 -2
  33. package/plugins/booking/tests/api.test.js +26 -1
  34. package/plugins/feedback/CLAUDE.md +7 -2
  35. package/plugins/feedback/admin/views/feedback.js +1 -1
  36. package/plugins/feedback/lib/receiver.js +1 -1
  37. package/plugins/feedback/plugin.json +3 -3
  38. package/plugins/free-tier.lock.json +42 -41
  39. package/plugins/mail-reader/CLAUDE.md +6 -0
  40. package/plugins/mail-reader/docs/guide.md +1 -0
  41. package/plugins/mail-reader/plugin.json +2 -2
  42. package/plugins/shopping-cart/CLAUDE.md +17 -1
  43. package/plugins/shopping-cart/docs/guide.md +1 -1
  44. package/plugins/shopping-cart/lib/accounts.js +8 -3
  45. package/plugins/shopping-cart/lib/payments.js +7 -4
  46. package/plugins/shopping-cart/lib/shop.js +8 -3
  47. package/plugins/shopping-cart/plugin.js +77 -41
  48. package/plugins/shopping-cart/plugin.json +2 -2
  49. package/plugins/shopping-cart/public/account.js +2 -2
  50. package/plugins/shopping-cart/tests/accounts.test.js +10 -1
  51. package/plugins/shopping-cart/tests/api.test.js +23 -0
  52. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  53. package/plugins/support/CLAUDE.md +6 -0
  54. package/plugins/support/admin/views/support.js +1 -1
  55. package/plugins/support/docs/guide.md +90 -0
  56. package/plugins/support/lib/receiver.js +1 -1
  57. package/plugins/support/plugin.json +3 -3
  58. package/server/routes/api/analytics.js +32 -0
  59. package/server/routes/api/collections.js +148 -15
  60. package/server/routes/outreach-public.js +54 -10
  61. package/server/server.js +5 -1
  62. package/server/services/authChecks.js +8 -0
  63. package/server/services/collectionAccess.js +191 -0
  64. package/server/services/collections.js +8 -2
  65. package/server/services/request-log.js +70 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.35",
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
@@ -6,12 +6,15 @@
6
6
  * {
7
7
  * "stun": ["stun:stun.l.google.com:19302"],
8
8
  * "turn": {"urls": ["turn:turn.example.org:3478?transport=udp", "turn:turn.example.org:3478?transport=tcp"],
9
- * "secret": "<coturn static-auth-secret>", "ttlHours": 12}
9
+ * "secret": "<coturn static-auth-secret>", "ttlHours": 4}
10
10
  * }
11
11
  *
12
12
  * TURN credentials are coturn's time-limited kind (use-auth-secret): the
13
13
  * username is "<expiry>:<label>" and the password an HMAC of it, so no
14
14
  * account is ever stored and a copied credential stops working on its own.
15
+ * The default life is 4 hours (was 12): it is made when a person is let in,
16
+ * and a call rarely runs longer; a shorter one limits what a leaked credential
17
+ * is worth as a relay.
15
18
  *
16
19
  * @module _lib/call/ice
17
20
  */
@@ -32,7 +35,7 @@ export function readCallConfig(root = process.cwd()) {
32
35
  const stun = list(raw?.stun);
33
36
  const turnUrls = list(raw?.turn?.urls);
34
37
  const secret = typeof raw?.turn?.secret === 'string' ? raw.turn.secret : '';
35
- const ttlHours = Math.max(1, Math.min(48, Number(raw?.turn?.ttlHours) || 12));
38
+ const ttlHours = Math.max(1, Math.min(48, Number(raw?.turn?.ttlHours) || 4));
36
39
  return {stun: stun.length ? stun : DEFAULT_STUN, turn: turnUrls.length && secret ? {urls: turnUrls, secret, ttlHours} : null};
37
40
  } catch {
38
41
  return {stun: DEFAULT_STUN, turn: null};
@@ -132,8 +132,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
132
132
  const item = quiet ? undefined : system(room, person, 'joined', `${person.name} joined`);
133
133
  tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person), ...(item && {item})});
134
134
  // `chat` is the history so far (their own "joined" line included), so a late joiner sees what was said.
135
+ // The ICE servers (TURN credentials) come HERE, not in the join reply: a guest still waiting, or turned
136
+ // away, never holds a relay credential. Made at admission, so its life starts now.
137
+ const ice = typeof person.ice === 'function' ? person.ice() : person.ice;
135
138
  push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording,
136
- chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c}))});
139
+ chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c})), ...(ice ? {iceServers: ice} : {})});
137
140
  tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
138
141
  }
139
142
 
@@ -149,9 +152,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
149
152
  * `canInvite`: this person may use the Tool's "Add people" (routes.js decides; see member()), with
150
153
  * `inviteOptions` (what of it). `seeHistory: false`: when let in they get only the chat from their own
151
154
  * "joined" line on - not what was said before (someone added to a conversation's call is not shown it).
155
+ * `ice`: the ICE servers for this person (an array, or a function making them) - handed over in their
156
+ * `admitted` event, never before: a waiting or turned-away guest holds no TURN credential.
152
157
  */
153
158
  join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = '', history, canInvite = false,
154
- inviteOptions = null, seeHistory = true}) {
159
+ inviteOptions = null, seeHistory = true, ice = null}) {
155
160
  let room = rooms.get(key);
156
161
  if (!room) {
157
162
  const seeded = (Array.isArray(history) ? history : []).map(cleanItem).filter(Boolean).slice(-HISTORY_MAX);
@@ -177,7 +182,8 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
177
182
  if (admitted(room).length >= room.max && (host || autoAdmit)) throw new CallError('full', `This call is full (${room.max} people).`);
178
183
  const id = newId(9);
179
184
  const person = {id, secret: newId(24), identity: String(identity || ''), who: whoOf(identity, id), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
180
- host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()};
185
+ host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now(),
186
+ ice: ice || null};
181
187
  room.people.set(person.id, person);
182
188
  if (host || autoAdmit) {
183
189
  // admit() also tells every host - this one included - who is already waiting.
@@ -4,7 +4,7 @@
4
4
  * nothing: only a Tool that mounts this, and says who may join which room,
5
5
  * has calls.
6
6
  *
7
- * POST {prefix}/:room/join {name, ...credentials} -> {id, secret, host, state, iceServers, ...}
7
+ * POST {prefix}/:room/join {name, ...credentials} -> {id, secret, host, state, ...} (ICE servers come in the `admitted` event)
8
8
  * GET {prefix}/:room/poll?since=N -> {events, state} (long-poll, 25 s)
9
9
  * POST {prefix}/:room/signal {to, data}
10
10
  * POST {prefix}/:room/chat {text}
@@ -118,12 +118,15 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
118
118
  try {
119
119
  const canInvite = inviting && (typeof r.mayInvite === 'boolean' ? r.mayInvite : r.host === true);
120
120
  const inviteOptions = canInvite ? inviteOptionsOf(r) : null;
121
+ // The ICE servers (a TURN credential good for ttlHours, when configured) go in this person's `admitted`
122
+ // event, made at admission - not in this reply, which a guest still waiting, or about to be turned away,
123
+ // also gets. Before, anyone holding a call link had a relay credential for the TTL.
124
+ const hostname = request.hostname;
121
125
  const joined = rooms.join(key, {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
122
126
  recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || '', history, canInvite,
123
- inviteOptions, seeHistory: r.history !== false});
127
+ inviteOptions, seeHistory: r.history !== false, ice: () => iceServers(config(), hostname)});
124
128
  // What "Add people" offers this person: {search, typed, copyLink, external} (invite.js inviteOptionsOf).
125
- return reply.send({...joined, iceServers: iceServers(config(), request.hostname), closesAt: r.closesAt || null,
126
- ...(inviteOptions ? {inviteOptions} : {})});
129
+ return reply.send({...joined, closesAt: r.closesAt || null, ...(inviteOptions ? {inviteOptions} : {})});
127
130
  } catch (err) { return fail(reply, err); }
128
131
  });
129
132
 
@@ -10,6 +10,7 @@
10
10
  * @module _lib/mail/compose
11
11
  */
12
12
  import {readPriority} from './priority.js';
13
+ import {normaliseMessageSource, readMessageSource} from './message-action.js';
13
14
 
14
15
  /**
15
16
  * Addresses to reply to.
@@ -162,11 +163,13 @@ export function quoteHtml(message, html, text, sanitise) {
162
163
  * @param {{message: object, self: string, mode: 'reply'|'replyAll'|'forward', body: string}} options
163
164
  * @returns {object} a draft the compose window can open with
164
165
  */
165
- export function buildDraft({message, self, mode, body, html = null, sanitise = null}) {
166
+ export function buildDraft({message, self, mode, body, html = null, sanitise = null, sourceMessage = null}) {
166
167
  const htmlQuote = sanitise ? quoteHtml(message, html, body, sanitise) : null;
168
+ const source = normaliseMessageSource(sourceMessage);
167
169
 
168
170
  if (mode === 'forward') {
169
171
  return {
172
+ ...(source ? {sourceMessage: source} : {}),
170
173
  to: [],
171
174
  cc: [],
172
175
  subject: replySubject(message.subject, 'Fwd'),
@@ -185,6 +188,7 @@ export function buildDraft({message, self, mode, body, html = null, sanitise = n
185
188
  : {to: replyRecipients(message), cc: []};
186
189
 
187
190
  return {
191
+ ...(source ? {sourceMessage: source} : {}),
188
192
  to,
189
193
  cc,
190
194
  subject: replySubject(message.subject),
@@ -239,7 +243,8 @@ export function editableDraft(message, uid = null) {
239
243
  content: file.content ?? '',
240
244
  size: file.size ?? 0
241
245
  })),
242
- savedUid: uid
246
+ savedUid: uid,
247
+ sourceMessage: readMessageSource(message.headers)
243
248
  };
244
249
  }
245
250
 
@@ -40,6 +40,18 @@ export default {
40
40
  */
41
41
  allowInsecureTLS: false,
42
42
 
43
+ /**
44
+ * Let a mailbox or outgoing server be on a private, loopback or link-local
45
+ * address.
46
+ *
47
+ * Off, deliberately: "test these details" makes this server open TCP to the
48
+ * host typed in and report refused / no answer / unknown - a port scanner of
49
+ * the server's own network behind the Email permission. The site's own
50
+ * configured outgoing server (config/site.json smtp.host) is always allowed.
51
+ * Turn on only for a site whose mail server really is on its LAN.
52
+ */
53
+ allowPrivateMailHosts: false,
54
+
43
55
  /** Largest inline image embedded into a rendered body, in bytes. */
44
56
  inlineImageMaxBytes: 2_000_000,
45
57
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * May this server open a connection to that mail host?
3
+ *
4
+ * `/accounts/test`, saving a mailbox and the outgoing-server routes make the
5
+ * CMS open TCP to whatever host:port the caller typed, and `describeError()`
6
+ * answers "refused", "did not respond" or "could not be resolved" - a port
7
+ * scanner of the server's own network behind the `email` permission
8
+ * (127.0.0.1:27018 is the site's Mongo, :8192 the manager). So a private,
9
+ * loopback or link-local host is refused unless it is the host the site's own
10
+ * outgoing mail already goes to (config/site.json smtp.host - the LAN relay
11
+ * the admin chose) or `allowPrivateMailHosts` is set for a site whose mail
12
+ * server really is on its LAN.
13
+ *
14
+ * The pure half is `isPrivateAddress` / `isLocalName` in unsubscribe.js; this
15
+ * module adds the lookup, which the tests inject.
16
+ *
17
+ * @module _lib/mail/hostGuard
18
+ */
19
+ import dns from 'node:dns/promises';
20
+ import net from 'node:net';
21
+ import {isLocalName, isPrivateAddress} from './unsubscribe.js';
22
+
23
+ const REFUSED = 'That server is on this machine\'s own network, so the site will not connect to it. '
24
+ + 'Use the mail server\'s public name, or ask the site administrator to allow private mail hosts.';
25
+
26
+ /**
27
+ * Normalise a typed host for comparison: case, trailing dot, IPv6 brackets.
28
+ *
29
+ * @param {string} host
30
+ * @returns {string}
31
+ */
32
+ export function normaliseHost(host) {
33
+ return String(host ?? '').trim().toLowerCase().replace(/\.$/, '').replace(/^\[(.*)\]$/, '$1');
34
+ }
35
+
36
+ /**
37
+ * Check one host.
38
+ *
39
+ * @param {string} host
40
+ * @param {object} [options]
41
+ * @param {boolean} [options.allowPrivate] - the site's `allowPrivateMailHosts` setting
42
+ * @param {string} [options.ownSmtpHost] - the site's configured outgoing server; always allowed
43
+ * @param {(host: string) => Promise<{address: string}[]>} [options.lookup] - DNS, injectable
44
+ * @returns {Promise<{ok: true} | {ok: false, error: string}>}
45
+ */
46
+ export async function checkMailHost(host, {allowPrivate = false, ownSmtpHost = '', lookup} = {}) {
47
+ const name = normaliseHost(host);
48
+ if (!name) return {ok: false, error: 'A server is required.'};
49
+ if (allowPrivate) return {ok: true};
50
+ if (ownSmtpHost && normaliseHost(ownSmtpHost) === name) return {ok: true};
51
+
52
+ if (net.isIP(name)) {
53
+ return isPrivateAddress(name) ? {ok: false, error: REFUSED} : {ok: true};
54
+ }
55
+ if (isLocalName(name)) return {ok: false, error: REFUSED};
56
+
57
+ let answers;
58
+ try {
59
+ answers = await (lookup ?? ((h) => dns.lookup(h, {all: true})))(name);
60
+ } catch {
61
+ // Unresolvable: let the connection attempt say so in its own words.
62
+ // The check is about where a name points, not whether it exists.
63
+ return {ok: true};
64
+ }
65
+ // Every answer must be public: a name with one private A record among
66
+ // public ones is exactly the rebinding trick the check exists for.
67
+ const addresses = (answers ?? []).map(a => (typeof a === 'string' ? a : a?.address)).filter(Boolean);
68
+ if (addresses.some(isPrivateAddress)) return {ok: false, error: REFUSED};
69
+ return {ok: true};
70
+ }
71
+
72
+ /**
73
+ * The site's own outgoing server, from config/site.json `smtp.host` (what
74
+ * core `createTransport` sends with), read through a getter so a test can
75
+ * hand in its own.
76
+ *
77
+ * @param {(section: string) => any} getConfig
78
+ * @returns {string}
79
+ */
80
+ export function siteSmtpHost(getConfig) {
81
+ try {
82
+ return String(getConfig('site')?.smtp?.host ?? '').trim();
83
+ } catch {
84
+ return '';
85
+ }
86
+ }
@@ -0,0 +1,56 @@
1
+ /** Remember replies and forwards on the original message after delivery. */
2
+ export const SOURCE_HEADER = 'X-Domma-Message-Source';
3
+
4
+ export function normaliseMessageSource(source) {
5
+ if (!source || !['reply', 'forward'].includes(source.action)) return null;
6
+ const uid = Number(source.uid);
7
+ const folder = typeof source.folder === 'string' ? source.folder : '';
8
+ const accountId = typeof source.accountId === 'string' ? source.accountId : '';
9
+ const messageId = typeof source.messageId === 'string' ? source.messageId : null;
10
+ if (!Number.isSafeInteger(uid) || uid < 1 || !folder || folder.length > 1024
11
+ || /[\r\n\0]/.test(folder) || !accountId || accountId.length > 128
12
+ || (messageId && (messageId.length > 1024 || /[\r\n\0]/.test(messageId)))) return null;
13
+ return {accountId, folder, uid, action: source.action, messageId};
14
+ }
15
+
16
+ export function readMessageSource(headers) {
17
+ const value = typeof headers?.get === 'function'
18
+ ? headers.get(SOURCE_HEADER.toLowerCase()) : headers?.[SOURCE_HEADER.toLowerCase()];
19
+ if (typeof value !== 'string' || value.length > 4096) return null;
20
+ try {
21
+ return normaliseMessageSource(JSON.parse(Buffer.from(value, 'base64url').toString('utf8')));
22
+ } catch { return null; }
23
+ }
24
+
25
+ /** Best effort: recording a delivered message must never turn it into a failed send. */
26
+ export async function recordMessageAction({source, account, pool, poolKey, connection, store = null}) {
27
+ source = normaliseMessageSource(source);
28
+ if (!source || source.accountId !== account?.id) return null;
29
+ const flag = source.action === 'reply' ? '\\Answered' : '$Forwarded';
30
+ let flags = null;
31
+ let error = null;
32
+ let sourceVerified = false;
33
+ try {
34
+ flags = await pool.withMailbox(poolKey, connection, source.folder, async client => {
35
+ const original = await client.fetchOne(source.uid, {envelope: true, flags: true}, {uid: true});
36
+ if (!original || (source.messageId && original.envelope?.messageId !== source.messageId)) {
37
+ throw new Error('The original message is no longer in that folder.');
38
+ }
39
+ sourceVerified = true;
40
+ await client.messageFlagsAdd({uid: String(source.uid)}, [flag], {uid: true});
41
+ const after = await client.fetchOne(source.uid, {flags: true}, {uid: true});
42
+ const recorded = [...(after?.flags ?? [])];
43
+ if (!recorded.includes(flag)) throw new Error('The mail server did not keep the message marker.');
44
+ return recorded;
45
+ }, {writable: true});
46
+ } catch (err) { error = err.message; }
47
+
48
+ let mirrored = false;
49
+ if (store && sourceVerified) {
50
+ try {
51
+ if (flags) await store.setFlags(account.id, source.folder, [{uid: source.uid, flags}]);
52
+ mirrored = await store.setMessageAction(account.id, source.folder, source.uid, source.action);
53
+ } catch (err) { error ??= err.message; }
54
+ }
55
+ return {...source, flags, recorded: Boolean(flags || mirrored), error: flags || mirrored ? null : error};
56
+ }
@@ -28,6 +28,8 @@
28
28
  import {simpleParser} from 'mailparser';
29
29
 
30
30
  import {listUsers} from '../../../server/services/users.js';
31
+ import {getConfig} from '../../../server/config.js';
32
+ import {checkMailHost, siteSmtpHost} from './hostGuard.js';
31
33
  import * as accounts from './accounts.js';
32
34
  import * as bodyTokens from './bodyTokens.js';
33
35
  import {buildDiagnostics} from './diagnostics.js';
@@ -207,6 +209,11 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
207
209
  });
208
210
  }
209
211
 
212
+ /** Where this server may be asked to connect - see hostGuard.js. */
213
+ const guardHost = (host) => checkMailHost(host, {allowPrivate: config.allowPrivateMailHosts === true, ownSmtpHost: siteSmtpHost(getConfig)});
214
+ // Each test is a connection attempt whose answer says what is listening: a few a minute per address is plenty.
215
+ const testLimit = {config: {rateLimit: {max: 10, timeWindow: '1 minute'}}};
216
+
210
217
  /** GET /accounts - every mailbox this user owns, without passwords. */
211
218
  fastify.get('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
212
219
  const uid = userId(request);
@@ -221,6 +228,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
221
228
 
222
229
  const result = accounts.validate(request.body ?? {}, null);
223
230
  if (!result.ok) return reply.code(400).send({error: result.error});
231
+ const where = await guardHost(result.value.host);
232
+ if (!where.ok) return reply.code(400).send({error: where.error});
224
233
 
225
234
  const saved = await accounts.saveAccount(uid, null, result.value);
226
235
  return {account: accounts.toPublic(saved)};
@@ -236,6 +245,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
236
245
 
237
246
  const result = accounts.validate(request.body ?? {}, existing);
238
247
  if (!result.ok) return reply.code(400).send({error: result.error});
248
+ const where = await guardHost(result.value.host);
249
+ if (!where.ok) return reply.code(400).send({error: where.error});
239
250
 
240
251
  const saved = await accounts.saveAccount(uid, request.params.id, result.value);
241
252
  // The pooled connection was opened with the old details.
@@ -271,7 +282,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
271
282
  * password field is blank and `id` names an existing mailbox, the stored
272
283
  * password is used - otherwise editing a hostname would mean retyping it.
273
284
  */
274
- fastify.post('/accounts/test', {preHandler: [authenticate]}, async (request, reply) => {
285
+ fastify.post('/accounts/test', {preHandler: [authenticate], ...testLimit}, async (request, reply) => {
275
286
  const uid = userId(request);
276
287
  if (!uid) return reply.code(401).send({error: 'Authentication required.'});
277
288
 
@@ -281,6 +292,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
281
292
 
282
293
  const result = accounts.validate(body, existing);
283
294
  if (!result.ok) return reply.code(400).send({error: result.error});
295
+ const where = await guardHost(result.value.host);
296
+ if (!where.ok) return reply.code(400).send({error: where.error});
284
297
 
285
298
  let pass = result.value.password;
286
299
  if (!pass) {
@@ -711,6 +724,12 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
711
724
  reply.code(404).send({error: 'That message is no longer in this folder.'});
712
725
  return null;
713
726
  }
727
+ // The same ceiling as /messages/:uid and /body: without it a hostile
728
+ // 200 MB message cost a 200 MB buffer plus mailparser output per click.
729
+ if (fetched.source.length > config.maxMessageBytes) {
730
+ reply.code(413).send({error: 'That message is too large to open its attachments.'});
731
+ return null;
732
+ }
714
733
 
715
734
  const parsed = await simpleParser(fetched.source);
716
735
  const attachment = parsed.attachments?.[index];
@@ -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,