domma-cms 0.116.5 → 0.116.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +59 -2
- package/plugins/_lib/mail/accounts.js +29 -2
- package/plugins/_lib/mail/readRoutes.js +10 -0
- package/server/services/adapters/FileAdapter.js +42 -22
- package/server/services/adapters/fileLock.js +63 -0
- package/server/services/userProfiles.js +20 -6
package/package.json
CHANGED
|
@@ -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-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)}
|
|
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)}
|
|
@@ -923,6 +923,16 @@ return {
|
|
|
923
923
|
* @returns {void}
|
|
924
924
|
*/
|
|
925
925
|
function renderMailboxSection(container) {
|
|
926
|
+
container.appendChild(mailboxSectionBody());
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* The mailbox rows and the Add button, rebuilt whole when the
|
|
931
|
+
* default changes so the order matches the folder pane.
|
|
932
|
+
*
|
|
933
|
+
* @returns {HTMLElement}
|
|
934
|
+
*/
|
|
935
|
+
function mailboxSectionBody() {
|
|
926
936
|
const body = document.createElement('div');
|
|
927
937
|
body.className = 'flex flex-col gap-2';
|
|
928
938
|
|
|
@@ -942,6 +952,12 @@ return {
|
|
|
942
952
|
const name = document.createElement('div');
|
|
943
953
|
name.className = 'font-semibold text-sm';
|
|
944
954
|
name.textContent = box.label;
|
|
955
|
+
if (box.isDefault && mailboxes.length > 1) {
|
|
956
|
+
const badge = document.createElement('span');
|
|
957
|
+
badge.className = 'badge badge-primary mail-default-badge';
|
|
958
|
+
badge.textContent = 'Default';
|
|
959
|
+
name.appendChild(badge);
|
|
960
|
+
}
|
|
945
961
|
const sub = document.createElement('div');
|
|
946
962
|
sub.className = 'text-xs text-muted mail-box-sub';
|
|
947
963
|
sub.textContent = `${box.username} \u00b7 ${box.host}:${box.port}${box.secure ? '' : ' (no TLS)'}`;
|
|
@@ -971,6 +987,30 @@ return {
|
|
|
971
987
|
}
|
|
972
988
|
});
|
|
973
989
|
|
|
990
|
+
// Only worth offering when there is a choice to make.
|
|
991
|
+
if (mailboxes.length > 1) {
|
|
992
|
+
const defaultBtn = document.createElement('button');
|
|
993
|
+
defaultBtn.className = 'btn btn-sm btn-ghost';
|
|
994
|
+
defaultBtn.textContent = box.isDefault ? 'Unset default' : 'Make default';
|
|
995
|
+
defaultBtn.title = box.isDefault
|
|
996
|
+
? 'Stop opening with this mailbox'
|
|
997
|
+
: 'List this mailbox first and open with it';
|
|
998
|
+
defaultBtn.addEventListener('click', async () => {
|
|
999
|
+
try {
|
|
1000
|
+
await api(`${BASE}/accounts/${encodeURIComponent(box.id)}/default`, 'PUT',
|
|
1001
|
+
{isDefault: !box.isDefault});
|
|
1002
|
+
mailboxes = (await api(`${BASE}/accounts`)).accounts;
|
|
1003
|
+
renderFolders();
|
|
1004
|
+
body.replaceWith(mailboxSectionBody());
|
|
1005
|
+
E.toast(box.isDefault ? 'Default cleared.' : `"${box.label}" is now the default.`,
|
|
1006
|
+
{type: 'success'});
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
E.toast(err.message, {type: 'error'});
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
row.appendChild(defaultBtn);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
974
1014
|
row.appendChild(editBtn);
|
|
975
1015
|
row.appendChild(removeBtn);
|
|
976
1016
|
body.appendChild(row);
|
|
@@ -983,7 +1023,7 @@ return {
|
|
|
983
1023
|
addBtn.addEventListener('click', () => openMailboxForm(null));
|
|
984
1024
|
body.appendChild(addBtn);
|
|
985
1025
|
|
|
986
|
-
|
|
1026
|
+
return body;
|
|
987
1027
|
}
|
|
988
1028
|
|
|
989
1029
|
/**
|
|
@@ -1256,8 +1296,21 @@ return {
|
|
|
1256
1296
|
// One handler, two gestures: the chevron folds, the rest of
|
|
1257
1297
|
// the row opens the folder. A nested <button> inside a
|
|
1258
1298
|
// <button> would be invalid markup, so the target decides.
|
|
1299
|
+
// A double-click on a parent folds it too. Read from the
|
|
1300
|
+
// click count rather than a dblclick listener: the first
|
|
1301
|
+
// click opens the folder, which redraws this pane, and a
|
|
1302
|
+
// dblclick needs both clicks to land on the same element.
|
|
1259
1303
|
btn.addEventListener('click', event => {
|
|
1260
|
-
|
|
1304
|
+
const onToggle = !!event.target.closest('.mail-folder-toggle');
|
|
1305
|
+
if (event.detail >= 2) {
|
|
1306
|
+
// The first click already folded it when it hit
|
|
1307
|
+
// the chevron or a level that cannot be opened.
|
|
1308
|
+
if (folder.children.length && folder.selectable && !onToggle && event.detail === 2) {
|
|
1309
|
+
toggleFolder(folder.path);
|
|
1310
|
+
}
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
if (folder.children.length && onToggle) {
|
|
1261
1314
|
toggleFolder(folder.path);
|
|
1262
1315
|
return;
|
|
1263
1316
|
}
|
|
@@ -1303,6 +1356,10 @@ return {
|
|
|
1303
1356
|
* @returns {string|null}
|
|
1304
1357
|
*/
|
|
1305
1358
|
function preferredAccount() {
|
|
1359
|
+
// A chosen default beats the last one looked at - that is what
|
|
1360
|
+
// choosing one means.
|
|
1361
|
+
const chosen = mailboxes.find(b => b.isDefault === true);
|
|
1362
|
+
if (chosen) return chosen.id;
|
|
1306
1363
|
let saved = null;
|
|
1307
1364
|
try {
|
|
1308
1365
|
saved = S.get(ACCOUNT_KEY);
|
|
@@ -773,7 +773,7 @@ export function validate(input, existing) {
|
|
|
773
773
|
}
|
|
774
774
|
|
|
775
775
|
/**
|
|
776
|
-
* Every mailbox belonging to one user, oldest first.
|
|
776
|
+
* Every mailbox belonging to one user: the default first, then oldest first.
|
|
777
777
|
*
|
|
778
778
|
* @param {string} userId
|
|
779
779
|
* @returns {Promise<object[]>}
|
|
@@ -782,7 +782,34 @@ export async function listAccounts(userId) {
|
|
|
782
782
|
const all = await readAll();
|
|
783
783
|
return all
|
|
784
784
|
.filter(a => a.userId === userId)
|
|
785
|
-
.sort((a, b) =>
|
|
785
|
+
.sort((a, b) => (b.isDefault === true) - (a.isDefault === true)
|
|
786
|
+
|| String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Make one mailbox this user's default, or clear the default.
|
|
791
|
+
*
|
|
792
|
+
* At most one per user, so setting it takes it off every other mailbox they
|
|
793
|
+
* own. The default is listed first and is the one the reader opens with.
|
|
794
|
+
*
|
|
795
|
+
* @param {string} userId
|
|
796
|
+
* @param {string} accountId
|
|
797
|
+
* @param {boolean} isDefault - false clears it from this mailbox only
|
|
798
|
+
* @returns {Promise<object|null>} the stored record, or null if not theirs
|
|
799
|
+
*/
|
|
800
|
+
export async function setDefaultAccount(userId, accountId, isDefault) {
|
|
801
|
+
const all = await readAll();
|
|
802
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
803
|
+
if (index === -1) return null;
|
|
804
|
+
|
|
805
|
+
const now = new Date().toISOString();
|
|
806
|
+
for (let i = 0; i < all.length; i++) {
|
|
807
|
+
if (all[i].userId !== userId) continue;
|
|
808
|
+
const want = i === index ? !!isDefault : (isDefault ? false : all[i].isDefault === true);
|
|
809
|
+
if ((all[i].isDefault === true) !== want) all[i] = {...all[i], isDefault: want, updatedAt: now};
|
|
810
|
+
}
|
|
811
|
+
await writeAll(all);
|
|
812
|
+
return all[index];
|
|
786
813
|
}
|
|
787
814
|
|
|
788
815
|
/**
|
|
@@ -243,6 +243,16 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
243
243
|
return {account: accounts.toPublic(saved)};
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
/** PUT /accounts/:id/default - make it the default mailbox ({isDefault: false} clears it). */
|
|
247
|
+
fastify.put('/accounts/:id/default', {preHandler: [authenticate]}, async (request, reply) => {
|
|
248
|
+
const uid = userId(request);
|
|
249
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
250
|
+
|
|
251
|
+
const saved = await accounts.setDefaultAccount(uid, request.params.id, request.body?.isDefault !== false);
|
|
252
|
+
if (!saved) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
253
|
+
return {account: accounts.toPublic(saved)};
|
|
254
|
+
});
|
|
255
|
+
|
|
246
256
|
/** DELETE /accounts/:id - forget a mailbox and its password. */
|
|
247
257
|
fastify.delete('/accounts/:id', {preHandler: [authenticate]}, async (request, reply) => {
|
|
248
258
|
const uid = userId(request);
|
|
@@ -22,6 +22,7 @@ import fs from 'fs/promises';
|
|
|
22
22
|
import path from 'path';
|
|
23
23
|
import { config } from '../../config.js';
|
|
24
24
|
import { applyFilter } from '../filterEngine.js';
|
|
25
|
+
import { withFileLock, writeFileAtomic } from './fileLock.js';
|
|
25
26
|
|
|
26
27
|
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
27
28
|
|
|
@@ -43,9 +44,16 @@ async function read(slug) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
async function write(slug, entries) {
|
|
46
|
-
await
|
|
47
|
+
await writeFileAtomic(dataPath(slug), JSON.stringify(entries, null, 2) + '\n');
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Every change is read-modify-write of the whole file, so changes to one
|
|
52
|
+
* collection run one at a time (./fileLock.js) - overlapping ones used to
|
|
53
|
+
* lose all but the last. Reads need no lock: writes are atomic renames.
|
|
54
|
+
*/
|
|
55
|
+
const locked = (slug, fn) => withFileLock(dataPath(slug), fn);
|
|
56
|
+
|
|
49
57
|
// ---------------------------------------------------------------------------
|
|
50
58
|
// Adapter implementation
|
|
51
59
|
// ---------------------------------------------------------------------------
|
|
@@ -125,10 +133,12 @@ export class FileAdapter {
|
|
|
125
133
|
* @returns {Promise<object>}
|
|
126
134
|
*/
|
|
127
135
|
async insert(slug, entry) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
return locked(slug, async () => {
|
|
137
|
+
const entries = await read(slug);
|
|
138
|
+
entries.push(entry);
|
|
139
|
+
await write(slug, entries);
|
|
140
|
+
return entry;
|
|
141
|
+
});
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
/**
|
|
@@ -141,12 +151,14 @@ export class FileAdapter {
|
|
|
141
151
|
* @throws {Error} If entry not found
|
|
142
152
|
*/
|
|
143
153
|
async update(slug, entryId, updated) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
return locked(slug, async () => {
|
|
155
|
+
const entries = await read(slug);
|
|
156
|
+
const idx = entries.findIndex(e => e.id === entryId);
|
|
157
|
+
if (idx === -1) throw new Error('Entry not found');
|
|
158
|
+
entries[idx] = updated;
|
|
159
|
+
await write(slug, entries);
|
|
160
|
+
return updated;
|
|
161
|
+
});
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
/**
|
|
@@ -158,11 +170,13 @@ export class FileAdapter {
|
|
|
158
170
|
* @throws {Error} If entry not found
|
|
159
171
|
*/
|
|
160
172
|
async remove(slug, entryId) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
return locked(slug, async () => {
|
|
174
|
+
const entries = await read(slug);
|
|
175
|
+
const idx = entries.findIndex(e => e.id === entryId);
|
|
176
|
+
if (idx === -1) throw new Error('Entry not found');
|
|
177
|
+
entries.splice(idx, 1);
|
|
178
|
+
await write(slug, entries);
|
|
179
|
+
});
|
|
166
180
|
}
|
|
167
181
|
|
|
168
182
|
/**
|
|
@@ -172,7 +186,7 @@ export class FileAdapter {
|
|
|
172
186
|
* @returns {Promise<void>}
|
|
173
187
|
*/
|
|
174
188
|
async clear(slug) {
|
|
175
|
-
|
|
189
|
+
return locked(slug, () => write(slug, []));
|
|
176
190
|
}
|
|
177
191
|
|
|
178
192
|
/**
|
|
@@ -193,10 +207,12 @@ export class FileAdapter {
|
|
|
193
207
|
* @returns {Promise<{ imported: number, skipped: number, errors: string[] }>}
|
|
194
208
|
*/
|
|
195
209
|
async insertMany(slug, entries) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
210
|
+
return locked(slug, async () => {
|
|
211
|
+
const existing = await read(slug);
|
|
212
|
+
existing.push(...entries);
|
|
213
|
+
await write(slug, existing);
|
|
214
|
+
return { imported: entries.length, skipped: 0, errors: [] };
|
|
215
|
+
});
|
|
200
216
|
}
|
|
201
217
|
|
|
202
218
|
/**
|
|
@@ -219,7 +235,7 @@ export class FileAdapter {
|
|
|
219
235
|
* @returns {Promise<void>}
|
|
220
236
|
*/
|
|
221
237
|
async replaceAll(slug, entries) {
|
|
222
|
-
|
|
238
|
+
return locked(slug, () => write(slug, entries));
|
|
223
239
|
}
|
|
224
240
|
|
|
225
241
|
/**
|
|
@@ -231,6 +247,10 @@ export class FileAdapter {
|
|
|
231
247
|
* @returns {Promise<string|null>} The backup's file name, or null if there was no data.json
|
|
232
248
|
*/
|
|
233
249
|
async archive(slug) {
|
|
250
|
+
return locked(slug, () => this.#archive(slug));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async #archive(slug) {
|
|
234
254
|
const from = dataPath(slug);
|
|
235
255
|
let to = `${from}.bak`;
|
|
236
256
|
try {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One writer at a time per JSON file, and no half-written file ever on disk.
|
|
3
|
+
*
|
|
4
|
+
* A collection's `data.json` is changed by reading the whole file, changing it
|
|
5
|
+
* in memory and writing it back. Two of those overlapping - two form
|
|
6
|
+
* submissions, two imports, a save during a migration - each read the same
|
|
7
|
+
* starting file, and the second write silently drops the first one's change.
|
|
8
|
+
* `withFileLock()` queues them per file, so each read sees the last write.
|
|
9
|
+
*
|
|
10
|
+
* `writeFileAtomic()` writes a temporary file beside the target and renames
|
|
11
|
+
* it over, so a crash mid-write leaves the old file whole rather than a
|
|
12
|
+
* truncated one that reads as "no entries".
|
|
13
|
+
*
|
|
14
|
+
* The queue is in-process: one site is one Node process, which is how every
|
|
15
|
+
* site runs. Anything else writing the same file must go through here too
|
|
16
|
+
* (userProfiles.js does).
|
|
17
|
+
*
|
|
18
|
+
* @module server/services/adapters/fileLock
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'fs/promises';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
|
|
23
|
+
/** file path -> the tail of its queue */
|
|
24
|
+
const queues = new Map();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run `fn` once every earlier call for the same file has finished (resolved
|
|
28
|
+
* or thrown). A failure is passed to this caller only; the queue goes on.
|
|
29
|
+
*
|
|
30
|
+
* @template T
|
|
31
|
+
* @param {string} file
|
|
32
|
+
* @param {() => Promise<T>} fn
|
|
33
|
+
* @returns {Promise<T>}
|
|
34
|
+
*/
|
|
35
|
+
export function withFileLock(file, fn) {
|
|
36
|
+
const key = path.resolve(file);
|
|
37
|
+
const prev = queues.get(key) ?? Promise.resolve();
|
|
38
|
+
const run = prev.then(fn, fn);
|
|
39
|
+
const tail = run.then(() => {}, () => {});
|
|
40
|
+
queues.set(key, tail);
|
|
41
|
+
tail.then(() => { if (queues.get(key) === tail) queues.delete(key); });
|
|
42
|
+
return run;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let seq = 0;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write `text` to `file` atomically: a temporary file in the same directory,
|
|
49
|
+
* then a rename over the target.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} file
|
|
52
|
+
* @param {string} text
|
|
53
|
+
*/
|
|
54
|
+
export async function writeFileAtomic(file, text) {
|
|
55
|
+
const tmp = `${file}.${process.pid}.${Date.now().toString(36)}${(seq++).toString(36)}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
await fs.writeFile(tmp, text, 'utf8');
|
|
58
|
+
await fs.rename(tmp, file);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
await fs.rm(tmp, {force: true}).catch(() => {});
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {config} from '../config.js';
|
|
9
|
+
import {withFileLock, writeFileAtomic} from './adapters/fileLock.js';
|
|
9
10
|
|
|
10
11
|
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
11
12
|
const SLUG = 'user-profiles';
|
|
@@ -54,9 +55,12 @@ async function readData() {
|
|
|
54
55
|
* @returns {Promise<void>}
|
|
55
56
|
*/
|
|
56
57
|
async function writeData(entries) {
|
|
57
|
-
await
|
|
58
|
+
await writeFileAtomic(DATA_PATH, JSON.stringify(entries, null, 2) + '\n');
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** Changes go one at a time, sharing the FileAdapter's queue for this file (adapters/fileLock.js). */
|
|
62
|
+
const locked = (fn) => withFileLock(DATA_PATH, fn);
|
|
63
|
+
|
|
60
64
|
// ---------------------------------------------------------------------------
|
|
61
65
|
// Public API
|
|
62
66
|
// ---------------------------------------------------------------------------
|
|
@@ -93,6 +97,10 @@ export async function seed() {
|
|
|
93
97
|
* @returns {Promise<void>}
|
|
94
98
|
*/
|
|
95
99
|
export async function ensureProfile(userId) {
|
|
100
|
+
return locked(() => ensureProfileUnlocked(userId));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function ensureProfileUnlocked(userId) {
|
|
96
104
|
const entries = await readData();
|
|
97
105
|
const exists = entries.some(e => e.id === userId);
|
|
98
106
|
if (!exists) {
|
|
@@ -125,6 +133,10 @@ export async function getProfile(userId) {
|
|
|
125
133
|
* @returns {Promise<object>} Updated profile entry
|
|
126
134
|
*/
|
|
127
135
|
export async function updateProfile(userId, data) {
|
|
136
|
+
return locked(() => updateProfileUnlocked(userId, data));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function updateProfileUnlocked(userId, data) {
|
|
128
140
|
const entries = await readData();
|
|
129
141
|
const index = entries.findIndex(e => e.id === userId);
|
|
130
142
|
|
|
@@ -155,11 +167,13 @@ export async function updateProfile(userId, data) {
|
|
|
155
167
|
* @returns {Promise<void>}
|
|
156
168
|
*/
|
|
157
169
|
export async function deleteProfile(userId) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
return locked(async () => {
|
|
171
|
+
const entries = await readData();
|
|
172
|
+
const filtered = entries.filter(e => e.id !== userId);
|
|
173
|
+
if (filtered.length !== entries.length) {
|
|
174
|
+
await writeData(filtered);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
163
177
|
}
|
|
164
178
|
|
|
165
179
|
/**
|