@bobfrankston/rmfmail 1.0.666 → 1.0.668

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/TODO.md +2 -0
  2. package/bin/mailx.js +114 -111
  3. package/bin/mailx.js.map +1 -1
  4. package/bin/mailx.ts +117 -95
  5. package/client/app.bundle.js +143 -13
  6. package/client/app.bundle.js.map +2 -2
  7. package/client/components/alarms.js +138 -16
  8. package/client/components/alarms.js.map +1 -1
  9. package/client/components/alarms.ts +138 -17
  10. package/client/components/calendar-sidebar.js +39 -3
  11. package/client/components/calendar-sidebar.js.map +1 -1
  12. package/client/components/calendar-sidebar.ts +39 -2
  13. package/client/compose/compose.bundle.js.map +1 -1
  14. package/client/index.html +6 -0
  15. package/client/lib/api-client.ts +1 -1
  16. package/client/styles/components.css +4 -0
  17. package/package.json +1 -1
  18. package/packages/mailx-host/index.d.ts +5 -0
  19. package/packages/mailx-host/index.d.ts.map +1 -1
  20. package/packages/mailx-host/index.js +5 -0
  21. package/packages/mailx-host/index.js.map +1 -1
  22. package/packages/mailx-host/index.ts +5 -0
  23. package/packages/mailx-host/package.json +1 -1
  24. package/packages/mailx-service/index.d.ts +1 -0
  25. package/packages/mailx-service/index.d.ts.map +1 -1
  26. package/packages/mailx-service/index.js +95 -9
  27. package/packages/mailx-service/index.js.map +1 -1
  28. package/packages/mailx-service/index.ts +93 -10
  29. package/packages/mailx-service/local-store.d.ts +4 -0
  30. package/packages/mailx-service/local-store.d.ts.map +1 -1
  31. package/packages/mailx-service/local-store.js +55 -1
  32. package/packages/mailx-service/local-store.js.map +1 -1
  33. package/packages/mailx-service/local-store.ts +51 -1
  34. package/packages/mailx-store/db.d.ts +1 -0
  35. package/packages/mailx-store/db.d.ts.map +1 -1
  36. package/packages/mailx-store/db.js +11 -4
  37. package/packages/mailx-store/db.js.map +1 -1
  38. package/packages/mailx-store/db.ts +13 -5
package/bin/mailx.ts CHANGED
@@ -23,8 +23,9 @@ import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import os from "node:os";
25
25
  import net from "node:net";
26
+ import { execSync } from "node:child_process";
26
27
  import { ports } from "@bobfrankston/miscinfo";
27
- import { showMessageBox, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";
28
+ import { showMessageBox, showMessageBoxEx, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";
28
29
 
29
30
  setAppName("rmfmail");
30
31
  // Prefer the .ico (Windows Explorer / taskbar-pin shortcut uses the embedded
@@ -225,7 +226,16 @@ const __selfVersion: string = (() => {
225
226
  })();
226
227
  const __instanceFile = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail", "instance.json");
227
228
 
228
- function readInstanceFile(): { pid: number; version: string; startedAt: number } | null {
229
+ interface InstanceFile {
230
+ pid: number;
231
+ version: string;
232
+ startedAt: number;
233
+ /** PIDs of children spawned by this daemon: msger WebView host,
234
+ * reminder popups, anything else we launched. Tracked so `-kill`
235
+ * can SIGTERM them too without resorting to name-based sweeps. */
236
+ childPids?: number[];
237
+ }
238
+ function readInstanceFile(): InstanceFile | null {
229
239
  try {
230
240
  const raw = fs.readFileSync(__instanceFile, "utf-8");
231
241
  const inst = JSON.parse(raw);
@@ -233,12 +243,29 @@ function readInstanceFile(): { pid: number; version: string; startedAt: number }
233
243
  } catch { /* missing or unreadable — treated as no instance */ }
234
244
  return null;
235
245
  }
236
- function writeInstanceFile(pid: number): void {
246
+ function writeInstanceFile(pid: number, childPids: number[] = []): void {
237
247
  try {
238
248
  fs.mkdirSync(path.dirname(__instanceFile), { recursive: true });
239
- fs.writeFileSync(__instanceFile, JSON.stringify({ pid, version: __selfVersion, startedAt: Date.now() }, null, 2));
249
+ const payload: InstanceFile = { pid, version: __selfVersion, startedAt: Date.now(), childPids };
250
+ fs.writeFileSync(__instanceFile, JSON.stringify(payload, null, 2));
240
251
  } catch { /* non-fatal */ }
241
252
  }
253
+ /** Add a child PID to the daemon's instance.json so a later `-kill`
254
+ * knows to reap it. Caller is expected to remove it (via
255
+ * removeChildPid) when the child exits naturally. */
256
+ function addChildPid(pid: number): void {
257
+ const inst = readInstanceFile();
258
+ if (!inst) return;
259
+ const set = new Set(inst.childPids || []);
260
+ set.add(pid);
261
+ writeInstanceFile(inst.pid, [...set]);
262
+ }
263
+ function removeChildPid(pid: number): void {
264
+ const inst = readInstanceFile();
265
+ if (!inst) return;
266
+ const next = (inst.childPids || []).filter(p => p !== pid);
267
+ writeInstanceFile(inst.pid, next);
268
+ }
242
269
  function clearInstanceFile(): void {
243
270
  try { fs.unlinkSync(__instanceFile); } catch { /* ignore */ }
244
271
  }
@@ -253,16 +280,17 @@ function pidAlive(pid: number): boolean {
253
280
  * with "already running". Verify it's actually us. Non-Windows: fall back
254
281
  * to pidAlive — POSIX PID reuse is rare enough that we don't bother. */
255
282
  function pidIsMailx(pid: number): boolean {
256
- if (!pidAlive(pid)) return false;
257
- if (process.platform !== "win32") return true;
258
- try {
259
- const { execSync } = require("node:child_process");
260
- const out = execSync(
261
- `powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \\"ProcessId=${pid}\\").CommandLine"`,
262
- { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
263
- ).toString();
264
- return /(?:rmfmail|mailx)[\\\/](?:bin|packages|app)|mailx-server/i.test(out);
265
- } catch { return false; }
283
+ // Cross-platform: trust instance.json as the source of identity.
284
+ // We registered this PID at startup; if it's still alive, treat it
285
+ // as ours. Earlier Windows-only check used PowerShell + CimInstance
286
+ // to grep the command line, which violated the cross-platform rule
287
+ // (see memory: feedback_cross_platform_strong_rule). The downside is
288
+ // a brief PID-reuse window after a crash: if Windows recycles the
289
+ // PID between crash and the next launch, our SIGTERM hits an
290
+ // unrelated process. Mitigation: daemon clears instance.json on
291
+ // graceful exit; stale-instance file only persists across hard
292
+ // crashes, which is rare.
293
+ return pidAlive(pid);
266
294
  }
267
295
 
268
296
  // Version-mismatch upgrade: if a daemon from an older version is running when
@@ -291,23 +319,17 @@ if (!isDaemon && !__isCommandInvocation && !__keepOthers) {
291
319
  if (inst && pidIsMailx(inst.pid) && inst.pid !== myPid) {
292
320
  try { process.kill(inst.pid, "SIGTERM"); killedPids.push(inst.pid); } catch { /* */ }
293
321
  }
294
- // Backstop sweep: catches stragglers not in instance.json. Slow (~2s
295
- // PowerShell+CIM startup) but only runs once per launch, and it's the
296
- // only way to guarantee no orphan windows survive.
297
- if (process.platform === "win32") {
298
- try {
299
- const { execSync } = require("node:child_process");
300
- const out = execSync(
301
- `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match '(?:rmfmail|mailx)[\\\\/](?:bin|packages)|(?:rmfmail|mailx)-server' } | Select-Object -ExpandProperty ProcessId"`,
302
- { encoding: "utf-8", windowsHide: true },
303
- ).toString();
304
- for (const line of out.split("\n").map((s: string) => s.trim()).filter((s: string) => /^\d+$/.test(s))) {
305
- const pid = Number(line);
306
- if (pid === myPid) continue;
307
- try { process.kill(pid, "SIGTERM"); if (!killedPids.includes(pid)) killedPids.push(pid); } catch { /* */ }
308
- }
309
- } catch { /* sweep failed — proceed; instance.json kill above usually covers it */ }
310
- }
322
+ // Replace-on-launch trusts the registered PID in instance.json. The
323
+ // graceful-shutdown handler in the prior daemon (this same file, below)
324
+ // is responsible for closing its own child msger/popup processes via
325
+ // handle.close() — name-based sweeps over `node.exe` / `msgernative.exe`
326
+ // / per-app exes are fragile and ALSO not portable (taskkill is
327
+ // Windows-only). If a daemon crashed without graceful shutdown, the
328
+ // instance file gets cleared next launch via the stale-PID check and
329
+ // the orphan stays until the user resolves it manually preferable to
330
+ // a sweep that risks killing an unrelated `mailx` / `rmfmail` process
331
+ // owned by a different user (e.g., a developer running it from a
332
+ // sibling worktree).
311
333
  // Brief wait + SIGKILL anything that didn't exit gracefully.
312
334
  if (killedPids.length) {
313
335
  const deadline = Date.now() + 2000;
@@ -371,7 +393,6 @@ function log(...msg: any[]): void { if (verbose) console.log("[rmfmail]", ...msg
371
393
  function isElevated(): boolean {
372
394
  try {
373
395
  if (process.platform === "win32") {
374
- const { execSync } = require("node:child_process");
375
396
  execSync("net session >nul 2>&1", { stdio: "ignore", windowsHide: true });
376
397
  return true;
377
398
  }
@@ -410,60 +431,41 @@ if (hasFlag("kill")) {
410
431
  const { execSync } = await import("node:child_process");
411
432
  let killed = 0;
412
433
 
413
- // Try graceful exit first
414
- try {
415
- execSync(`curl -s -m 2 http://localhost:${PORT}/api/exit`, { stdio: "pipe" });
416
- log("Sent graceful exit");
417
- execSync("timeout /t 1 /nobreak", { stdio: "pipe" });
418
- } catch { /* server may not be responding */ }
419
-
420
- if (process.platform === "win32") {
421
- // Kill by port
422
- try {
423
- const out = execSync(`netstat -ano | findstr :${PORT} | findstr LISTENING`, { encoding: "utf-8" }).trim();
424
- const pids = [...new Set(out.split("\n").map(l => l.trim().split(/\s+/).pop()).filter(Boolean))];
425
- for (const pid of pids) {
426
- try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (port ${PORT})`); killed++; } catch { /* */ }
427
- }
428
- } catch { /* no process on port */ }
429
-
430
- // Kill any node.exe running rmfmail (server or IPC service). Match
431
- // the legacy `mailx` path too — source tree dir is still
432
- // `Y:\dev\email\mailx\` and per-app exes from older installs are
433
- // under `%LOCALAPPDATA%\mailx\` until those get cleaned up.
434
- try {
435
- const ps = execSync(
436
- // Match both `\` and `/` path separators — node CommandLine
437
- // can show either depending on how it was invoked. The
438
- // earlier `\\packages` / `\\bin` variants missed forward-
439
- // slash paths and left a daemon running. The character
440
- // class [\\\\/] covers both. `(?:...)` keeps the regex
441
- // unanchored — substring match anywhere in CommandLine.
442
- `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match '(?:rmfmail|mailx)[\\\\/](?:bin|packages)|(?:rmfmail|mailx)-server' } | Select-Object -ExpandProperty ProcessId"`,
443
- { encoding: "utf-8" }
444
- ).trim();
445
- for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
446
- try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (rmfmail node process)`); killed++; } catch { /* */ }
447
- }
448
- } catch { /* */ }
449
-
450
- // Kill orphaned msgernative.exe windows. When the node process dies
451
- // without cascade-killing its WebView child (old crash, forced
452
- // taskkill, etc.), the msgernative.exe stays on screen and looks
453
- // like a live rmfmail. rmfmail -kill should leave no trace.
454
- // Scoped to exes launched for rmfmail by filtering CommandLine —
455
- // don't touch msger windows started by other apps (msga, bbs, etc.).
434
+ // Cross-platform kill via instance.json. Daemon writes its PID +
435
+ // child PIDs (msger WebView, popups) at startup and on each spawn;
436
+ // -kill reads them and SIGTERMs each. No name-based sweeps, no
437
+ // PowerShell, no taskkill — those were Windows-only AND fragile
438
+ // (could match unrelated processes). See memory:
439
+ // feedback_cross_platform_strong_rule.
440
+ const inst = readInstanceFile();
441
+ const targets: number[] = [];
442
+ if (inst && pidAlive(inst.pid)) targets.push(inst.pid);
443
+ for (const cp of (inst?.childPids || [])) {
444
+ if (pidAlive(cp)) targets.push(cp);
445
+ }
446
+ for (const pid of targets) {
456
447
  try {
457
- const ps = execSync(
458
- `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='msgernative.exe'\\" | Where-Object { $_.CommandLine -match 'rmfmail|mailx' -or $_.Path -match 'rmfmail|mailx' } | Select-Object -ExpandProperty ProcessId"`,
459
- { encoding: "utf-8" }
460
- ).trim();
461
- for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
462
- try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (rmfmail msgernative/WebView)`); killed++; } catch { /* */ }
448
+ process.kill(pid, "SIGTERM");
449
+ console.log(`SIGTERM PID ${pid}`);
450
+ killed++;
451
+ } catch (e: any) {
452
+ // ESRCH: stale PID, already dead. Anything else is unexpected.
453
+ if (e?.code !== "ESRCH") console.error(` kill ${pid}: ${e?.message || e}`);
454
+ }
455
+ }
456
+ // Wait briefly for graceful shutdown, then SIGKILL anything still alive.
457
+ if (targets.length) {
458
+ const deadline = Date.now() + 2000;
459
+ while (Date.now() < deadline && targets.some(p => pidAlive(p))) {
460
+ const sab = new SharedArrayBuffer(4);
461
+ Atomics.wait(new Int32Array(sab), 0, 0, 100);
462
+ }
463
+ for (const pid of targets) {
464
+ if (pidAlive(pid)) {
465
+ try { process.kill(pid, "SIGKILL"); console.log(`SIGKILL → PID ${pid}`); }
466
+ catch { /* */ }
463
467
  }
464
- } catch { /* */ }
465
- } else {
466
- try { execSync(`fuser -k ${PORT}/tcp`, { stdio: "pipe" }); console.log(`Killed process on port ${PORT}`); killed++; } catch { /* */ }
468
+ }
467
469
  }
468
470
 
469
471
  // Clean up stale SQLite WAL/SHM files
@@ -1308,9 +1310,22 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1308
1310
  // Inject the popup function so MailxService.showReminderPopup can spawn
1309
1311
  // an OS-level always-on-top window via mailx-host. Kept as injection
1310
1312
  // (not import) so mailx-service stays host-agnostic.
1313
+ //
1314
+ // Uses showMessageBoxEx so we get a MessageBoxHandle back with
1315
+ // `pid` and `close()`. The pid goes into instance.json's childPids
1316
+ // for the lifetime of the popup, so a `rmfmail -kill` from another
1317
+ // shell can SIGTERM it without name-based sweeps (cross-platform —
1318
+ // see memory: feedback_cross_platform_strong_rule). Remove on
1319
+ // resolution so the file doesn't accumulate stale PIDs.
1311
1320
  svc.setPopupFn(async (opts: any) => {
1312
- const r = await showMessageBox(opts);
1313
- return { button: r.button, closed: r.closed, dismissed: r.dismissed };
1321
+ const h = showMessageBoxEx(opts);
1322
+ if (typeof h.pid === "number") addChildPid(h.pid);
1323
+ try {
1324
+ const r = await h.result;
1325
+ return { button: r.button, closed: r.closed, dismissed: r.dismissed, form: (r as any).form };
1326
+ } finally {
1327
+ if (typeof h.pid === "number") removeChildPid(h.pid);
1328
+ }
1314
1329
  });
1315
1330
 
1316
1331
  // Open msger in service mode — custom protocol serves files from client dir
@@ -1378,17 +1393,24 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1378
1393
  escapeCloses: false,
1379
1394
  });
1380
1395
 
1381
- // Register ourselves as the live instance so subsequent `mailx` invocations
1382
- // can detect version-mismatch and upgrade us (see top of file). Clear on
1383
- // any of: SIGINT, SIGTERM, normal exit.
1396
+ // Register ourselves as the live instance so subsequent `rmfmail`
1397
+ // invocations can detect version-mismatch and upgrade us (see top of
1398
+ // file). Clear on any of: SIGINT, SIGTERM, normal exit.
1384
1399
  //
1385
1400
  // Critical: the SIGTERM handler must *close the WebView child process*
1386
- // (handle.close() → kills msgernative.exe) before Node exits. Without
1387
- // this, the auto-upgrade leaves the old WebView orphaned on screen and
1388
- // the user sees an apparently frozen "old mailx" while the new Node is
1389
- // trying to spawn a second one. Cascade-killing the child makes the
1390
- // version-mismatch auto-upgrade actually transparent to the user.
1391
- writeInstanceFile(process.pid);
1401
+ // (handle.close() → SIGTERM to the msger child) before Node exits.
1402
+ // Without this, the auto-upgrade leaves the old WebView orphaned on
1403
+ // screen and the user sees an apparently frozen "old rmfmail" while
1404
+ // the new Node is trying to spawn a second one. Cascade-closing the
1405
+ // child makes the version-mismatch auto-upgrade transparent.
1406
+ //
1407
+ // Child PIDs (the msger WebView host + any reminder popups) go into
1408
+ // instance.json so that a future `rmfmail -kill` can SIGTERM them
1409
+ // cross-platform without resorting to name-based process sweeps.
1410
+ const initialChildPids: number[] = [];
1411
+ const handleChild = (handle as any)?.pid ?? (handle as any)?.child?.pid;
1412
+ if (typeof handleChild === "number") initialChildPids.push(handleChild);
1413
+ writeInstanceFile(process.pid, initialChildPids);
1392
1414
  const __cleanupInstance = () => {
1393
1415
  // Only clear if WE are still the registered instance. Prevents the
1394
1416
  // restart-daemon sequence (clear → spawn → new daemon writes its
@@ -3426,7 +3426,8 @@ async function fetchUpcoming(from) {
3426
3426
  notes: r.notes,
3427
3427
  source: r.providerId ? "google" : "local",
3428
3428
  recurringEventId: r.recurringEventId,
3429
- htmlLink: r.htmlLink
3429
+ htmlLink: r.htmlLink,
3430
+ isHoliday: !!r.isHoliday
3430
3431
  }));
3431
3432
  }
3432
3433
  function formatDayHeader(d, today, tomorrow) {
@@ -3518,8 +3519,12 @@ function renderEvents(events) {
3518
3519
  }
3519
3520
  const recurMark = e.recurringEventId ? `<span class="cal-side-event-recur" title="Recurring event">\u21BB</span>` : "";
3520
3521
  const link = e.htmlLink || "";
3521
- html += `<div class="cal-side-event" data-id="${e.id}" data-link="${escapeHtml4(link)}" ${link ? 'title="Click to open in Google Calendar"' : ""}>
3522
- <span class="cal-side-event-dot ${e.source === "google" ? "g" : "l"}"></span>
3522
+ const holidayAttr = e.isHoliday ? ' data-holiday="1"' : "";
3523
+ const clickable = !e.isHoliday;
3524
+ const titleAttr = clickable && link ? 'title="Click to open in Google Calendar"' : "";
3525
+ const dataLink = clickable ? `data-link="${escapeHtml4(link)}"` : "";
3526
+ html += `<div class="cal-side-event" data-id="${e.id}"${holidayAttr} ${dataLink} ${titleAttr}>
3527
+ <span class="cal-side-event-dot ${e.isHoliday ? "h" : e.source === "google" ? "g" : "l"}"></span>
3523
3528
  <span class="cal-side-event-time">${escapeHtml4(formatTime(e))}</span>
3524
3529
  <span class="cal-side-event-title">${escapeHtml4(e.title)}${recurMark}</span>
3525
3530
  </div>`;
@@ -3908,6 +3913,28 @@ function initCalendarSidebar() {
3908
3913
  refresh();
3909
3914
  });
3910
3915
  }
3916
+ const wireHolidayCheckbox = (cbId, settingsKey) => {
3917
+ const cb = document.getElementById(cbId);
3918
+ if (!cb || cb.__wired)
3919
+ return;
3920
+ cb.__wired = true;
3921
+ getSettings().then((s) => {
3922
+ cb.checked = !!s?.calendar?.[settingsKey];
3923
+ }).catch(() => {
3924
+ });
3925
+ cb.addEventListener("change", async () => {
3926
+ try {
3927
+ const s = await getSettings();
3928
+ s.calendar = { ...s.calendar || {}, [settingsKey]: cb.checked };
3929
+ await saveSettings(s);
3930
+ } catch (e) {
3931
+ console.error(`[cal] ${settingsKey} save failed:`, e);
3932
+ }
3933
+ refresh();
3934
+ });
3935
+ };
3936
+ wireHolidayCheckbox("cal-side-show-holidays", "showHolidays");
3937
+ wireHolidayCheckbox("cal-side-show-jewish-holidays", "showJewishHolidays");
3911
3938
  const horizonInput = document.getElementById("cal-side-horizon");
3912
3939
  if (horizonInput && !horizonInput.__wired) {
3913
3940
  horizonInput.__wired = true;
@@ -4346,21 +4373,104 @@ async function firePopupForItem(item) {
4346
4373
  if (openPopups.has(item.uuid))
4347
4374
  return;
4348
4375
  openPopups.add(item.uuid);
4376
+ const buttons = ["snooze", "Dismiss"];
4377
+ if (item.htmlLink)
4378
+ buttons.push("Open");
4379
+ if (item.kind === "calendar")
4380
+ buttons.push("Delete");
4381
+ const icon = item.kind === "calendar" ? "\u{1F4C5}" : "\u2611";
4382
+ const kindLabel = item.kind === "calendar" ? "Calendar event" : "Task due";
4383
+ const presets = [
4384
+ { label: "5m", minutes: 5 },
4385
+ { label: "15m", minutes: 15 },
4386
+ { label: "30m", minutes: 30 },
4387
+ { label: "1h", minutes: 60 },
4388
+ { label: "2h", minutes: 120 },
4389
+ { label: "1d", minutes: 60 * 24 }
4390
+ ];
4391
+ const presetChips = presets.map((p) => `<button type="button" class="chip" data-snooze-min="${p.minutes}">${p.label}</button>`).join("");
4392
+ const actionBtns = ["Dismiss"];
4393
+ if (item.htmlLink)
4394
+ actionBtns.push("Open");
4395
+ if (item.kind === "calendar")
4396
+ actionBtns.push("Delete");
4397
+ const actionHtml = actionBtns.map((b) => `<button type="button" class="action" data-btn="${escapeHtml6(b)}">${escapeHtml6(b)}</button>`).join("");
4349
4398
  const html = `<!DOCTYPE html>
4350
4399
  <html><head><meta charset="utf-8"><style>
4351
- body { font: 13px system-ui, sans-serif; margin: 0; padding: 16px 18px; background: #fff; color: #222; }
4352
- .icon { display: inline-block; width: 18px; text-align: center; margin-right: 4px; }
4353
- .title { font-size: 1.05em; font-weight: 600; margin-bottom: 6px; }
4354
- .when { color: #555; font-size: 0.95em; margin-bottom: 4px; }
4400
+ html, body { height: 100%; }
4401
+ body { font: 13px system-ui, sans-serif; margin: 0; padding: 14px 16px; background: #fff; color: #222; display:flex; flex-direction:column; box-sizing:border-box; gap: 8px; }
4402
+ .icon { display: inline-block; width: 20px; text-align: center; margin-right: 4px; }
4403
+ .title { font-size: 1.1em; font-weight: 600; }
4404
+ .when { color: #555; font-size: 0.95em; }
4355
4405
  .kind { color: #888; font-size: 0.85em; }
4406
+ .spacer { flex: 1; }
4407
+ .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
4408
+ .row-label { color: #555; font-size: 0.85em; margin-right: 2px; }
4409
+ button { font: 13px system-ui, sans-serif; cursor: pointer; }
4410
+ .chip { padding: 3px 9px; border: 1px solid #ccc; border-radius: 12px; background: #f7f7f7; color: #222; }
4411
+ .chip:hover { background: #e8f0fe; border-color: #0066cc; color: #0066cc; }
4412
+ .custom { display: flex; align-items: center; gap: 4px; }
4413
+ .custom input[type=number] { width: 48px; padding: 2px 4px; border: 1px solid #ccc; border-radius: 3px; font: inherit; }
4414
+ .custom select { padding: 2px 4px; border: 1px solid #ccc; border-radius: 3px; font: inherit; background: #fff; }
4415
+ .custom button.ok { padding: 2px 8px; border: 1px solid #0066cc; border-radius: 3px; background: #0066cc; color: #fff; }
4416
+ .custom button.ok:hover { background: #0052a3; }
4417
+ .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
4418
+ .actions button.action { padding: 5px 14px; border: none; border-radius: 4px; background: #0066cc; color: #fff; min-width: 70px; }
4419
+ .actions button.action:hover { background: #0052a3; }
4420
+ .actions button.action[data-btn="Delete"] { background: #b00; }
4421
+ .actions button.action[data-btn="Delete"]:hover { background: #800; }
4356
4422
  </style></head><body>
4357
- <div class="title"><span class="icon">${item.kind === "calendar" ? "\u{1F4C5}" : "\u2611"}</span>${escapeHtml6(item.title)}</div>
4423
+ <div class="title"><span class="icon">${icon}</span>${escapeHtml6(item.title)}</div>
4358
4424
  <div class="when">${escapeHtml6(formatWhen(item.whenMs))}</div>
4359
- <div class="kind">${item.kind === "calendar" ? "Calendar event" : "Task due"}</div>
4425
+ <div class="kind">${kindLabel}</div>
4426
+ <div class="row">
4427
+ <span class="row-label">Snooze:</span>
4428
+ ${presetChips}
4429
+ </div>
4430
+ <div class="row custom">
4431
+ <span class="row-label">Custom:</span>
4432
+ <input type="number" id="customN" value="5" min="1" max="999">
4433
+ <select id="customUnit">
4434
+ <option value="1">minutes</option>
4435
+ <option value="60">hours</option>
4436
+ <option value="1440">days</option>
4437
+ </select>
4438
+ <button type="button" class="ok" id="customGo">Snooze</button>
4439
+ </div>
4440
+ <div class="spacer"></div>
4441
+ <div class="actions">${actionHtml}</div>
4442
+ <script>
4443
+ function send(payload) {
4444
+ try { window.ipc.postMessage(JSON.stringify(payload)); }
4445
+ catch (e) { try { window.close(); } catch (_) {} }
4446
+ }
4447
+ // Preset chips \u2192 snooze N minutes
4448
+ document.querySelectorAll("button[data-snooze-min]").forEach(function(b) {
4449
+ b.addEventListener("click", function() {
4450
+ var m = parseInt(b.getAttribute("data-snooze-min"), 10) || 15;
4451
+ send({ button: "snooze", form: { minutes: m } });
4452
+ });
4453
+ });
4454
+ // Custom snooze form \u2192 snooze N * unit-multiplier minutes
4455
+ var customGo = document.getElementById("customGo");
4456
+ var customN = document.getElementById("customN");
4457
+ var customUnit = document.getElementById("customUnit");
4458
+ function submitCustom() {
4459
+ var n = parseInt(customN.value, 10);
4460
+ if (!isFinite(n) || n < 1) n = 5;
4461
+ var mult = parseInt(customUnit.value, 10) || 1;
4462
+ send({ button: "snooze", form: { minutes: n * mult } });
4463
+ }
4464
+ customGo.addEventListener("click", submitCustom);
4465
+ customN.addEventListener("keydown", function(e) { if (e.key === "Enter") submitCustom(); });
4466
+ // Action buttons (Dismiss/Open/Delete) \u2192 discriminate by label.
4467
+ document.querySelectorAll("button[data-btn]").forEach(function(b) {
4468
+ b.addEventListener("click", function() {
4469
+ send({ button: b.getAttribute("data-btn") });
4470
+ });
4471
+ });
4472
+ <\/script>
4360
4473
  </body></html>`;
4361
- const buttons = ["Snooze 15m", "Snooze 1h", "Dismiss"];
4362
- if (item.htmlLink)
4363
- buttons.push("Open");
4364
4474
  const title = item.kind === "calendar" ? "Calendar reminder" : "Task reminder";
4365
4475
  const hasHost = !!window.mailxapi?.showReminderPopup;
4366
4476
  let r;
@@ -4370,7 +4480,10 @@ async function firePopupForItem(item) {
4370
4480
  title,
4371
4481
  html,
4372
4482
  buttons,
4373
- size: { width: 480, height: 210 }
4483
+ // Sized for the new snooze form (chips row + custom input
4484
+ // row + action buttons) plus title/when/kind. Width fits
4485
+ // "5m 15m 30m 1h 2h 1d" on one line in Segoe UI.
4486
+ size: { width: 560, height: 300 }
4374
4487
  });
4375
4488
  } else {
4376
4489
  r = await showInWebViewPopup({ title, html, buttons });
@@ -4398,6 +4511,14 @@ async function firePopupForItem(item) {
4398
4511
  firedThisSession.delete(item.uuid);
4399
4512
  snoozeItem(item, 2);
4400
4513
  break;
4514
+ case "snooze": {
4515
+ const m = Math.max(1, Math.floor(r.form?.minutes ?? 15));
4516
+ snoozeItem(item, m);
4517
+ break;
4518
+ }
4519
+ // Legacy literal labels — retained so a pre-upgrade popup whose
4520
+ // bundle still says "Snooze 15m" / "Snooze 1h" continues to work
4521
+ // through a daemon-side rebuild without a UI restart.
4401
4522
  case "Snooze 15m":
4402
4523
  snoozeItem(item, 15);
4403
4524
  break;
@@ -4411,6 +4532,15 @@ async function firePopupForItem(item) {
4411
4532
  saveDismissed(m);
4412
4533
  }
4413
4534
  break;
4535
+ case "Delete":
4536
+ {
4537
+ const m = loadDismissed();
4538
+ m[item.uuid] = true;
4539
+ saveDismissed(m);
4540
+ }
4541
+ firedThisSession.delete(item.uuid);
4542
+ deleteCalendarEvent(item.uuid).catch((e) => console.error(` [alarm] delete failed for ${item.uuid}: ${e?.message || e}`));
4543
+ break;
4414
4544
  default:
4415
4545
  snoozeItem(item, 15);
4416
4546
  break;