@bobfrankston/mailx 1.0.391 → 1.0.393
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/#build-apk.cmd.asv# +4 -0
- package/bin/mailx.js +3 -1
- package/build-apk.cmd +3 -0
- package/client/app.js +52 -5
- package/client/components/message-list.js +16 -0
- package/client/compose/compose.js +61 -5
- package/package.json +1 -1
- package/packages/mailx-service/index.js +3 -1
- package/packages/mailx-store/db.js +17 -1
package/bin/mailx.js
CHANGED
|
@@ -907,7 +907,9 @@ async function main() {
|
|
|
907
907
|
fs.mkdirSync(logDir, { recursive: true });
|
|
908
908
|
// Prune logs older than LOG_RETENTION_DAYS on startup. Keep it simple:
|
|
909
909
|
// scan the dir, stat, delete. Cheap even with years of history.
|
|
910
|
-
|
|
910
|
+
// Bumped 7 → 30 days so "where did my letter go?" reports can still
|
|
911
|
+
// reach the `[reconcile-delete]` log entry weeks after the fact.
|
|
912
|
+
const LOG_RETENTION_DAYS = 30;
|
|
911
913
|
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
|
912
914
|
try {
|
|
913
915
|
for (const name of fs.readdirSync(logDir)) {
|
package/build-apk.cmd
ADDED
package/client/app.js
CHANGED
|
@@ -1573,11 +1573,12 @@ onWsEvent((event) => {
|
|
|
1573
1573
|
}
|
|
1574
1574
|
break;
|
|
1575
1575
|
case "folderCountsChanged": {
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
|
|
1576
|
+
// Incremental update only — updateFolderCounts patches badge counts
|
|
1577
|
+
// in-place and falls back to a full refreshFolderTree() when the
|
|
1578
|
+
// folder structure has actually changed. Calling both was doing a
|
|
1579
|
+
// 300 ms debounced rebuild on every sync tick even when just the
|
|
1580
|
+
// unread count moved — visible as folder-tree flicker on Dovecot
|
|
1581
|
+
// accounts where STATUS polls fire frequently.
|
|
1581
1582
|
updateFolderCounts();
|
|
1582
1583
|
updateNewMessageCount();
|
|
1583
1584
|
// Debounced silent reload — preserves scroll position, selection, and viewer
|
|
@@ -1677,6 +1678,52 @@ onWsEvent((event) => {
|
|
|
1677
1678
|
case "outboxStatus":
|
|
1678
1679
|
renderOutboxStatus(event);
|
|
1679
1680
|
break;
|
|
1681
|
+
case "calendarUpdated":
|
|
1682
|
+
case "tasksUpdated":
|
|
1683
|
+
// Reauth succeeded (or was never broken): clear any lingering
|
|
1684
|
+
// scope banner for this feature. Handled here (not just in the
|
|
1685
|
+
// sidebar) because the global fallback banner isn't tied to the
|
|
1686
|
+
// sidebar's lifecycle.
|
|
1687
|
+
if (alertBanner && /^scope-(calendar|tasks|google)$/.test(alertBanner.dataset.key || "")) {
|
|
1688
|
+
alertBanner.hidden = true;
|
|
1689
|
+
alertBanner.dataset.key = "";
|
|
1690
|
+
alertBanner.querySelector(".status-action")?.remove();
|
|
1691
|
+
}
|
|
1692
|
+
break;
|
|
1693
|
+
case "authScopeError": {
|
|
1694
|
+
// Fallback banner: calendar-sidebar.ts already shows this inline
|
|
1695
|
+
// when the sidebar is visible, but if the user has the sidebar
|
|
1696
|
+
// off or is on a narrow tier where it's hidden, the error would
|
|
1697
|
+
// otherwise be invisible. Global banner with the same button.
|
|
1698
|
+
const feat = event.feature || "google";
|
|
1699
|
+
const key = `scope-${feat}`;
|
|
1700
|
+
const msg = event.message || `Google ${feat} access needs re-consent.`;
|
|
1701
|
+
showAlert(msg, key, { sticky: true });
|
|
1702
|
+
const bannerText = document.getElementById("alert-text");
|
|
1703
|
+
if (bannerText && bannerText.textContent === msg) {
|
|
1704
|
+
const existing = bannerText.parentElement?.querySelector(".status-action");
|
|
1705
|
+
if (!existing) {
|
|
1706
|
+
const btn = document.createElement("button");
|
|
1707
|
+
btn.className = "status-action";
|
|
1708
|
+
btn.textContent = "Re-authenticate";
|
|
1709
|
+
btn.addEventListener("click", async () => {
|
|
1710
|
+
btn.disabled = true;
|
|
1711
|
+
btn.textContent = "Opening browser…";
|
|
1712
|
+
try {
|
|
1713
|
+
const { reauthGoogleScopes } = await import("./lib/api-client.js");
|
|
1714
|
+
await reauthGoogleScopes();
|
|
1715
|
+
btn.textContent = "Consent opened — finish in browser";
|
|
1716
|
+
}
|
|
1717
|
+
catch (err) {
|
|
1718
|
+
btn.disabled = false;
|
|
1719
|
+
btn.textContent = `Failed: ${err?.message || err}`;
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
bannerText.parentElement?.insertBefore(btn, document.getElementById("alert-dismiss"));
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
break;
|
|
1726
|
+
}
|
|
1680
1727
|
case "accountError": {
|
|
1681
1728
|
// Show actual error + hint in banner
|
|
1682
1729
|
const msg = `${event.accountId}: ${event.error}`;
|
|
@@ -793,6 +793,22 @@ function appendMessages(body, accountId, items) {
|
|
|
793
793
|
label: "Delete",
|
|
794
794
|
action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
|
|
795
795
|
},
|
|
796
|
+
{ label: "", action: () => { }, separator: true },
|
|
797
|
+
{
|
|
798
|
+
label: "Copy Message-ID",
|
|
799
|
+
action: async () => {
|
|
800
|
+
// Useful when asking "where did my letter go?" — pair the
|
|
801
|
+
// Message-ID with the reconcile-delete log line.
|
|
802
|
+
if (!msg.messageId) {
|
|
803
|
+
alert("No Message-ID on this row.");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
await navigator.clipboard.writeText(msg.messageId);
|
|
808
|
+
}
|
|
809
|
+
catch { /* */ }
|
|
810
|
+
},
|
|
811
|
+
},
|
|
796
812
|
];
|
|
797
813
|
showContextMenu(e.clientX, e.clientY, items);
|
|
798
814
|
});
|
|
@@ -853,10 +853,8 @@ function formatSize(n) {
|
|
|
853
853
|
document.getElementById("btn-attach")?.addEventListener("click", () => {
|
|
854
854
|
fileInput?.click();
|
|
855
855
|
});
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
return;
|
|
859
|
-
for (const file of Array.from(fileInput.files)) {
|
|
856
|
+
async function ingestFiles(files) {
|
|
857
|
+
for (const file of Array.from(files)) {
|
|
860
858
|
const buf = await file.arrayBuffer();
|
|
861
859
|
// base64 the whole thing — mailx-service builds the multipart/mixed
|
|
862
860
|
let binary = "";
|
|
@@ -871,10 +869,68 @@ fileInput?.addEventListener("change", async () => {
|
|
|
871
869
|
dataBase64,
|
|
872
870
|
});
|
|
873
871
|
}
|
|
874
|
-
fileInput.value = "";
|
|
875
872
|
renderAttachmentChips();
|
|
876
873
|
scheduleDraftSave();
|
|
874
|
+
}
|
|
875
|
+
fileInput?.addEventListener("change", async () => {
|
|
876
|
+
if (!fileInput.files)
|
|
877
|
+
return;
|
|
878
|
+
await ingestFiles(fileInput.files);
|
|
879
|
+
fileInput.value = "";
|
|
877
880
|
});
|
|
881
|
+
// Drag-and-drop: dropping files anywhere on the compose window attaches them.
|
|
882
|
+
// Highlights a subtle overlay while dragging so the target is obvious. The
|
|
883
|
+
// editor iframe swallows drag events internally so we attach to the compose
|
|
884
|
+
// document root; Quill's own paste/drop handling doesn't fight us because
|
|
885
|
+
// files-with-no-HTML-or-text dragover never hits Quill's clipboard module.
|
|
886
|
+
(() => {
|
|
887
|
+
let dragDepth = 0;
|
|
888
|
+
const root = document.body;
|
|
889
|
+
const overlay = document.createElement("div");
|
|
890
|
+
overlay.id = "compose-drop-overlay";
|
|
891
|
+
// Toggle `display` directly — can't use the `hidden` attribute here
|
|
892
|
+
// because the inline `display` property in cssText outranks it, which is
|
|
893
|
+
// why the overlay showed permanently when I used `overlay.hidden = true`
|
|
894
|
+
// (user-reported 2026-04-24 with screenshot — blue tint + dashed border
|
|
895
|
+
// were visible before any drag started).
|
|
896
|
+
const baseStyle = "position:fixed;inset:0;background:oklch(0.6 0.18 250 / 0.15);border:3px dashed oklch(0.55 0.2 250);z-index:9999;pointer-events:none;align-items:center;justify-content:center;font-size:1.5rem;font-weight:500;color:oklch(0.35 0.2 250)";
|
|
897
|
+
overlay.style.cssText = baseStyle + ";display:none";
|
|
898
|
+
overlay.textContent = "Drop files to attach";
|
|
899
|
+
root.appendChild(overlay);
|
|
900
|
+
const show = () => { overlay.style.display = "flex"; };
|
|
901
|
+
const hide = () => { overlay.style.display = "none"; };
|
|
902
|
+
const hasFiles = (e) => Array.from(e.dataTransfer?.types || []).includes("Files");
|
|
903
|
+
root.addEventListener("dragenter", (e) => {
|
|
904
|
+
if (!hasFiles(e))
|
|
905
|
+
return;
|
|
906
|
+
dragDepth++;
|
|
907
|
+
show();
|
|
908
|
+
});
|
|
909
|
+
root.addEventListener("dragleave", (e) => {
|
|
910
|
+
if (!hasFiles(e))
|
|
911
|
+
return;
|
|
912
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
913
|
+
if (dragDepth === 0)
|
|
914
|
+
hide();
|
|
915
|
+
});
|
|
916
|
+
root.addEventListener("dragover", (e) => {
|
|
917
|
+
if (!hasFiles(e))
|
|
918
|
+
return;
|
|
919
|
+
e.preventDefault(); // required so drop fires
|
|
920
|
+
if (e.dataTransfer)
|
|
921
|
+
e.dataTransfer.dropEffect = "copy";
|
|
922
|
+
});
|
|
923
|
+
root.addEventListener("drop", async (e) => {
|
|
924
|
+
if (!hasFiles(e))
|
|
925
|
+
return;
|
|
926
|
+
e.preventDefault();
|
|
927
|
+
dragDepth = 0;
|
|
928
|
+
hide();
|
|
929
|
+
const files = e.dataTransfer?.files;
|
|
930
|
+
if (files && files.length > 0)
|
|
931
|
+
await ingestFiles(files);
|
|
932
|
+
});
|
|
933
|
+
})();
|
|
878
934
|
// ── Save and close (X button from parent) ──
|
|
879
935
|
window.addEventListener("compose-save-and-close", () => {
|
|
880
936
|
handleCloseRequest();
|
package/package.json
CHANGED
|
@@ -351,7 +351,8 @@ export class MailxService {
|
|
|
351
351
|
}
|
|
352
352
|
// ── Search ──
|
|
353
353
|
async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
|
|
354
|
-
|
|
354
|
+
q = (q || "").trim();
|
|
355
|
+
if (!q)
|
|
355
356
|
return { items: [], total: 0, page, pageSize };
|
|
356
357
|
if (scope === "server") {
|
|
357
358
|
// Parse qualifiers once; SEARCH runs per folder.
|
|
@@ -1345,6 +1346,7 @@ export class MailxService {
|
|
|
1345
1346
|
}
|
|
1346
1347
|
// ── Contacts ──
|
|
1347
1348
|
searchContacts(query) {
|
|
1349
|
+
query = (query || "").trim();
|
|
1348
1350
|
if (query.length < 1)
|
|
1349
1351
|
return [];
|
|
1350
1352
|
return this.db.searchContacts(query);
|
|
@@ -168,6 +168,9 @@ const SCHEMA = `
|
|
|
168
168
|
);
|
|
169
169
|
CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
|
|
170
170
|
CREATE INDEX IF NOT EXISTS idx_calendar_events_dirty ON calendar_events(dirty) WHERE dirty = 1;
|
|
171
|
+
-- getCalendarEventByProviderId runs once per event on every Google refresh;
|
|
172
|
+
-- without this index each lookup is a full table scan over calendar_events.
|
|
173
|
+
CREATE INDEX IF NOT EXISTS idx_calendar_events_provider ON calendar_events(account_id, provider_id);
|
|
171
174
|
|
|
172
175
|
-- Tasks: two-way cache of Google Tasks / local tasks. Same shape as
|
|
173
176
|
-- calendar_events minus the time range.
|
|
@@ -188,6 +191,10 @@ const SCHEMA = `
|
|
|
188
191
|
);
|
|
189
192
|
CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
|
|
190
193
|
CREATE INDEX IF NOT EXISTS idx_tasks_dirty ON tasks(dirty) WHERE dirty = 1;
|
|
194
|
+
-- Mirror calendar_events: any provider_id lookup path needs a proper index,
|
|
195
|
+
-- even if today's reconcile does the dedup in memory — prevents a future
|
|
196
|
+
-- refactor from accidentally introducing an O(N) scan.
|
|
197
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_provider ON tasks(account_id, provider_id);
|
|
191
198
|
|
|
192
199
|
-- Generic store-sync queue for domains OTHER than messages. Messages
|
|
193
200
|
-- use sync_actions above. This table queues push-to-server actions
|
|
@@ -208,6 +215,10 @@ const SCHEMA = `
|
|
|
208
215
|
UNIQUE(kind, target_uuid, op)
|
|
209
216
|
);
|
|
210
217
|
CREATE INDEX IF NOT EXISTS idx_store_sync_account ON store_sync(account_id, kind);
|
|
218
|
+
-- UNIQUE(kind, target_uuid, op) covers queries that start with kind; lookups
|
|
219
|
+
-- by target_uuid alone ("is this uuid queued for any op?") would otherwise
|
|
220
|
+
-- table-scan. Cheap; store_sync is tiny and write-heavy.
|
|
221
|
+
CREATE INDEX IF NOT EXISTS idx_store_sync_target_uuid ON store_sync(target_uuid);
|
|
211
222
|
`;
|
|
212
223
|
export class MailxDB {
|
|
213
224
|
db;
|
|
@@ -1023,6 +1034,9 @@ export class MailxDB {
|
|
|
1023
1034
|
}
|
|
1024
1035
|
/** Search contacts by name or email prefix */
|
|
1025
1036
|
searchContacts(query, limit = 10) {
|
|
1037
|
+
query = (query || "").trim();
|
|
1038
|
+
if (!query)
|
|
1039
|
+
return [];
|
|
1026
1040
|
// Ranking: prefix matches beat substring matches, then recency-weighted
|
|
1027
1041
|
// use_count within a tier. Recency decay: half-life of 30 days, so a
|
|
1028
1042
|
// contact used today edges out one from months ago even with a lower
|
|
@@ -1064,7 +1078,8 @@ export class MailxDB {
|
|
|
1064
1078
|
}
|
|
1065
1079
|
/** List all contacts (address-book view) with pagination + optional filter. */
|
|
1066
1080
|
listContacts(query, page = 1, pageSize = 100) {
|
|
1067
|
-
|
|
1081
|
+
query = (query || "").trim();
|
|
1082
|
+
const hasQuery = !!query;
|
|
1068
1083
|
const q = `%${query}%`;
|
|
1069
1084
|
const whereClause = hasQuery ? "WHERE email LIKE ? OR name LIKE ?" : "";
|
|
1070
1085
|
const params = hasQuery ? [q, q] : [];
|
|
@@ -1105,6 +1120,7 @@ export class MailxDB {
|
|
|
1105
1120
|
// ── Search ──
|
|
1106
1121
|
/** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
|
|
1107
1122
|
searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
|
|
1123
|
+
query = (query || "").trim();
|
|
1108
1124
|
// Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
|
|
1109
1125
|
let ftsQuery = "";
|
|
1110
1126
|
const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|