@bobfrankston/mailx 1.0.383 → 1.0.385
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/bin/mailx.js +27 -0
- package/client/app.js +58 -4
- package/client/components/calendar-sidebar.js +93 -4
- package/client/components/message-viewer.js +80 -5
- package/client/index.html +12 -0
- package/client/lib/api-client.js +6 -0
- package/client/lib/mailxapi.js +3 -0
- package/client/styles/components.css +28 -0
- package/package.json +3 -3
- package/packages/mailx-service/google-sync.d.ts +7 -0
- package/packages/mailx-service/google-sync.js +2 -0
- package/packages/mailx-service/index.d.ts +19 -2
- package/packages/mailx-service/index.js +109 -13
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-store/db.d.ts +5 -0
- package/packages/mailx-store/db.js +20 -4
package/bin/mailx.js
CHANGED
|
@@ -1213,6 +1213,15 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
1213
1213
|
// with one IPC write per message — lets the UI flip many rows at once.
|
|
1214
1214
|
let pendingCached = [];
|
|
1215
1215
|
let cachedTimer = null;
|
|
1216
|
+
// Calendar/tasks refresh completion — service emits when a pull merged
|
|
1217
|
+
// new rows or reconciled a server-side delete. Client re-renders its
|
|
1218
|
+
// sidebar on receipt. No payload beyond accountId.
|
|
1219
|
+
imapManager.on("calendarUpdated", (payload) => {
|
|
1220
|
+
handle.send({ _event: "calendarUpdated", type: "calendarUpdated", ...payload });
|
|
1221
|
+
});
|
|
1222
|
+
imapManager.on("tasksUpdated", (payload) => {
|
|
1223
|
+
handle.send({ _event: "tasksUpdated", type: "tasksUpdated", ...payload });
|
|
1224
|
+
});
|
|
1216
1225
|
imapManager.on("bodyCached", (accountId, uid) => {
|
|
1217
1226
|
pendingCached.push({ accountId, uid });
|
|
1218
1227
|
if (!cachedTimer) {
|
|
@@ -1328,6 +1337,24 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
1328
1337
|
setInterval(() => {
|
|
1329
1338
|
svc.drainStoreSync().catch((e) => console.error(` [store_sync] periodic drain error: ${e?.message || e}`));
|
|
1330
1339
|
}, 30_000);
|
|
1340
|
+
// Calendar + Tasks poll — pulls server-side changes so the sidebar
|
|
1341
|
+
// reflects events added/edited/deleted on another device without
|
|
1342
|
+
// needing a sidebar nav click. 5-minute cadence is well under the
|
|
1343
|
+
// per-user rate limit and the 1M/day project quota. Webhooks would
|
|
1344
|
+
// be cheaper in theory but need a public HTTPS endpoint; poll is
|
|
1345
|
+
// the pragmatic choice (confirmed 2026-04-23). getCalendarEvents /
|
|
1346
|
+
// getTasks emit the refresh event via imapManager, so the sidebar
|
|
1347
|
+
// re-renders automatically.
|
|
1348
|
+
const CAL_POLL_MS = 5 * 60_000;
|
|
1349
|
+
const horizonDays = 90; // larger than sidebar's default so background
|
|
1350
|
+
// poll catches upcoming-week events the sidebar hasn't asked for yet.
|
|
1351
|
+
setInterval(() => {
|
|
1352
|
+
const now = Date.now();
|
|
1353
|
+
svc.getCalendarEvents(now, now + horizonDays * 86400_000)
|
|
1354
|
+
.catch((e) => console.error(` [calendar] poll error: ${e?.message || e}`));
|
|
1355
|
+
svc.getTasks(false)
|
|
1356
|
+
.catch((e) => console.error(` [tasks] poll error: ${e?.message || e}`));
|
|
1357
|
+
}, CAL_POLL_MS);
|
|
1331
1358
|
// Auto-update: periodically check npm for a newer version and push a
|
|
1332
1359
|
// notification to the WebView so the user can update with one click.
|
|
1333
1360
|
const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
|
package/client/app.js
CHANGED
|
@@ -971,6 +971,36 @@ async function refreshSpamButtonVisibility() {
|
|
|
971
971
|
}
|
|
972
972
|
document.addEventListener("mailx-message-shown", refreshSpamButtonVisibility);
|
|
973
973
|
document.addEventListener("mailx-folder-changed", refreshSpamButtonVisibility);
|
|
974
|
+
// Q100 placeholder — append a row to ~/.mailx/spam.csv for later analysis.
|
|
975
|
+
// No folder move, no flag change, no auto-delete. Button is always visible
|
|
976
|
+
// (no configuration required; unlike btn-spam which needs a junk folder).
|
|
977
|
+
document.getElementById("btn-spam-report")?.addEventListener("click", async () => {
|
|
978
|
+
const current = getCurrentMessage();
|
|
979
|
+
const msg = current?.message;
|
|
980
|
+
const accountId = current?.accountId;
|
|
981
|
+
if (!msg || !accountId)
|
|
982
|
+
return;
|
|
983
|
+
const btn = document.getElementById("btn-spam-report");
|
|
984
|
+
const originalLabel = btn.textContent;
|
|
985
|
+
btn.disabled = true;
|
|
986
|
+
btn.textContent = "…";
|
|
987
|
+
try {
|
|
988
|
+
const { recordSpamReport } = await import("./lib/api-client.js");
|
|
989
|
+
await recordSpamReport(accountId, msg.uid, msg.folderId);
|
|
990
|
+
btn.textContent = "✓";
|
|
991
|
+
const status = document.getElementById("status-sync");
|
|
992
|
+
if (status)
|
|
993
|
+
status.textContent = "Logged to ~/.mailx/spam.csv";
|
|
994
|
+
setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
|
|
995
|
+
}
|
|
996
|
+
catch (e) {
|
|
997
|
+
btn.textContent = "✗";
|
|
998
|
+
const status = document.getElementById("status-sync");
|
|
999
|
+
if (status)
|
|
1000
|
+
status.textContent = `Spam log failed: ${e?.message || e}`;
|
|
1001
|
+
setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 2500);
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
974
1004
|
document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
|
|
975
1005
|
document.getElementById("btn-mark-unread")?.addEventListener("click", () => {
|
|
976
1006
|
// Toggle \Seen on the currently-selected message. Mirrors the R
|
|
@@ -1031,16 +1061,40 @@ document.getElementById("rail-help")?.addEventListener("click", () => {
|
|
|
1031
1061
|
document.getElementById("btn-about")?.click();
|
|
1032
1062
|
});
|
|
1033
1063
|
document.getElementById("rail-theme")?.addEventListener("click", () => {
|
|
1034
|
-
//
|
|
1064
|
+
// Rail theme icon cycles system → dark → light → system. Settings menu
|
|
1065
|
+
// exposes the same three as radio buttons for direct selection.
|
|
1035
1066
|
const root = document.documentElement;
|
|
1036
1067
|
const cur = root.getAttribute("data-theme") || "system";
|
|
1037
1068
|
const next = cur === "system" ? "dark" : cur === "dark" ? "light" : "system";
|
|
1038
|
-
|
|
1069
|
+
applyTheme(next);
|
|
1070
|
+
});
|
|
1071
|
+
function applyTheme(theme) {
|
|
1072
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
1039
1073
|
try {
|
|
1040
|
-
localStorage.setItem("mailx-theme",
|
|
1074
|
+
localStorage.setItem("mailx-theme", theme);
|
|
1041
1075
|
}
|
|
1042
1076
|
catch { /* private mode */ }
|
|
1043
|
-
|
|
1077
|
+
// Reflect in the Settings menu radios so the two paths stay in sync.
|
|
1078
|
+
const radio = document.getElementById(`opt-theme-${theme}`);
|
|
1079
|
+
if (radio)
|
|
1080
|
+
radio.checked = true;
|
|
1081
|
+
}
|
|
1082
|
+
// Restore saved theme + wire the Settings radios. Defaults to "system".
|
|
1083
|
+
(() => {
|
|
1084
|
+
const saved = (() => { try {
|
|
1085
|
+
return localStorage.getItem("mailx-theme") || "system";
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
return "system";
|
|
1089
|
+
} })();
|
|
1090
|
+
applyTheme(saved);
|
|
1091
|
+
for (const t of ["system", "light", "dark"]) {
|
|
1092
|
+
document.getElementById(`opt-theme-${t}`)?.addEventListener("change", (e) => {
|
|
1093
|
+
if (e.target.checked)
|
|
1094
|
+
applyTheme(t);
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
})();
|
|
1044
1098
|
// Highlight the current rail target. For now just inbox is the default; once
|
|
1045
1099
|
// calendar/tasks ship, update this on view change.
|
|
1046
1100
|
function setRailActive(id) {
|
|
@@ -15,19 +15,42 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
|
|
17
17
|
const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
|
|
18
|
+
const SHOW_RECURRING_PREF = "mailx-cal-show-recurring";
|
|
19
|
+
const HORIZON_DAYS_PREF = "mailx-cal-horizon-days";
|
|
20
|
+
const HORIZON_DEFAULT_DAYS = 30;
|
|
18
21
|
let viewYear = new Date().getFullYear();
|
|
19
22
|
let viewMonth = new Date().getMonth();
|
|
20
23
|
let viewDay = new Date().getDate();
|
|
21
24
|
let lastEvents = [];
|
|
25
|
+
function getHorizonDays() {
|
|
26
|
+
try {
|
|
27
|
+
const v = localStorage.getItem(HORIZON_DAYS_PREF);
|
|
28
|
+
const n = v ? parseInt(v, 10) : NaN;
|
|
29
|
+
if (Number.isFinite(n) && n > 0 && n <= 365)
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
catch { /* */ }
|
|
33
|
+
return HORIZON_DEFAULT_DAYS;
|
|
34
|
+
}
|
|
35
|
+
function getShowRecurring() {
|
|
36
|
+
try {
|
|
37
|
+
return localStorage.getItem(SHOW_RECURRING_PREF) !== "false";
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
22
43
|
/** Fetch events from the local two-way cache; service returns local rows
|
|
23
44
|
* immediately and kicks a background refresh from Google. Next render
|
|
24
45
|
* (view-nav or user action) picks up the refreshed rows. No localStorage
|
|
25
46
|
* — everything lives in the service-side DB so phone / desktop share
|
|
26
47
|
* the same events. */
|
|
27
48
|
async function fetchUpcoming(from) {
|
|
28
|
-
const horizon = from.getTime() +
|
|
49
|
+
const horizon = from.getTime() + getHorizonDays() * 86400_000;
|
|
29
50
|
const rows = await getCalendarEvents(from.getTime(), horizon);
|
|
30
|
-
|
|
51
|
+
const showRecurring = getShowRecurring();
|
|
52
|
+
const filtered = showRecurring ? rows : rows.filter((r) => !r.recurringEventId);
|
|
53
|
+
return filtered.map((r) => ({
|
|
31
54
|
id: r.uuid,
|
|
32
55
|
title: r.title,
|
|
33
56
|
start: r.startMs,
|
|
@@ -36,6 +59,8 @@ async function fetchUpcoming(from) {
|
|
|
36
59
|
location: r.location,
|
|
37
60
|
notes: r.notes,
|
|
38
61
|
source: r.providerId ? "google" : "local",
|
|
62
|
+
recurringEventId: r.recurringEventId,
|
|
63
|
+
htmlLink: r.htmlLink,
|
|
39
64
|
}));
|
|
40
65
|
}
|
|
41
66
|
function formatDayHeader(d, today, tomorrow) {
|
|
@@ -81,13 +106,29 @@ function renderEvents(events) {
|
|
|
81
106
|
html += `<div class="cal-side-day">${escapeHtml(formatDayHeader(d, today, tomorrow))}</div>`;
|
|
82
107
|
lastDayKey = dayKey;
|
|
83
108
|
}
|
|
84
|
-
|
|
109
|
+
const recurMark = e.recurringEventId ? `<span class="cal-side-event-recur" title="Recurring event">↻</span>` : "";
|
|
110
|
+
const link = e.htmlLink || "";
|
|
111
|
+
html += `<div class="cal-side-event" data-id="${e.id}" data-link="${escapeHtml(link)}" ${link ? 'title="Click to open in Google Calendar"' : ""}>
|
|
85
112
|
<span class="cal-side-event-dot ${e.source === "google" ? "g" : "l"}"></span>
|
|
86
113
|
<span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
|
|
87
|
-
<span class="cal-side-event-title">${escapeHtml(e.title)}</span>
|
|
114
|
+
<span class="cal-side-event-title">${escapeHtml(e.title)}${recurMark}</span>
|
|
88
115
|
</div>`;
|
|
89
116
|
}
|
|
90
117
|
body.innerHTML = html;
|
|
118
|
+
// Click-to-open — interim per user 2026-04-23: route to Google Calendar's
|
|
119
|
+
// web UI via openExternal until we build an in-app event editor.
|
|
120
|
+
body.querySelectorAll(".cal-side-event").forEach(el => {
|
|
121
|
+
el.addEventListener("click", () => {
|
|
122
|
+
const link = el.dataset.link;
|
|
123
|
+
if (link) {
|
|
124
|
+
const api = window.mailxapi;
|
|
125
|
+
if (api?.openExternal)
|
|
126
|
+
api.openExternal(link);
|
|
127
|
+
else
|
|
128
|
+
window.open(link, "_blank");
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
91
132
|
}
|
|
92
133
|
async function renderTasks() {
|
|
93
134
|
const showDone = document.getElementById("cal-side-show-done")?.checked || false;
|
|
@@ -209,5 +250,53 @@ export function initCalendarSidebar() {
|
|
|
209
250
|
showDoneCb.__wired = true;
|
|
210
251
|
showDoneCb.addEventListener("change", () => renderTasks());
|
|
211
252
|
}
|
|
253
|
+
// Recurring-events filter toggle — hides expanded recurring-series
|
|
254
|
+
// instances when unchecked. Default on so new users see everything.
|
|
255
|
+
const recurCb = document.getElementById("cal-side-show-recurring");
|
|
256
|
+
if (recurCb && !recurCb.__wired) {
|
|
257
|
+
recurCb.__wired = true;
|
|
258
|
+
recurCb.checked = getShowRecurring();
|
|
259
|
+
recurCb.addEventListener("change", () => {
|
|
260
|
+
try {
|
|
261
|
+
localStorage.setItem(SHOW_RECURRING_PREF, String(recurCb.checked));
|
|
262
|
+
}
|
|
263
|
+
catch { /* */ }
|
|
264
|
+
refresh();
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// Horizon input — how many days ahead to list events. Bounded 1..365.
|
|
268
|
+
const horizonInput = document.getElementById("cal-side-horizon");
|
|
269
|
+
if (horizonInput && !horizonInput.__wired) {
|
|
270
|
+
horizonInput.__wired = true;
|
|
271
|
+
horizonInput.value = String(getHorizonDays());
|
|
272
|
+
const commit = () => {
|
|
273
|
+
const n = parseInt(horizonInput.value, 10);
|
|
274
|
+
const clamped = Number.isFinite(n) && n > 0 ? Math.min(365, Math.max(1, n)) : HORIZON_DEFAULT_DAYS;
|
|
275
|
+
horizonInput.value = String(clamped);
|
|
276
|
+
try {
|
|
277
|
+
localStorage.setItem(HORIZON_DAYS_PREF, String(clamped));
|
|
278
|
+
}
|
|
279
|
+
catch { /* */ }
|
|
280
|
+
refresh();
|
|
281
|
+
};
|
|
282
|
+
horizonInput.addEventListener("change", commit);
|
|
283
|
+
horizonInput.addEventListener("blur", commit);
|
|
284
|
+
}
|
|
285
|
+
// Subscribe to service events — when a background Google pull finishes
|
|
286
|
+
// and upserted/reconciled rows, re-render without waiting for a nav
|
|
287
|
+
// click. Uses the mailxapi.onEvent bus. Idempotent flag keeps multiple
|
|
288
|
+
// initCalendarSidebar calls from dup-subscribing.
|
|
289
|
+
if (!window.__mailxCalEventsWired) {
|
|
290
|
+
const api = window.mailxapi;
|
|
291
|
+
if (api?.onEvent) {
|
|
292
|
+
window.__mailxCalEventsWired = true;
|
|
293
|
+
api.onEvent((event) => {
|
|
294
|
+
if (event?.type === "calendarUpdated")
|
|
295
|
+
refresh();
|
|
296
|
+
else if (event?.type === "tasksUpdated")
|
|
297
|
+
renderTasks();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
212
301
|
}
|
|
213
302
|
//# sourceMappingURL=calendar-sidebar.js.map
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Message viewer component -- displays full message in sandboxed iframe.
|
|
3
3
|
* Subscribes to message-state: clears when selected becomes null.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact } from "../lib/api-client.js";
|
|
5
|
+
import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact, listContacts, upsertContact } from "../lib/api-client.js";
|
|
6
6
|
import { showContextMenu } from "./context-menu.js";
|
|
7
7
|
import * as state from "../lib/message-state.js";
|
|
8
8
|
/** Currently displayed message (for reply/forward) */
|
|
@@ -297,10 +297,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
297
297
|
items.push({
|
|
298
298
|
label: `Add to contacts: ${addr.address}`,
|
|
299
299
|
action: async () => {
|
|
300
|
-
|
|
301
|
-
await addContact(name, addr.address);
|
|
302
|
-
}
|
|
303
|
-
catch { /* ignore */ }
|
|
300
|
+
await showAddContactDialog(name, addr.address);
|
|
304
301
|
},
|
|
305
302
|
});
|
|
306
303
|
items.push({ label: "", action: () => { }, separator: true });
|
|
@@ -746,6 +743,84 @@ function escapeText(s) {
|
|
|
746
743
|
div.textContent = s;
|
|
747
744
|
return div.innerHTML;
|
|
748
745
|
}
|
|
746
|
+
/** Minimal add-contact modal: name + email + organization with a duplicate
|
|
747
|
+
* check (checks the contacts DB for an existing row with the same email
|
|
748
|
+
* and surfaces it so the user can update instead of creating a second
|
|
749
|
+
* row with a different name). Future: AI-extracted fields from the letter
|
|
750
|
+
* body populate the form before it opens. */
|
|
751
|
+
async function showAddContactDialog(nameIn, emailIn) {
|
|
752
|
+
let dup = null;
|
|
753
|
+
try {
|
|
754
|
+
const existing = await listContacts(emailIn, 1, 10);
|
|
755
|
+
const match = (existing?.items || []).find((c) => (c.email || "").toLowerCase() === emailIn.toLowerCase());
|
|
756
|
+
if (match)
|
|
757
|
+
dup = match;
|
|
758
|
+
}
|
|
759
|
+
catch { /* non-fatal — dialog still works without dup info */ }
|
|
760
|
+
const backdrop = document.createElement("div");
|
|
761
|
+
backdrop.className = "mailx-modal-backdrop";
|
|
762
|
+
const panel = document.createElement("div");
|
|
763
|
+
panel.className = "mailx-modal";
|
|
764
|
+
panel.innerHTML = `
|
|
765
|
+
<div class="mailx-modal-title">
|
|
766
|
+
<span class="mailx-modal-title-text">${dup ? "Update contact" : "Add contact"}</span>
|
|
767
|
+
<button type="button" class="mailx-modal-close" id="ac-close" aria-label="Close">×</button>
|
|
768
|
+
</div>
|
|
769
|
+
${dup ? `<div class="mailx-modal-info">Already in address book as <strong>${escapeText(dup.name || "(no name)")}</strong> (${escapeText(dup.source)}). Saving will update the name.</div>` : ""}
|
|
770
|
+
<label class="mailx-modal-label">Name
|
|
771
|
+
<input class="mailx-modal-input" id="ac-name" type="text" value="${escapeText(dup?.name || nameIn || "")}" autofocus>
|
|
772
|
+
</label>
|
|
773
|
+
<label class="mailx-modal-label">Email
|
|
774
|
+
<input class="mailx-modal-input" id="ac-email" type="email" value="${escapeText(emailIn)}" readonly>
|
|
775
|
+
</label>
|
|
776
|
+
<label class="mailx-modal-label">Organization <span style="color:var(--color-text-muted);font-size:0.85em">(optional)</span>
|
|
777
|
+
<input class="mailx-modal-input" id="ac-org" type="text" placeholder="">
|
|
778
|
+
</label>
|
|
779
|
+
<div class="mailx-modal-buttons">
|
|
780
|
+
<span class="mailx-modal-spacer"></span>
|
|
781
|
+
<button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
|
|
782
|
+
<button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">${dup ? "Update" : "Save"}</button>
|
|
783
|
+
</div>`;
|
|
784
|
+
backdrop.appendChild(panel);
|
|
785
|
+
document.body.appendChild(backdrop);
|
|
786
|
+
const close = () => backdrop.remove();
|
|
787
|
+
panel.querySelector("#ac-close").addEventListener("click", close);
|
|
788
|
+
panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
|
|
789
|
+
btn.addEventListener("click", async () => {
|
|
790
|
+
if (btn.dataset.action === "cancel") {
|
|
791
|
+
close();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const nameEl = panel.querySelector("#ac-name");
|
|
795
|
+
const emailEl = panel.querySelector("#ac-email");
|
|
796
|
+
btn.disabled = true;
|
|
797
|
+
btn.textContent = "Saving…";
|
|
798
|
+
try {
|
|
799
|
+
// upsertContact is the two-way cache path (enqueues a Google
|
|
800
|
+
// People push); for pure local-first addContact would also
|
|
801
|
+
// work but skips the Google sync. Use upsertContact so the
|
|
802
|
+
// row propagates to Google Contacts next drain tick.
|
|
803
|
+
await upsertContact(nameEl.value.trim(), emailEl.value.trim());
|
|
804
|
+
close();
|
|
805
|
+
}
|
|
806
|
+
catch (e) {
|
|
807
|
+
btn.disabled = false;
|
|
808
|
+
btn.textContent = dup ? "Update" : "Save";
|
|
809
|
+
alert(`Couldn't save: ${e?.message || e}`);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
const onKey = (e) => {
|
|
814
|
+
if (e.key === "Escape") {
|
|
815
|
+
close();
|
|
816
|
+
document.removeEventListener("keydown", onKey, true);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
document.addEventListener("keydown", onKey, true);
|
|
820
|
+
// addContact is kept as a legacy silent path (no-form) for any caller
|
|
821
|
+
// that still invokes it — currently none after this refactor.
|
|
822
|
+
void addContact;
|
|
823
|
+
}
|
|
749
824
|
function formatSize(bytes) {
|
|
750
825
|
if (bytes < 1024)
|
|
751
826
|
return `${bytes} B`;
|
package/client/index.html
CHANGED
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
<div class="tb-menu" id="settings-menu">
|
|
36
36
|
<button class="tb-btn" id="btn-settings">Settings</button>
|
|
37
37
|
<div class="tb-menu-dropdown" id="settings-dropdown" hidden>
|
|
38
|
+
<span class="tb-menu-label">Theme</span>
|
|
39
|
+
<label class="tb-menu-item"><input type="radio" name="opt-theme" value="system" id="opt-theme-system"> System</label>
|
|
40
|
+
<label class="tb-menu-item"><input type="radio" name="opt-theme" value="light" id="opt-theme-light"> Light</label>
|
|
41
|
+
<label class="tb-menu-item"><input type="radio" name="opt-theme" value="dark" id="opt-theme-dark"> Dark</label>
|
|
42
|
+
<hr class="tb-menu-sep">
|
|
38
43
|
<span class="tb-menu-label">Editor</span>
|
|
39
44
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
|
|
40
45
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
|
|
@@ -133,6 +138,7 @@
|
|
|
133
138
|
<button class="tb-btn" id="btn-forward" title="Forward">→</button>
|
|
134
139
|
<button class="tb-btn" id="btn-delete" title="Delete (Del)">🗑</button>
|
|
135
140
|
<button class="tb-btn" id="btn-spam" title="Mark as spam — move to configured spam folder" hidden>⚠</button>
|
|
141
|
+
<button class="tb-btn" id="btn-spam-report" title="Report as spam (append to ~/.mailx/spam.csv for later analysis)">🚫</button>
|
|
136
142
|
<button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
|
|
137
143
|
<button class="tb-btn" id="btn-mark-unread" title="Mark unread (R)">◉</button>
|
|
138
144
|
<button class="tb-btn" id="mv-view-thread" title="View thread (conversation)" hidden>💬</button>
|
|
@@ -166,6 +172,12 @@
|
|
|
166
172
|
</header>
|
|
167
173
|
<div class="cal-side-actions">
|
|
168
174
|
<button class="cal-side-new" id="cal-side-new" title="New event">+ New event</button>
|
|
175
|
+
<label class="cal-side-opt" title="Include expanded instances of recurring events">
|
|
176
|
+
<input type="checkbox" id="cal-side-show-recurring" checked> Show recurring
|
|
177
|
+
</label>
|
|
178
|
+
<label class="cal-side-opt" title="How many days ahead to list">
|
|
179
|
+
Horizon <input type="number" id="cal-side-horizon" min="1" max="365" step="1" style="width:4em">
|
|
180
|
+
</label>
|
|
169
181
|
</div>
|
|
170
182
|
<div class="cal-side-body" id="cal-side-body">
|
|
171
183
|
<div class="cal-side-empty">Loading…</div>
|
package/client/lib/api-client.js
CHANGED
|
@@ -170,6 +170,12 @@ export function deleteTask(uuid) {
|
|
|
170
170
|
export function drainStoreSync() {
|
|
171
171
|
return ipc().drainStoreSync?.();
|
|
172
172
|
}
|
|
173
|
+
/** Report the currently-viewed message as spam → appends a row to
|
|
174
|
+
* `~/.mailx/spam.csv`. Placeholder: no folder move, no flag change, no
|
|
175
|
+
* auto-delete. Training data for a smarter pass later. */
|
|
176
|
+
export function recordSpamReport(accountId, uid, folderId) {
|
|
177
|
+
return ipc().recordSpamReport?.(accountId, uid, folderId);
|
|
178
|
+
}
|
|
173
179
|
export function getOutboxStatus() {
|
|
174
180
|
return ipc().getOutboxStatus();
|
|
175
181
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -122,6 +122,9 @@
|
|
|
122
122
|
updateTask: function(uuid, patch) { return callNode("updateTask", { uuid: uuid, patch: patch }); },
|
|
123
123
|
deleteTask: function(uuid) { return callNode("deleteTask", { uuid: uuid }); },
|
|
124
124
|
drainStoreSync: function() { return callNode("drainStoreSync"); },
|
|
125
|
+
recordSpamReport: function(accountId, uid, folderId) {
|
|
126
|
+
return callNode("recordSpamReport", { accountId: accountId, uid: uid, folderId: folderId });
|
|
127
|
+
},
|
|
125
128
|
readJsoncFile: function(name) {
|
|
126
129
|
return callNode("readJsoncFile", { name: name });
|
|
127
130
|
},
|
|
@@ -900,6 +900,19 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
900
900
|
list empty when the selection is a singleton thread. */
|
|
901
901
|
.ml-row.thread-filter-hidden { display: none; }
|
|
902
902
|
|
|
903
|
+
/* Info banner inside a modal — used by the Add-contact dialog to surface
|
|
904
|
+
"already in address book" duplicate notices. Amber tone so it reads as
|
|
905
|
+
"heads up" not "error". */
|
|
906
|
+
.mailx-modal-info {
|
|
907
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
908
|
+
background: oklch(0.95 0.05 75);
|
|
909
|
+
border: 1px solid oklch(0.80 0.10 75);
|
|
910
|
+
border-radius: var(--radius-sm);
|
|
911
|
+
color: oklch(0.35 0.08 75);
|
|
912
|
+
font-size: var(--font-size-sm);
|
|
913
|
+
margin-bottom: var(--gap-sm);
|
|
914
|
+
}
|
|
915
|
+
|
|
903
916
|
/* Offline indicator — sits in the status bar; amber tone so it's visible
|
|
904
917
|
but doesn't scream (being offline is normal local-first behavior, not
|
|
905
918
|
an error). */
|
|
@@ -996,6 +1009,21 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
|
|
|
996
1009
|
.cal-side-event-dot.g { background: oklch(0.65 0.20 250); } /* google — blue */
|
|
997
1010
|
.cal-side-event-time { color: var(--color-text); min-width: 44px; font-variant-numeric: tabular-nums; }
|
|
998
1011
|
.cal-side-event-title { flex: 1; }
|
|
1012
|
+
.cal-side-event-recur {
|
|
1013
|
+
color: var(--color-text-muted);
|
|
1014
|
+
margin-left: 4px;
|
|
1015
|
+
font-size: 0.85em;
|
|
1016
|
+
opacity: 0.8;
|
|
1017
|
+
}
|
|
1018
|
+
.cal-side-opt {
|
|
1019
|
+
display: block;
|
|
1020
|
+
padding: 2px 0;
|
|
1021
|
+
font-size: 0.85em;
|
|
1022
|
+
color: var(--color-text-muted);
|
|
1023
|
+
cursor: pointer;
|
|
1024
|
+
}
|
|
1025
|
+
.cal-side-opt input[type=number] { margin-left: 4px; }
|
|
1026
|
+
.cal-side-event { cursor: pointer; }
|
|
999
1027
|
.cal-side-empty {
|
|
1000
1028
|
padding: var(--gap-md) var(--gap-sm);
|
|
1001
1029
|
color: var(--color-text-muted);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.385",
|
|
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.7",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.348",
|
|
28
28
|
"@bobfrankston/mailx-host": "^0.1.4",
|
|
29
29
|
"@capacitor/android": "^8.3.0",
|
|
30
30
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"@bobfrankston/iflow-node": "^0.1.7",
|
|
89
89
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
90
90
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
91
|
-
"@bobfrankston/msger": "^0.1.
|
|
91
|
+
"@bobfrankston/msger": "^0.1.348",
|
|
92
92
|
"@bobfrankston/mailx-host": "^0.1.4",
|
|
93
93
|
"@capacitor/android": "^8.3.0",
|
|
94
94
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -24,6 +24,11 @@ export interface GCalEvent {
|
|
|
24
24
|
location?: string;
|
|
25
25
|
description?: string;
|
|
26
26
|
etag?: string;
|
|
27
|
+
/** Set on instances expanded from a recurring series (singleEvents=true).
|
|
28
|
+
* Absent for singletons. Lets the UI filter out recurring expansions. */
|
|
29
|
+
recurringEventId?: string;
|
|
30
|
+
/** Link to open the event in Google Calendar web. */
|
|
31
|
+
htmlLink?: string;
|
|
27
32
|
}
|
|
28
33
|
export declare function listCalendarEvents(tokenProvider: TokenProvider, fromMs: number, toMs: number, calendarId?: string): Promise<GCalEvent[]>;
|
|
29
34
|
export declare function createCalendarEvent(tokenProvider: TokenProvider, event: any, calendarId?: string): Promise<GCalEvent>;
|
|
@@ -55,6 +60,8 @@ export declare function calendarEventToLocal(ev: GCalEvent, accountId: string):
|
|
|
55
60
|
location: string;
|
|
56
61
|
notes: string;
|
|
57
62
|
etag: string;
|
|
63
|
+
recurringEventId: string | undefined;
|
|
64
|
+
htmlLink: string | undefined;
|
|
58
65
|
};
|
|
59
66
|
export declare function localToCalendarEvent(local: {
|
|
60
67
|
title: string;
|
|
@@ -106,6 +106,8 @@ export function calendarEventToLocal(ev, accountId) {
|
|
|
106
106
|
startMs, endMs, allDay,
|
|
107
107
|
location: ev.location || "", notes: ev.description || "",
|
|
108
108
|
etag: ev.etag || "",
|
|
109
|
+
recurringEventId: ev.recurringEventId,
|
|
110
|
+
htmlLink: ev.htmlLink,
|
|
109
111
|
};
|
|
110
112
|
}
|
|
111
113
|
export function localToCalendarEvent(local) {
|
|
@@ -49,9 +49,14 @@ export declare class MailxService {
|
|
|
49
49
|
getPrimaryAccount(feature?: string): any;
|
|
50
50
|
private primaryTokenProvider;
|
|
51
51
|
/** Return cal events visible in [fromMs..toMs), refreshing from Google
|
|
52
|
-
* in the background
|
|
53
|
-
*
|
|
52
|
+
* in the background. Caller displays local results immediately; after
|
|
53
|
+
* the refresh completes the service emits `calendarUpdated` so the UI
|
|
54
|
+
* re-renders with pulled-in rows. Fire-and-forget-with-event, not
|
|
55
|
+
* fire-and-forget-and-pray. */
|
|
54
56
|
getCalendarEvents(fromMs: number, toMs: number): Promise<any[]>;
|
|
57
|
+
/** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
|
|
58
|
+
* server-side deletions. Returns true if anything changed so callers
|
|
59
|
+
* can decide whether to emit a refresh event. */
|
|
55
60
|
private refreshCalendarEvents;
|
|
56
61
|
createCalendarEventLocal(ev: {
|
|
57
62
|
title: string;
|
|
@@ -109,6 +114,18 @@ export declare class MailxService {
|
|
|
109
114
|
targetFolderId: number;
|
|
110
115
|
moved: number;
|
|
111
116
|
}>;
|
|
117
|
+
/** Append a spam report row to `~/.mailx/spam.csv` — placeholder mechanism
|
|
118
|
+
* per user 2026-04-23 ("let's make it smart later; no auto-delete until
|
|
119
|
+
* safety issues are addressed"). One row per click. Columns: timestamp
|
|
120
|
+
* (ms since epoch), ISO date, ISO time, accountId, Delivered-To, From
|
|
121
|
+
* address, Subject, eml file path. CSV fields RFC 4180-quoted so commas
|
|
122
|
+
* and quotes in subjects survive. No move, no flag change, no server
|
|
123
|
+
* hit — just the log. Useful as training data for a future classifier.
|
|
124
|
+
*/
|
|
125
|
+
recordSpamReport(accountId: string, uid: number, folderId: number): Promise<{
|
|
126
|
+
ok: true;
|
|
127
|
+
row: string;
|
|
128
|
+
}>;
|
|
112
129
|
undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
|
|
113
130
|
deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
|
|
114
131
|
createFolder(accountId: string, parentPath: string, name: string): Promise<void>;
|
|
@@ -459,30 +459,56 @@ export class MailxService {
|
|
|
459
459
|
};
|
|
460
460
|
}
|
|
461
461
|
/** Return cal events visible in [fromMs..toMs), refreshing from Google
|
|
462
|
-
* in the background
|
|
463
|
-
*
|
|
462
|
+
* in the background. Caller displays local results immediately; after
|
|
463
|
+
* the refresh completes the service emits `calendarUpdated` so the UI
|
|
464
|
+
* re-renders with pulled-in rows. Fire-and-forget-with-event, not
|
|
465
|
+
* fire-and-forget-and-pray. */
|
|
464
466
|
async getCalendarEvents(fromMs, toMs) {
|
|
465
467
|
const acct = this.getPrimaryAccount("calendar");
|
|
466
468
|
if (!acct)
|
|
467
469
|
return [];
|
|
468
|
-
|
|
469
|
-
|
|
470
|
+
this.refreshCalendarEvents(acct.id, fromMs, toMs)
|
|
471
|
+
.then(changed => {
|
|
472
|
+
if (changed)
|
|
473
|
+
this.imapManager.emit("calendarUpdated", { accountId: acct.id });
|
|
474
|
+
})
|
|
475
|
+
.catch(e => console.error(`[calendar] refresh failed: ${e.message}`));
|
|
470
476
|
return this.db.getCalendarEvents(acct.id, fromMs, toMs);
|
|
471
477
|
}
|
|
478
|
+
/** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
|
|
479
|
+
* server-side deletions. Returns true if anything changed so callers
|
|
480
|
+
* can decide whether to emit a refresh event. */
|
|
472
481
|
async refreshCalendarEvents(accountId, fromMs, toMs) {
|
|
473
482
|
const tp = await this.primaryTokenProvider("calendar");
|
|
474
483
|
const events = await gsync.listCalendarEvents(tp, fromMs, toMs);
|
|
484
|
+
let changed = false;
|
|
485
|
+
// Upsert by provider_id — dedup globally, not just within the window,
|
|
486
|
+
// so an event whose start moves outside the prior query range doesn't
|
|
487
|
+
// get a second row on the next pull.
|
|
488
|
+
const seenProviderIds = new Set();
|
|
475
489
|
for (const ev of events) {
|
|
476
490
|
const local = gsync.calendarEventToLocal(ev, accountId);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
491
|
+
seenProviderIds.add(ev.id);
|
|
492
|
+
const existing = this.db.getCalendarEventByProviderId(accountId, ev.id);
|
|
493
|
+
this.db.upsertCalendarEvent({ uuid: existing?.uuid, ...local });
|
|
494
|
+
changed = true;
|
|
495
|
+
}
|
|
496
|
+
// Server-side delete reconciliation: any local non-dirty row whose
|
|
497
|
+
// start falls in the queried window and whose provider_id wasn't
|
|
498
|
+
// returned must have been deleted on Google. Purge it. Dirty rows
|
|
499
|
+
// are local-only edits that haven't been pushed yet — don't touch.
|
|
500
|
+
const localWindow = this.db.getCalendarEvents(accountId, fromMs, toMs);
|
|
501
|
+
for (const row of localWindow) {
|
|
502
|
+
if (!row.providerId)
|
|
503
|
+
continue; // local-only, never pushed
|
|
504
|
+
if (row.dirty)
|
|
505
|
+
continue; // locally edited, pending push
|
|
506
|
+
if (seenProviderIds.has(row.providerId))
|
|
507
|
+
continue;
|
|
508
|
+
this.db.purgeCalendarEvent(row.uuid);
|
|
509
|
+
changed = true;
|
|
485
510
|
}
|
|
511
|
+
return changed;
|
|
486
512
|
}
|
|
487
513
|
async createCalendarEventLocal(ev) {
|
|
488
514
|
const acct = this.getPrimaryAccount("calendar");
|
|
@@ -532,18 +558,38 @@ export class MailxService {
|
|
|
532
558
|
const acct = this.getPrimaryAccount("tasks");
|
|
533
559
|
if (!acct)
|
|
534
560
|
return [];
|
|
535
|
-
this.refreshTasks(acct.id, includeCompleted)
|
|
561
|
+
this.refreshTasks(acct.id, includeCompleted)
|
|
562
|
+
.then(changed => {
|
|
563
|
+
if (changed)
|
|
564
|
+
this.imapManager.emit("tasksUpdated", { accountId: acct.id });
|
|
565
|
+
})
|
|
566
|
+
.catch(e => console.error(`[tasks] refresh failed: ${e.message}`));
|
|
536
567
|
return this.db.getTasks(acct.id, includeCompleted);
|
|
537
568
|
}
|
|
538
569
|
async refreshTasks(accountId, includeCompleted) {
|
|
539
570
|
const tp = await this.primaryTokenProvider("tasks");
|
|
540
571
|
const tasks = await gsync.listTasks(tp, "@default", includeCompleted);
|
|
541
572
|
const existing = this.db.getTasks(accountId, true);
|
|
573
|
+
let changed = false;
|
|
574
|
+
const seen = new Set();
|
|
542
575
|
for (const t of tasks) {
|
|
543
576
|
const local = gsync.taskToLocal(t, accountId);
|
|
544
577
|
const prior = existing.find(e => e.providerId === t.id);
|
|
545
578
|
this.db.upsertTask({ uuid: prior?.uuid, ...local });
|
|
579
|
+
seen.add(t.id);
|
|
580
|
+
changed = true;
|
|
581
|
+
}
|
|
582
|
+
// Server-side delete reconciliation: any non-dirty local task whose
|
|
583
|
+
// provider_id wasn't returned has been deleted on Google. Purge.
|
|
584
|
+
for (const row of existing) {
|
|
585
|
+
if (!row.providerId || row.dirty)
|
|
586
|
+
continue;
|
|
587
|
+
if (seen.has(row.providerId))
|
|
588
|
+
continue;
|
|
589
|
+
this.db.purgeTask(row.uuid);
|
|
590
|
+
changed = true;
|
|
546
591
|
}
|
|
592
|
+
return changed;
|
|
547
593
|
}
|
|
548
594
|
async createTaskLocal(t) {
|
|
549
595
|
const acct = this.getPrimaryAccount("tasks");
|
|
@@ -943,6 +989,56 @@ export class MailxService {
|
|
|
943
989
|
await this.moveMessages(accountId, uids, target.id);
|
|
944
990
|
return { targetFolderId: target.id, moved: uids.length };
|
|
945
991
|
}
|
|
992
|
+
/** Append a spam report row to `~/.mailx/spam.csv` — placeholder mechanism
|
|
993
|
+
* per user 2026-04-23 ("let's make it smart later; no auto-delete until
|
|
994
|
+
* safety issues are addressed"). One row per click. Columns: timestamp
|
|
995
|
+
* (ms since epoch), ISO date, ISO time, accountId, Delivered-To, From
|
|
996
|
+
* address, Subject, eml file path. CSV fields RFC 4180-quoted so commas
|
|
997
|
+
* and quotes in subjects survive. No move, no flag change, no server
|
|
998
|
+
* hit — just the log. Useful as training data for a future classifier.
|
|
999
|
+
*/
|
|
1000
|
+
async recordSpamReport(accountId, uid, folderId) {
|
|
1001
|
+
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1002
|
+
if (!env)
|
|
1003
|
+
throw new Error(`Message not found: ${accountId}/${uid}`);
|
|
1004
|
+
const bodyPath = env.bodyPath || "";
|
|
1005
|
+
// Prefer `body_path` (authoritative). `Delivered-To` isn't in the
|
|
1006
|
+
// envelope struct, so parse from the cached `.eml` if available.
|
|
1007
|
+
let deliveredTo = "";
|
|
1008
|
+
if (bodyPath) {
|
|
1009
|
+
try {
|
|
1010
|
+
const raw = fs.readFileSync(bodyPath, "utf-8").slice(0, 4096);
|
|
1011
|
+
const m = raw.match(/^Delivered-To:\s*(.+)$/mi);
|
|
1012
|
+
if (m)
|
|
1013
|
+
deliveredTo = m[1].trim();
|
|
1014
|
+
}
|
|
1015
|
+
catch { /* not fatal — leave blank */ }
|
|
1016
|
+
}
|
|
1017
|
+
const now = new Date();
|
|
1018
|
+
const isoDate = now.toISOString().slice(0, 10);
|
|
1019
|
+
const isoTime = now.toISOString().slice(11, 19);
|
|
1020
|
+
const fromAddr = env.from?.address || "";
|
|
1021
|
+
const subject = env.subject || "";
|
|
1022
|
+
const csvEscape = (s) => `"${String(s).replace(/"/g, '""')}"`;
|
|
1023
|
+
const row = [
|
|
1024
|
+
String(now.getTime()),
|
|
1025
|
+
csvEscape(isoDate),
|
|
1026
|
+
csvEscape(isoTime),
|
|
1027
|
+
csvEscape(accountId),
|
|
1028
|
+
csvEscape(deliveredTo),
|
|
1029
|
+
csvEscape(fromAddr),
|
|
1030
|
+
csvEscape(subject),
|
|
1031
|
+
csvEscape(bodyPath),
|
|
1032
|
+
].join(",") + "\n";
|
|
1033
|
+
const spamCsvPath = path.join(getConfigDir(), "spam.csv");
|
|
1034
|
+
// Write a header if the file doesn't exist yet so the CSV is self-describing.
|
|
1035
|
+
if (!fs.existsSync(spamCsvPath)) {
|
|
1036
|
+
fs.writeFileSync(spamCsvPath, "timestamp_ms,date,time,account,delivered_to,from,subject,eml_path\n", "utf-8");
|
|
1037
|
+
}
|
|
1038
|
+
fs.appendFileSync(spamCsvPath, row, "utf-8");
|
|
1039
|
+
console.log(` [spam] reported ${accountId}/${uid} → spam.csv`);
|
|
1040
|
+
return { ok: true, row };
|
|
1041
|
+
}
|
|
946
1042
|
async undeleteMessage(accountId, uid, folderId) {
|
|
947
1043
|
// Clear the tombstone first so a subsequent sync can re-import if
|
|
948
1044
|
// the server still has the row. Messages with no Message-ID just
|
|
@@ -123,6 +123,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
123
123
|
case "drainStoreSync":
|
|
124
124
|
await svc.drainStoreSync();
|
|
125
125
|
return { ok: true };
|
|
126
|
+
case "recordSpamReport":
|
|
127
|
+
return await svc.recordSpamReport(p.accountId, p.uid, p.folderId);
|
|
126
128
|
case "getOutboxStatus":
|
|
127
129
|
return svc.getOutboxStatus();
|
|
128
130
|
case "listQueuedOutgoing":
|
|
@@ -46,6 +46,8 @@ export declare class MailxDB {
|
|
|
46
46
|
notes?: string;
|
|
47
47
|
etag?: string;
|
|
48
48
|
dirty?: boolean;
|
|
49
|
+
recurringEventId?: string;
|
|
50
|
+
htmlLink?: string;
|
|
49
51
|
}): string;
|
|
50
52
|
getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
|
|
51
53
|
/** Lookup by uuid only — used by patch/delete paths that don't have an
|
|
@@ -54,6 +56,9 @@ export declare class MailxDB {
|
|
|
54
56
|
getTaskByUuid(uuid: string): any | null;
|
|
55
57
|
getDirtyCalendarEvents(accountId: string): any[];
|
|
56
58
|
private calendarRowToObject;
|
|
59
|
+
/** Find a calendar event by its Google Calendar event id (provider_id).
|
|
60
|
+
* Global lookup — not window-scoped — so repeat pulls dedup cleanly. */
|
|
61
|
+
getCalendarEventByProviderId(accountId: string, providerId: string): any | null;
|
|
57
62
|
markCalendarEventClean(uuid: string, providerId: string, etag: string): void;
|
|
58
63
|
deleteCalendarEventLocal(uuid: string): void;
|
|
59
64
|
purgeCalendarEvent(uuid: string): void;
|
|
@@ -244,6 +244,11 @@ export class MailxDB {
|
|
|
244
244
|
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
|
|
245
245
|
}
|
|
246
246
|
catch { /* already exists */ }
|
|
247
|
+
// calendar_events: recurring_event_id carries the Google Calendar
|
|
248
|
+
// series id when the event is an expanded instance of a recurrence.
|
|
249
|
+
// Filters like "hide recurring events" check this column.
|
|
250
|
+
this.addColumnIfMissing("calendar_events", "recurring_event_id", "TEXT");
|
|
251
|
+
this.addColumnIfMissing("calendar_events", "html_link", "TEXT");
|
|
247
252
|
// Backfill UUIDs for any pre-existing rows that were inserted before
|
|
248
253
|
// this column landed. One UPDATE + an id roundtrip per row — cheap
|
|
249
254
|
// at our row counts, runs once per DB upgrade.
|
|
@@ -375,8 +380,9 @@ export class MailxDB {
|
|
|
375
380
|
this.db.prepare(`
|
|
376
381
|
INSERT INTO calendar_events
|
|
377
382
|
(uuid, account_id, provider_id, calendar_id, title, start_ms, end_ms,
|
|
378
|
-
all_day, location, notes, etag, last_synced, dirty, deleted, updated_at
|
|
379
|
-
|
|
383
|
+
all_day, location, notes, etag, last_synced, dirty, deleted, updated_at,
|
|
384
|
+
recurring_event_id, html_link)
|
|
385
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
|
|
380
386
|
ON CONFLICT(uuid) DO UPDATE SET
|
|
381
387
|
account_id=excluded.account_id, provider_id=excluded.provider_id,
|
|
382
388
|
calendar_id=excluded.calendar_id, title=excluded.title,
|
|
@@ -384,8 +390,10 @@ export class MailxDB {
|
|
|
384
390
|
all_day=excluded.all_day, location=excluded.location,
|
|
385
391
|
notes=excluded.notes, etag=excluded.etag,
|
|
386
392
|
last_synced=excluded.last_synced, dirty=excluded.dirty,
|
|
387
|
-
updated_at=excluded.updated_at
|
|
388
|
-
|
|
393
|
+
updated_at=excluded.updated_at,
|
|
394
|
+
recurring_event_id=excluded.recurring_event_id,
|
|
395
|
+
html_link=excluded.html_link
|
|
396
|
+
`).run(uuid, ev.accountId, ev.providerId || null, ev.calendarId || "primary", ev.title, ev.startMs, ev.endMs, ev.allDay ? 1 : 0, ev.location || "", ev.notes || "", ev.etag || null, ev.dirty ? 0 : Date.now(), ev.dirty ? 1 : 0, Date.now(), ev.recurringEventId || null, ev.htmlLink || null);
|
|
389
397
|
return uuid;
|
|
390
398
|
}
|
|
391
399
|
getCalendarEvents(accountId, fromMs, toMs) {
|
|
@@ -418,8 +426,16 @@ export class MailxDB {
|
|
|
418
426
|
calendarId: r.calendar_id, title: r.title, startMs: r.start_ms,
|
|
419
427
|
endMs: r.end_ms, allDay: !!r.all_day, location: r.location, notes: r.notes,
|
|
420
428
|
etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
|
|
429
|
+
recurringEventId: r.recurring_event_id || null,
|
|
430
|
+
htmlLink: r.html_link || null,
|
|
421
431
|
};
|
|
422
432
|
}
|
|
433
|
+
/** Find a calendar event by its Google Calendar event id (provider_id).
|
|
434
|
+
* Global lookup — not window-scoped — so repeat pulls dedup cleanly. */
|
|
435
|
+
getCalendarEventByProviderId(accountId, providerId) {
|
|
436
|
+
const r = this.db.prepare("SELECT * FROM calendar_events WHERE account_id = ? AND provider_id = ?").get(accountId, providerId);
|
|
437
|
+
return r ? this.calendarRowToObject(r) : null;
|
|
438
|
+
}
|
|
423
439
|
markCalendarEventClean(uuid, providerId, etag) {
|
|
424
440
|
this.db.prepare(`
|
|
425
441
|
UPDATE calendar_events SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?
|