@bobfrankston/mailx 1.0.220 → 1.0.222
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/client/components/folder-tree.js +3 -0
- package/client/compose/compose.css +90 -0
- package/client/compose/editor.js +203 -11
- package/client/styles/components.css +7 -7
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +35 -12
- package/packages/mailx-imap/providers/gmail-api.d.ts +5 -1
- package/packages/mailx-imap/providers/gmail-api.js +26 -7
|
@@ -157,6 +157,7 @@ function renderNode(node, container, depth) {
|
|
|
157
157
|
const badge = document.createElement("span");
|
|
158
158
|
badge.className = "ft-badge";
|
|
159
159
|
badge.textContent = String(node.unreadCount);
|
|
160
|
+
badge.title = `${node.unreadCount} unread`;
|
|
160
161
|
folderEl.appendChild(badge);
|
|
161
162
|
}
|
|
162
163
|
// Total count (shown when View > Folder counts is checked)
|
|
@@ -164,6 +165,7 @@ function renderNode(node, container, depth) {
|
|
|
164
165
|
const total = document.createElement("span");
|
|
165
166
|
total.className = "ft-total-count";
|
|
166
167
|
total.textContent = String(node.totalCount);
|
|
168
|
+
total.title = `${node.totalCount} total messages`;
|
|
167
169
|
folderEl.appendChild(total);
|
|
168
170
|
}
|
|
169
171
|
folderEl.addEventListener("click", () => {
|
|
@@ -587,6 +589,7 @@ async function loadFolderTree(container) {
|
|
|
587
589
|
if (accounts.length > 1) {
|
|
588
590
|
const unifiedEl = document.createElement("div");
|
|
589
591
|
unifiedEl.className = "ft-folder ft-unified";
|
|
592
|
+
unifiedEl.title = "Merged inbox view of all accounts — click to see messages from every account's INBOX sorted by date";
|
|
590
593
|
unifiedEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">All Inboxes</span>`;
|
|
591
594
|
unifiedEl.addEventListener("click", () => {
|
|
592
595
|
if (selectedElement)
|
|
@@ -293,3 +293,93 @@ body {
|
|
|
293
293
|
max-width: 60ch;
|
|
294
294
|
}
|
|
295
295
|
.ql-editor, .tt-content .tiptap { position: relative; }
|
|
296
|
+
|
|
297
|
+
/* Link editor modal (Ctrl+K / toolbar link button) */
|
|
298
|
+
.mailx-modal-backdrop {
|
|
299
|
+
position: fixed;
|
|
300
|
+
inset: 0;
|
|
301
|
+
background: rgba(0, 0, 0, 0.35);
|
|
302
|
+
display: flex;
|
|
303
|
+
align-items: center;
|
|
304
|
+
justify-content: center;
|
|
305
|
+
z-index: 2000;
|
|
306
|
+
}
|
|
307
|
+
.mailx-modal {
|
|
308
|
+
background: var(--color-bg);
|
|
309
|
+
color: var(--color-text);
|
|
310
|
+
padding: var(--gap-lg);
|
|
311
|
+
border-radius: var(--radius-md);
|
|
312
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
313
|
+
min-width: 420px;
|
|
314
|
+
max-width: 90vw;
|
|
315
|
+
display: flex;
|
|
316
|
+
flex-direction: column;
|
|
317
|
+
gap: var(--gap-md);
|
|
318
|
+
font-family: var(--font-ui);
|
|
319
|
+
}
|
|
320
|
+
.mailx-modal-title {
|
|
321
|
+
font-size: var(--font-size-lg);
|
|
322
|
+
font-weight: 600;
|
|
323
|
+
}
|
|
324
|
+
.mailx-modal-label {
|
|
325
|
+
display: flex;
|
|
326
|
+
flex-direction: column;
|
|
327
|
+
gap: 4px;
|
|
328
|
+
font-size: var(--font-size-sm);
|
|
329
|
+
color: var(--color-text-muted);
|
|
330
|
+
}
|
|
331
|
+
.mailx-modal-input {
|
|
332
|
+
padding: 6px 8px;
|
|
333
|
+
border: 1px solid var(--color-border);
|
|
334
|
+
border-radius: var(--radius-sm);
|
|
335
|
+
background: var(--color-bg-surface);
|
|
336
|
+
color: var(--color-text);
|
|
337
|
+
font-family: var(--font-ui);
|
|
338
|
+
font-size: var(--font-size-base);
|
|
339
|
+
}
|
|
340
|
+
.mailx-modal-input:focus {
|
|
341
|
+
outline: 2px solid var(--color-accent);
|
|
342
|
+
outline-offset: -1px;
|
|
343
|
+
}
|
|
344
|
+
.mailx-modal-buttons {
|
|
345
|
+
display: flex;
|
|
346
|
+
gap: var(--gap-sm);
|
|
347
|
+
align-items: center;
|
|
348
|
+
margin-top: var(--gap-xs);
|
|
349
|
+
}
|
|
350
|
+
.mailx-modal-spacer { flex: 1; }
|
|
351
|
+
.mailx-modal-btn {
|
|
352
|
+
padding: 6px 14px;
|
|
353
|
+
border: 1px solid var(--color-border);
|
|
354
|
+
border-radius: var(--radius-sm);
|
|
355
|
+
background: var(--color-bg-surface);
|
|
356
|
+
color: var(--color-text);
|
|
357
|
+
cursor: pointer;
|
|
358
|
+
font-size: var(--font-size-sm);
|
|
359
|
+
}
|
|
360
|
+
.mailx-modal-btn:hover { background: var(--color-bg-hover); }
|
|
361
|
+
.mailx-modal-btn-primary {
|
|
362
|
+
background: var(--color-accent);
|
|
363
|
+
color: #fff;
|
|
364
|
+
border-color: transparent;
|
|
365
|
+
font-weight: 500;
|
|
366
|
+
}
|
|
367
|
+
.mailx-modal-btn-primary:hover { filter: brightness(1.1); }
|
|
368
|
+
|
|
369
|
+
/* Link hover preview: small floating URL below the anchor */
|
|
370
|
+
.mailx-link-hover {
|
|
371
|
+
position: fixed;
|
|
372
|
+
padding: 4px 8px;
|
|
373
|
+
background: rgba(20, 20, 28, 0.92);
|
|
374
|
+
color: #e6e6f0;
|
|
375
|
+
font-size: 0.8rem;
|
|
376
|
+
font-family: var(--font-ui);
|
|
377
|
+
border-radius: var(--radius-sm);
|
|
378
|
+
max-width: 60ch;
|
|
379
|
+
overflow: hidden;
|
|
380
|
+
text-overflow: ellipsis;
|
|
381
|
+
white-space: nowrap;
|
|
382
|
+
z-index: 2100;
|
|
383
|
+
pointer-events: none;
|
|
384
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
385
|
+
}
|
package/client/compose/editor.js
CHANGED
|
@@ -2,24 +2,147 @@
|
|
|
2
2
|
* Editor abstraction — wraps Quill or tiptap behind a common interface.
|
|
3
3
|
* The compose window loads this module and calls createEditor() based on the user's setting.
|
|
4
4
|
*/
|
|
5
|
+
/** URL-ish test: accepts http(s)://, mailto:, tel:, and bare domains with a dot. */
|
|
6
|
+
function looksLikeUrl(s) {
|
|
7
|
+
const t = s.trim();
|
|
8
|
+
if (!t)
|
|
9
|
+
return false;
|
|
10
|
+
if (/^(https?|mailto|tel):/i.test(t))
|
|
11
|
+
return true;
|
|
12
|
+
// bare domain (e.g. "example.com/path") — require a dot and no internal whitespace
|
|
13
|
+
return /^[\w-]+(\.[\w-]+)+(\/\S*)?$/.test(t);
|
|
14
|
+
}
|
|
15
|
+
function normalizeUrl(s) {
|
|
16
|
+
const t = s.trim();
|
|
17
|
+
if (!t)
|
|
18
|
+
return t;
|
|
19
|
+
if (/^(https?|mailto|tel):/i.test(t))
|
|
20
|
+
return t;
|
|
21
|
+
if (/^[\w.+-]+@[\w-]+(\.[\w-]+)+$/.test(t))
|
|
22
|
+
return `mailto:${t}`;
|
|
23
|
+
return `https://${t}`;
|
|
24
|
+
}
|
|
25
|
+
/** Floating modal that edits both link text and URL. Returns null on Cancel,
|
|
26
|
+
* { text, url } on OK, or { text: "", url: "" } on "Remove link". */
|
|
27
|
+
function openLinkDialog(initialText, initialUrl) {
|
|
28
|
+
return new Promise(resolve => {
|
|
29
|
+
const backdrop = document.createElement("div");
|
|
30
|
+
backdrop.className = "mailx-modal-backdrop";
|
|
31
|
+
const panel = document.createElement("div");
|
|
32
|
+
panel.className = "mailx-modal";
|
|
33
|
+
panel.innerHTML = `
|
|
34
|
+
<div class="mailx-modal-title">Edit link</div>
|
|
35
|
+
<label class="mailx-modal-label">Text
|
|
36
|
+
<input type="text" class="mailx-modal-input" id="mailx-link-text">
|
|
37
|
+
</label>
|
|
38
|
+
<label class="mailx-modal-label">URL
|
|
39
|
+
<input type="text" class="mailx-modal-input" id="mailx-link-url" spellcheck="false" autocomplete="off">
|
|
40
|
+
</label>
|
|
41
|
+
<div class="mailx-modal-buttons">
|
|
42
|
+
<button type="button" class="mailx-modal-btn" data-action="remove">Remove link</button>
|
|
43
|
+
<span class="mailx-modal-spacer"></span>
|
|
44
|
+
<button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
|
|
45
|
+
<button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="ok">OK</button>
|
|
46
|
+
</div>`;
|
|
47
|
+
backdrop.appendChild(panel);
|
|
48
|
+
document.body.appendChild(backdrop);
|
|
49
|
+
const textInput = panel.querySelector("#mailx-link-text");
|
|
50
|
+
const urlInput = panel.querySelector("#mailx-link-url");
|
|
51
|
+
textInput.value = initialText;
|
|
52
|
+
urlInput.value = initialUrl;
|
|
53
|
+
const close = (result) => {
|
|
54
|
+
backdrop.remove();
|
|
55
|
+
document.removeEventListener("keydown", onKey, true);
|
|
56
|
+
resolve(result);
|
|
57
|
+
};
|
|
58
|
+
const commit = () => close({ text: textInput.value, url: normalizeUrl(urlInput.value) });
|
|
59
|
+
const onKey = (e) => {
|
|
60
|
+
if (e.key === "Escape") {
|
|
61
|
+
e.stopPropagation();
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
close(null);
|
|
64
|
+
}
|
|
65
|
+
else if (e.key === "Enter") {
|
|
66
|
+
e.stopPropagation();
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
commit();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
document.addEventListener("keydown", onKey, true);
|
|
72
|
+
panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
|
|
73
|
+
btn.addEventListener("click", () => {
|
|
74
|
+
const action = btn.dataset.action;
|
|
75
|
+
if (action === "cancel")
|
|
76
|
+
close(null);
|
|
77
|
+
else if (action === "remove")
|
|
78
|
+
close({ text: textInput.value, url: "", remove: true });
|
|
79
|
+
else
|
|
80
|
+
commit();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
|
|
84
|
+
close(null); });
|
|
85
|
+
// Focus URL if we have text, else text first
|
|
86
|
+
(initialText ? urlInput : textInput).focus();
|
|
87
|
+
(initialText ? urlInput : textInput).select();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
5
90
|
function createQuillEditor(container) {
|
|
91
|
+
/** Open the link dialog for the current selection or cursor. If range has
|
|
92
|
+
* a selection, prefill text from the selected text; if cursor is inside
|
|
93
|
+
* a link, prefill both from the link's range. */
|
|
94
|
+
const openLinkForRange = async (quill, range) => {
|
|
95
|
+
if (!range)
|
|
96
|
+
return;
|
|
97
|
+
// Expand a bare cursor inside a link to the full link range
|
|
98
|
+
let linkRange = range;
|
|
99
|
+
const format = quill.getFormat(range);
|
|
100
|
+
if (range.length === 0 && format.link) {
|
|
101
|
+
// Walk left and right to find the link extent
|
|
102
|
+
const [leaf, offset] = quill.getLeaf(range.index);
|
|
103
|
+
if (leaf) {
|
|
104
|
+
// Build a range that spans the whole link
|
|
105
|
+
const text = quill.getText();
|
|
106
|
+
let start = range.index, end = range.index;
|
|
107
|
+
while (start > 0 && quill.getFormat(start - 1, 1).link === format.link)
|
|
108
|
+
start--;
|
|
109
|
+
while (end < text.length - 1 && quill.getFormat(end, 1).link === format.link)
|
|
110
|
+
end++;
|
|
111
|
+
linkRange = { index: start, length: end - start };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const currentText = linkRange.length ? quill.getText(linkRange.index, linkRange.length).replace(/\n$/, "") : "";
|
|
115
|
+
const currentUrl = format.link || "";
|
|
116
|
+
const result = await openLinkDialog(currentText, currentUrl);
|
|
117
|
+
if (!result)
|
|
118
|
+
return;
|
|
119
|
+
if (result.remove) {
|
|
120
|
+
if (linkRange.length)
|
|
121
|
+
quill.formatText(linkRange.index, linkRange.length, "link", false);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!result.url)
|
|
125
|
+
return;
|
|
126
|
+
const newText = result.text || result.url;
|
|
127
|
+
if (linkRange.length) {
|
|
128
|
+
// Replace the existing text+link with new text+link
|
|
129
|
+
quill.deleteText(linkRange.index, linkRange.length);
|
|
130
|
+
quill.insertText(linkRange.index, newText, { link: result.url });
|
|
131
|
+
quill.setSelection(linkRange.index + newText.length, 0);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
quill.insertText(linkRange.index, newText, { link: result.url });
|
|
135
|
+
quill.setSelection(linkRange.index + newText.length, 0);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
6
138
|
// Extra keybindings for formatting that Quill doesn't wire up by default.
|
|
7
|
-
// Ctrl+K (insert link) is the one most users expect; we also add shortcuts
|
|
139
|
+
// Ctrl+K (insert/edit link) is the one most users expect; we also add shortcuts
|
|
8
140
|
// for strikethrough, lists, indent, color, and clear-formatting.
|
|
9
141
|
const extraBindings = {
|
|
10
142
|
insertLink: {
|
|
11
143
|
key: "K", shortKey: true,
|
|
12
144
|
handler: function (range) {
|
|
13
|
-
|
|
14
|
-
return true;
|
|
15
|
-
const current = this.quill.getFormat(range).link || "";
|
|
16
|
-
const url = prompt("URL (leave blank to remove link):", current);
|
|
17
|
-
if (url === null)
|
|
18
|
-
return;
|
|
19
|
-
if (url === "")
|
|
20
|
-
this.quill.format("link", false);
|
|
21
|
-
else
|
|
22
|
-
this.quill.format("link", url);
|
|
145
|
+
openLinkForRange(this.quill, range);
|
|
23
146
|
},
|
|
24
147
|
},
|
|
25
148
|
removeLink: {
|
|
@@ -95,6 +218,75 @@ function createQuillEditor(container) {
|
|
|
95
218
|
});
|
|
96
219
|
// Make toolbar buttons non-tabbable so Tab goes straight to editor body
|
|
97
220
|
document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
|
|
221
|
+
// Native spell-check: WebView2 / Chromium underlines misspellings and the
|
|
222
|
+
// right-click menu offers "Add to dictionary". Quill clears spellcheck on
|
|
223
|
+
// its root by default, so we turn it back on explicitly.
|
|
224
|
+
q.root.setAttribute("spellcheck", "true");
|
|
225
|
+
q.root.setAttribute("autocorrect", "on");
|
|
226
|
+
q.root.setAttribute("autocapitalize", "on");
|
|
227
|
+
// Toolbar link button: open our modal instead of Quill's built-in URL prompt.
|
|
228
|
+
const toolbar = q.getModule("toolbar");
|
|
229
|
+
toolbar?.addHandler("link", function () {
|
|
230
|
+
openLinkForRange(q, q.getSelection() || { index: q.getLength() - 1, length: 0 });
|
|
231
|
+
});
|
|
232
|
+
// Paste handling:
|
|
233
|
+
// - If clipboard has text/html, let Quill convert it (preserves anchors).
|
|
234
|
+
// - If only text/plain and it's a URL, insert as a link (honoring any text
|
|
235
|
+
// currently selected — the URL becomes the href of that text).
|
|
236
|
+
// - Shift+Ctrl+V (handled via contextmenu "Paste as text") or any other
|
|
237
|
+
// plain text is inserted verbatim.
|
|
238
|
+
q.root.addEventListener("paste", (e) => {
|
|
239
|
+
const cb = e.clipboardData;
|
|
240
|
+
if (!cb)
|
|
241
|
+
return;
|
|
242
|
+
const html = cb.getData("text/html");
|
|
243
|
+
const plain = cb.getData("text/plain");
|
|
244
|
+
if (html)
|
|
245
|
+
return; // Quill handles HTML clipboard natively
|
|
246
|
+
if (plain && looksLikeUrl(plain)) {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
const range = q.getSelection(true);
|
|
249
|
+
if (!range)
|
|
250
|
+
return;
|
|
251
|
+
const url = normalizeUrl(plain);
|
|
252
|
+
if (range.length > 0) {
|
|
253
|
+
// Preserve existing selection text, just format it as a link
|
|
254
|
+
q.formatText(range.index, range.length, "link", url);
|
|
255
|
+
q.setSelection(range.index + range.length, 0);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
q.insertText(range.index, plain.trim(), { link: url });
|
|
259
|
+
q.setSelection(range.index + plain.trim().length, 0);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
// Hover preview: show the target URL in a floating tooltip when the
|
|
264
|
+
// pointer is over a link. Built on top of native mouseover/mouseout
|
|
265
|
+
// rather than Quill's ql-tooltip (which is keyboard-triggered).
|
|
266
|
+
let hoverTip = null;
|
|
267
|
+
q.root.addEventListener("mouseover", (e) => {
|
|
268
|
+
const a = e.target.closest("a[href]");
|
|
269
|
+
if (!a)
|
|
270
|
+
return;
|
|
271
|
+
if (hoverTip)
|
|
272
|
+
hoverTip.remove();
|
|
273
|
+
hoverTip = document.createElement("div");
|
|
274
|
+
hoverTip.className = "mailx-link-hover";
|
|
275
|
+
hoverTip.textContent = a.getAttribute("href") || "";
|
|
276
|
+
document.body.appendChild(hoverTip);
|
|
277
|
+
const rect = a.getBoundingClientRect();
|
|
278
|
+
hoverTip.style.left = `${Math.max(8, rect.left)}px`;
|
|
279
|
+
hoverTip.style.top = `${rect.bottom + 4}px`;
|
|
280
|
+
});
|
|
281
|
+
q.root.addEventListener("mouseout", (e) => {
|
|
282
|
+
const to = e.relatedTarget;
|
|
283
|
+
if (to && to.closest("a[href]"))
|
|
284
|
+
return;
|
|
285
|
+
if (hoverTip) {
|
|
286
|
+
hoverTip.remove();
|
|
287
|
+
hoverTip = null;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
98
290
|
return {
|
|
99
291
|
setHtml(html) {
|
|
100
292
|
q.clipboard.dangerouslyPasteHTML(html);
|
|
@@ -166,11 +166,9 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
166
166
|
align-items: center;
|
|
167
167
|
gap: var(--gap-xs);
|
|
168
168
|
padding: var(--gap-xs) var(--gap-sm);
|
|
169
|
-
font-weight:
|
|
169
|
+
font-weight: 600;
|
|
170
170
|
font-size: var(--font-size-base);
|
|
171
171
|
color: var(--color-brand-dark);
|
|
172
|
-
text-transform: uppercase;
|
|
173
|
-
letter-spacing: 0.05em;
|
|
174
172
|
cursor: pointer;
|
|
175
173
|
user-select: none;
|
|
176
174
|
}
|
|
@@ -197,12 +195,12 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
197
195
|
padding: var(--gap-xs) var(--gap-sm) var(--gap-xs) var(--gap-lg);
|
|
198
196
|
cursor: pointer;
|
|
199
197
|
font-size: var(--font-size-base);
|
|
200
|
-
font-weight:
|
|
198
|
+
font-weight: 500;
|
|
201
199
|
color: var(--color-brand-dark);
|
|
202
200
|
border-radius: 0;
|
|
203
201
|
|
|
204
202
|
&:hover { background: var(--color-bg-hover); }
|
|
205
|
-
&.selected { background: var(--color-bg-selected); color: var(--color-accent); }
|
|
203
|
+
&.selected { background: var(--color-bg-selected); color: var(--color-accent); font-weight: 600; }
|
|
206
204
|
}
|
|
207
205
|
|
|
208
206
|
.ft-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -353,11 +351,13 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
353
351
|
border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
|
|
354
352
|
cursor: pointer;
|
|
355
353
|
font-size: var(--font-size-base);
|
|
354
|
+
font-weight: 400;
|
|
356
355
|
color: var(--color-text);
|
|
357
356
|
align-items: baseline;
|
|
357
|
+
line-height: 1.35;
|
|
358
358
|
|
|
359
359
|
&:hover { background: var(--color-bg-hover); }
|
|
360
|
-
&.selected { background: var(--color-brand); color: var(--color-brand-dark); font-weight:
|
|
360
|
+
&.selected { background: var(--color-brand); color: var(--color-brand-dark); font-weight: 500; }
|
|
361
361
|
&.unread {
|
|
362
362
|
font-weight: 600;
|
|
363
363
|
color: var(--color-unread);
|
|
@@ -411,7 +411,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
411
411
|
}
|
|
412
412
|
}
|
|
413
413
|
.no-snippets .ml-preview { display: none; }
|
|
414
|
-
.ml-date { white-space: nowrap; text-align: right; color: var(--color-text-muted); font-
|
|
414
|
+
.ml-date { white-space: nowrap; text-align: right; color: var(--color-text-muted); font-size: var(--font-size-sm); font-variant-numeric: tabular-nums; }
|
|
415
415
|
/* Not-downloaded indicator: small dot before the date */
|
|
416
416
|
.ml-row.not-downloaded .ml-date::before {
|
|
417
417
|
content: "○ ";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.222",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.284",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
79
79
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
80
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
-
"@bobfrankston/msger": "^0.1.
|
|
81
|
+
"@bobfrankston/msger": "^0.1.284",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -946,20 +946,40 @@ export class ImapManager extends EventEmitter {
|
|
|
946
946
|
console.log(` [api] ${accountId}/${folder.path}: ${messages.length} new messages`);
|
|
947
947
|
this.storeApiMessages(accountId, folder.id, messages, highestUid);
|
|
948
948
|
}
|
|
949
|
-
// Reconcile deletions
|
|
949
|
+
// Reconcile deletions — messages present locally but not on the server.
|
|
950
|
+
// SAFETY: this used to silently wipe entire folders when getUids()
|
|
951
|
+
// returned a partial list (e.g. paginated fetch hit a rate limit and
|
|
952
|
+
// bailed). Multiple guards now:
|
|
953
|
+
// 1. getUids() flags partial results via _truncated — refuse to delete
|
|
954
|
+
// 2. If server list is empty but local isn't, assume a transient error
|
|
955
|
+
// 3. If reconcile would delete more than RECONCILE_DELETE_THRESHOLD of
|
|
956
|
+
// local messages, log and skip — safer to keep phantoms than to lose
|
|
957
|
+
// real messages. User can fix with `mailx -rebuild` if needed.
|
|
950
958
|
try {
|
|
951
|
-
const
|
|
959
|
+
const serverUidsArr = await api.getUids(folder.path);
|
|
960
|
+
const serverUids = new Set(serverUidsArr);
|
|
952
961
|
const localUids = this.db.getUidsForFolder(accountId, folder.id);
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
962
|
+
if (serverUidsArr._truncated) {
|
|
963
|
+
console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list truncated (${serverUidsArr.length} ids)`);
|
|
964
|
+
}
|
|
965
|
+
else if (serverUidsArr.length === 0 && localUids.length > 0) {
|
|
966
|
+
console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list empty but local has ${localUids.length}`);
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
const toDelete = localUids.filter(uid => !serverUids.has(uid));
|
|
970
|
+
const RECONCILE_DELETE_THRESHOLD = 0.5; // refuse to delete >50% in one pass
|
|
971
|
+
if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
|
|
972
|
+
console.log(` [api] ${accountId}/${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
for (const uid of toDelete) {
|
|
976
|
+
this.db.deleteMessage(accountId, uid);
|
|
977
|
+
this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
|
|
978
|
+
}
|
|
979
|
+
if (toDelete.length > 0)
|
|
980
|
+
console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
|
|
959
981
|
}
|
|
960
982
|
}
|
|
961
|
-
if (deleted > 0)
|
|
962
|
-
console.log(` [api] ${accountId}/${folder.path}: ${deleted} deleted`);
|
|
963
983
|
}
|
|
964
984
|
catch (e) {
|
|
965
985
|
console.error(` [api] ${accountId}/${folder.path}: reconciliation error: ${e.message}`);
|
|
@@ -970,12 +990,15 @@ export class ImapManager extends EventEmitter {
|
|
|
970
990
|
}
|
|
971
991
|
/** Store API-fetched messages to DB */
|
|
972
992
|
storeApiMessages(accountId, folderId, msgs, highestUid) {
|
|
993
|
+
// highestUid kept for signature compatibility but no longer used to
|
|
994
|
+
// filter — Gmail message IDs aren't monotonic, so `msg.uid <= highestUid`
|
|
995
|
+
// would drop brand-new messages whose hash happens to be smaller than
|
|
996
|
+
// the previous high. upsertMessage's primary-key dedup handles it.
|
|
997
|
+
void highestUid;
|
|
973
998
|
let stored = 0;
|
|
974
999
|
this.db.beginTransaction();
|
|
975
1000
|
try {
|
|
976
1001
|
for (const msg of msgs) {
|
|
977
|
-
if (msg.uid <= highestUid)
|
|
978
|
-
continue;
|
|
979
1002
|
const flags = [];
|
|
980
1003
|
if (msg.seen)
|
|
981
1004
|
flags.push("\\Seen");
|
|
@@ -8,7 +8,11 @@ export declare class GmailApiProvider implements MailProvider {
|
|
|
8
8
|
constructor(tokenProvider: () => Promise<string>);
|
|
9
9
|
private fetch;
|
|
10
10
|
listFolders(): Promise<ProviderFolder[]>;
|
|
11
|
-
/** List message IDs matching a query, handling pagination
|
|
11
|
+
/** List message IDs matching a query, handling pagination.
|
|
12
|
+
* IMPORTANT: on any error we throw — do NOT return a partial list, because
|
|
13
|
+
* callers use this for sync reconciliation and a short list would delete
|
|
14
|
+
* real messages from the local DB. Returning [] silently caused the
|
|
15
|
+
* "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
|
|
12
16
|
private listMessageIds;
|
|
13
17
|
/** Batch-fetch message metadata or full content */
|
|
14
18
|
private batchFetch;
|
|
@@ -93,10 +93,15 @@ export class GmailApiProvider {
|
|
|
93
93
|
}
|
|
94
94
|
return folders;
|
|
95
95
|
}
|
|
96
|
-
/** List message IDs matching a query, handling pagination
|
|
96
|
+
/** List message IDs matching a query, handling pagination.
|
|
97
|
+
* IMPORTANT: on any error we throw — do NOT return a partial list, because
|
|
98
|
+
* callers use this for sync reconciliation and a short list would delete
|
|
99
|
+
* real messages from the local DB. Returning [] silently caused the
|
|
100
|
+
* "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
|
|
97
101
|
async listMessageIds(query, maxResults = 500) {
|
|
98
102
|
const ids = [];
|
|
99
103
|
let pageToken = "";
|
|
104
|
+
let truncated = false;
|
|
100
105
|
while (true) {
|
|
101
106
|
const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
|
|
102
107
|
if (pageToken)
|
|
@@ -105,10 +110,17 @@ export class GmailApiProvider {
|
|
|
105
110
|
for (const msg of data.messages || []) {
|
|
106
111
|
ids.push(msg.id);
|
|
107
112
|
}
|
|
108
|
-
if (!data.nextPageToken
|
|
113
|
+
if (!data.nextPageToken)
|
|
109
114
|
break;
|
|
115
|
+
if (ids.length >= maxResults) {
|
|
116
|
+
// Hit the caller's cap but the server has more. Flag it so
|
|
117
|
+
// reconcile-style callers can refuse to treat this as complete.
|
|
118
|
+
truncated = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
110
121
|
pageToken = data.nextPageToken;
|
|
111
122
|
}
|
|
123
|
+
ids._truncated = truncated;
|
|
112
124
|
return ids;
|
|
113
125
|
}
|
|
114
126
|
/** Batch-fetch message metadata or full content */
|
|
@@ -170,12 +182,15 @@ export class GmailApiProvider {
|
|
|
170
182
|
};
|
|
171
183
|
}
|
|
172
184
|
async fetchSince(folder, sinceUid, options = {}) {
|
|
173
|
-
// Gmail
|
|
174
|
-
//
|
|
185
|
+
// Gmail message IDs are hash-derived, NOT monotonic — filtering by
|
|
186
|
+
// `uid > sinceUid` silently drops new messages whose hash happens to
|
|
187
|
+
// fall below the high-water mark. Fetch the most recent page and let
|
|
188
|
+
// upsertMessage dedupe by (account, folder, uid). The sinceUid arg is
|
|
189
|
+
// kept for interface compatibility but no longer used for filtering.
|
|
190
|
+
void sinceUid;
|
|
175
191
|
const query = `in:${this.folderToLabel(folder)}`;
|
|
176
192
|
const ids = await this.listMessageIds(query, 200);
|
|
177
|
-
|
|
178
|
-
return messages.filter(m => m.uid > sinceUid);
|
|
193
|
+
return this.batchFetch(ids, options);
|
|
179
194
|
}
|
|
180
195
|
async fetchByDate(folder, since, before, options = {}, onChunk) {
|
|
181
196
|
const afterDate = this.formatDate(since);
|
|
@@ -213,7 +228,11 @@ export class GmailApiProvider {
|
|
|
213
228
|
async getUids(folder) {
|
|
214
229
|
const query = `in:${this.folderToLabel(folder)}`;
|
|
215
230
|
const ids = await this.listMessageIds(query, 10000);
|
|
216
|
-
|
|
231
|
+
const result = ids.map(idToUid);
|
|
232
|
+
// Propagate the truncation flag so reconcile can refuse to delete.
|
|
233
|
+
if (ids._truncated)
|
|
234
|
+
result._truncated = true;
|
|
235
|
+
return result;
|
|
217
236
|
}
|
|
218
237
|
async close() {
|
|
219
238
|
// No persistent connection to close
|