@bobfrankston/mailx 1.0.306 → 1.0.310

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/index.html CHANGED
@@ -40,6 +40,7 @@
40
40
  <label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
41
41
  <hr class="tb-menu-sep">
42
42
  <button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc">Edit config files...</button>
43
+ <button class="tb-menu-item" id="btn-about" title="Show version and build info">About mailx...</button>
43
44
  </div>
44
45
  </div>
45
46
  <span id="app-version" class="app-version">mailx</span>
@@ -161,6 +161,12 @@ export function readJsoncFile(name) {
161
161
  export function writeJsoncFile(name, content) {
162
162
  return ipc().writeJsoncFile?.(name, content);
163
163
  }
164
+ export function readConfigHelp(name) {
165
+ return ipc().readConfigHelp?.(name) ?? Promise.resolve({ content: "" });
166
+ }
167
+ export function unsubscribeOneClick(url) {
168
+ return ipc().unsubscribeOneClick?.(url);
169
+ }
164
170
  export function setupAccount(name, email, password) {
165
171
  return ipc().setupAccount?.(name, email, password);
166
172
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * mailxapi — IPC bridge injected into WebView by the Rust launcher.
3
- * Follows the msgapi pattern: callNode → Promise → _mailxapiResolve/Reject.
3
+ * Follows the msgapi pattern: callNode → Promise → _msgapiServiceResolve/Reject.
4
4
  *
5
5
  * When running in a browser (no IPC), this file is not loaded.
6
6
  * api-client.ts auto-detects and falls back to HTTP fetch.
@@ -31,8 +31,8 @@
31
31
  });
32
32
  }
33
33
 
34
- // Called by Rust to resolve promises
35
- window._mailxapiResolve = function(id, value) {
34
+ // Called by host (msger Rust / msgview preload) to resolve service-channel promises
35
+ window._msgapiServiceResolve = function(id, value) {
36
36
  var cb = _callbacks[id];
37
37
  if (!cb) return;
38
38
  delete _callbacks[id];
@@ -40,8 +40,8 @@
40
40
  cb.resolve(value);
41
41
  };
42
42
 
43
- // Called by Rust to reject promises
44
- window._mailxapiReject = function(id, error) {
43
+ // Called by host to reject service-channel promises
44
+ window._msgapiServiceReject = function(id, error) {
45
45
  var cb = _callbacks[id];
46
46
  if (!cb) return;
47
47
  delete _callbacks[id];
@@ -49,8 +49,8 @@
49
49
  cb.reject(new Error(error));
50
50
  };
51
51
 
52
- // Called by Rust to push events (new mail, sync progress, etc.)
53
- window._mailxapiEvent = function(event) {
52
+ // Called by host to push events (new mail, sync progress, etc.)
53
+ window._msgapiServiceEvent = function(event) {
54
54
  for (var i = 0; i < _eventHandlers.length; i++) {
55
55
  try { _eventHandlers[i](event); } catch(e) { /* ignore */ }
56
56
  }
@@ -109,6 +109,12 @@
109
109
  writeJsoncFile: function(name, content) {
110
110
  return callNode("writeJsoncFile", { name: name, content: content });
111
111
  },
112
+ readConfigHelp: function(name) {
113
+ return callNode("readConfigHelp", { name: name });
114
+ },
115
+ unsubscribeOneClick: function(url) {
116
+ return callNode("unsubscribeOneClick", { url: url });
117
+ },
112
118
  searchContacts: function(query) {
113
119
  return callNode("searchContacts", { query: query });
114
120
  },
@@ -562,6 +562,88 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
562
562
  outline: 2px solid var(--color-accent);
563
563
  outline-offset: -1px;
564
564
  }
565
+ .mailx-modal-input-error {
566
+ outline: 2px solid oklch(0.65 0.2 25) !important;
567
+ outline-offset: -1px;
568
+ background: color-mix(in oklch, oklch(0.65 0.2 25) 6%, var(--color-bg-surface));
569
+ }
570
+ .mailx-modal-input-error::selection {
571
+ background: oklch(0.65 0.2 25);
572
+ color: #fff;
573
+ }
574
+
575
+ /* Split content area — textarea on the left, help on the right */
576
+ .mailx-modal-split {
577
+ display: grid;
578
+ grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
579
+ gap: var(--gap-md);
580
+ min-height: 0;
581
+
582
+ &:has(.mailx-help-collapsed) {
583
+ grid-template-columns: minmax(0, 1fr) auto;
584
+ }
585
+ }
586
+ @media (max-width: 900px) {
587
+ .mailx-modal-split { grid-template-columns: 1fr; }
588
+ }
589
+ .mailx-modal-split-left, .mailx-modal-split-right {
590
+ display: flex;
591
+ flex-direction: column;
592
+ min-height: 0;
593
+ }
594
+
595
+ .mailx-help-panel {
596
+ background: var(--color-bg-surface);
597
+ border: 1px solid var(--color-border);
598
+ border-radius: var(--radius-sm);
599
+ padding: var(--gap-sm);
600
+ overflow: auto;
601
+ max-height: 60vh;
602
+ font-size: var(--font-size-sm);
603
+ line-height: 1.4;
604
+
605
+ &.mailx-help-collapsed .mailx-help-body { display: none; }
606
+ &.mailx-help-collapsed { max-height: none; padding: 4px; }
607
+ }
608
+ .mailx-help-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--gap-xs); }
609
+ .mailx-help-toggle {
610
+ background: none;
611
+ border: none;
612
+ color: var(--color-text);
613
+ font-weight: 600;
614
+ cursor: pointer;
615
+ font-size: var(--font-size-sm);
616
+ padding: 2px 4px;
617
+ }
618
+ .mailx-help-toggle:hover { background: var(--color-bg-hover); border-radius: var(--radius-sm); }
619
+
620
+ .mailx-help-body {
621
+ & h1, & h2, & h3 { margin-top: var(--gap-sm); margin-bottom: 4px; font-size: 1em; }
622
+ & h1 { font-size: 1.1em; }
623
+ & p { margin: 4px 0; }
624
+ & ul { margin: 4px 0 4px var(--gap-md); padding: 0; }
625
+ & li { margin: 2px 0; }
626
+ & code {
627
+ font-family: var(--font-mono);
628
+ font-size: 0.9em;
629
+ background: var(--color-bg);
630
+ padding: 1px 4px;
631
+ border-radius: 3px;
632
+ }
633
+ & a { color: var(--color-accent); }
634
+ }
635
+ .mailx-help-code {
636
+ font-family: var(--font-mono);
637
+ font-size: 12px;
638
+ background: var(--color-bg);
639
+ border: 1px solid var(--color-border);
640
+ border-radius: var(--radius-sm);
641
+ padding: var(--gap-xs);
642
+ overflow-x: auto;
643
+ margin: 4px 0;
644
+ white-space: pre;
645
+ }
646
+ .mailx-help-code code { background: none; padding: 0; }
565
647
  .mailx-modal-error {
566
648
  color: oklch(0.65 0.2 25);
567
649
  font-size: var(--font-size-sm);
@@ -593,6 +675,39 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
593
675
  font-weight: 500;
594
676
  }
595
677
  .mailx-modal-btn-primary:hover { filter: brightness(1.1); }
678
+
679
+ /* About dialog */
680
+ .mailx-about { font-size: var(--font-size-sm); line-height: 1.5; }
681
+ .mailx-about-dl {
682
+ display: grid;
683
+ grid-template-columns: auto 1fr;
684
+ gap: 4px var(--gap-md);
685
+ margin: 0;
686
+
687
+ & dt { color: var(--color-text-muted); font-weight: 500; }
688
+ & dd { margin: 0; word-break: break-word; }
689
+ }
690
+ .mailx-about-section {
691
+ font-weight: 600;
692
+ margin-top: var(--gap-sm);
693
+ color: var(--color-text-muted);
694
+ font-size: 0.8rem;
695
+ text-transform: uppercase;
696
+ letter-spacing: 0.05em;
697
+ }
698
+ .mailx-about-accounts ul {
699
+ margin: 4px 0 0 var(--gap-md);
700
+ padding: 0;
701
+ }
702
+ .mailx-about-foot {
703
+ margin-top: var(--gap-sm);
704
+ padding-top: var(--gap-sm);
705
+ border-top: 1px solid var(--color-border);
706
+ color: var(--color-text-muted);
707
+ font-size: 0.8rem;
708
+ text-align: center;
709
+ }
710
+
596
711
  .ml-subject {
597
712
  overflow: hidden;
598
713
  text-overflow: ellipsis;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.306",
3
+ "version": "1.0.310",
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,8 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.7",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
- "@bobfrankston/msger": "^0.1.327",
27
+ "@bobfrankston/msger": "^0.1.340",
28
+ "@bobfrankston/mailx-host": "^0.1.0",
28
29
  "@capacitor/android": "^8.3.0",
29
30
  "@capacitor/cli": "^8.3.0",
30
31
  "@capacitor/core": "^8.3.0",
@@ -65,6 +66,7 @@
65
66
  "@bobfrankston/miscinfo": "file:../../projects/npm/miscinfo",
66
67
  "@bobfrankston/oauthsupport": "file:../../projects/oauth/oauthsupport",
67
68
  "@bobfrankston/msger": "file:../../utils/msgx/msger",
69
+ "@bobfrankston/mailx-host": "file:packages/mailx-host",
68
70
  "@capacitor/android": "^8.3.0",
69
71
  "@capacitor/cli": "^8.3.0",
70
72
  "@capacitor/core": "^8.3.0",
@@ -86,7 +88,8 @@
86
88
  "@bobfrankston/iflow-node": "^0.1.7",
87
89
  "@bobfrankston/miscinfo": "^1.0.8",
88
90
  "@bobfrankston/oauthsupport": "^1.0.24",
89
- "@bobfrankston/msger": "^0.1.327",
91
+ "@bobfrankston/msger": "^0.1.340",
92
+ "@bobfrankston/mailx-host": "^0.1.0",
90
93
  "@capacitor/android": "^8.3.0",
91
94
  "@capacitor/cli": "^8.3.0",
92
95
  "@capacitor/core": "^8.3.0",
@@ -48,6 +48,9 @@ export declare function getMessage(params: {
48
48
  deliveredTo: string;
49
49
  returnPath: string;
50
50
  listUnsubscribe: string;
51
+ listUnsubscribeMail: string;
52
+ listUnsubscribeHttp: string;
53
+ listUnsubscribeOneClick: boolean;
51
54
  emlPath: string;
52
55
  id: number;
53
56
  accountId: string;
@@ -141,6 +141,9 @@ export async function getMessage(params) {
141
141
  let deliveredTo = "";
142
142
  let returnPath = "";
143
143
  let listUnsubscribe = "";
144
+ let listUnsubscribeMail = "";
145
+ let listUnsubscribeHttp = "";
146
+ let listUnsubscribeOneClick = false;
144
147
  const raw = await imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
145
148
  if (raw) {
146
149
  const parsed = await simpleParser(raw);
@@ -204,12 +207,9 @@ export async function getMessage(params) {
204
207
  }
205
208
  }
206
209
  returnPath = hdr("return-path").replace(/[<>]/g, "");
207
- // mailparser merges List-* headers into a "list" object
208
- const listHeaders = parsed.headers.get("list");
209
- if (listHeaders?.unsubscribe) {
210
- const unsub = listHeaders.unsubscribe;
211
- listUnsubscribe = unsub.url || (unsub.mail ? `mailto:${unsub.mail}` : "");
212
- }
210
+ ({ listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
211
+ parseListUnsubscribe(parsed.headers));
212
+ listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
213
213
  }
214
214
  if (bodyHtml && !allowRemote) {
215
215
  const allowList = loadAllowlist();
@@ -231,7 +231,45 @@ export async function getMessage(params) {
231
231
  // Build .eml file path for "View Source"
232
232
  const storePath = getStorePath();
233
233
  const emlPath = `${storePath}/${accountId}/${envelope.folderId}/${envelope.uid}.eml`;
234
- return { ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote, attachments, deliveredTo, returnPath, listUnsubscribe, emlPath };
234
+ return { ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote, attachments, deliveredTo, returnPath, listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick, emlPath };
235
+ }
236
+ /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
237
+ * Returns mailto URL, HTTPS URL, and whether one-click POST is allowed.
238
+ * mailparser only exposes ONE of mail/url even when both are present, so we
239
+ * also scan the raw header text for the full set of angle-bracketed URIs. */
240
+ function parseListUnsubscribe(headers) {
241
+ let mail = "";
242
+ let http = "";
243
+ let oneClick = false;
244
+ // Raw header scan — most reliable.
245
+ const raw = headers.get("list-unsubscribe");
246
+ const rawStr = typeof raw === "string" ? raw : (raw && typeof raw.text === "string" ? raw.text : "");
247
+ if (rawStr) {
248
+ const matches = rawStr.match(/<([^>]+)>/g) || [];
249
+ for (const m of matches) {
250
+ const url = m.slice(1, -1).trim();
251
+ if (!mail && /^mailto:/i.test(url))
252
+ mail = url;
253
+ else if (!http && /^https?:/i.test(url))
254
+ http = url;
255
+ }
256
+ }
257
+ // mailparser fallback — for builds where headers.get returns the parsed object.
258
+ if (!mail && !http) {
259
+ const listHeaders = headers.get("list");
260
+ if (listHeaders?.unsubscribe) {
261
+ const unsub = listHeaders.unsubscribe;
262
+ if (unsub.url)
263
+ http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
264
+ if (unsub.mail)
265
+ mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
266
+ }
267
+ }
268
+ const post = headers.get("list-unsubscribe-post");
269
+ const postStr = typeof post === "string" ? post : (post && typeof post.text === "string" ? post.text : "");
270
+ if (postStr && /one-?click/i.test(postStr))
271
+ oneClick = true;
272
+ return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
235
273
  }
236
274
  export async function updateFlags(params) {
237
275
  const envelope = db.getMessageByUid(params.accountId, params.uid);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * mailx-host — runtime WebView host selector.
3
+ *
4
+ * Today: msger on every platform.
5
+ * Future: lazy-load @bobfrankston/mailx-host-msgview for Mac and niche
6
+ * Linux systems that can't get webkit2gtk-4.1.
7
+ *
8
+ * Callers (bin/mailx.ts) import showMessageBox / showService / setAppName
9
+ * from here instead of @bobfrankston/msger directly, so the swap is a
10
+ * one-line change when msgview arrives.
11
+ */
12
+ import * as msger from "@bobfrankston/msger";
13
+ export type { MessageBoxOptions, MessageBoxResult, ServiceHandle } from "@bobfrankston/msger";
14
+ export type HostName = "msger" | "msgview";
15
+ export declare function selectHost(): HostName;
16
+ export declare const showMessageBox: typeof msger.showMessageBox;
17
+ export declare const showService: typeof msger.showService;
18
+ export declare const setAppName: typeof msger.setAppName;
19
+ export declare const hostName: HostName;
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * mailx-host — runtime WebView host selector.
3
+ *
4
+ * Today: msger on every platform.
5
+ * Future: lazy-load @bobfrankston/mailx-host-msgview for Mac and niche
6
+ * Linux systems that can't get webkit2gtk-4.1.
7
+ *
8
+ * Callers (bin/mailx.ts) import showMessageBox / showService / setAppName
9
+ * from here instead of @bobfrankston/msger directly, so the swap is a
10
+ * one-line change when msgview arrives.
11
+ */
12
+ import * as msger from "@bobfrankston/msger";
13
+ export function selectHost() {
14
+ const requested = (process.env.MAILX_HOST || "").toLowerCase();
15
+ if (requested === "msger" || requested === "msgview")
16
+ return requested;
17
+ if (process.platform === "darwin")
18
+ return "msgview";
19
+ return "msger";
20
+ }
21
+ const _hostName = selectHost();
22
+ if (_hostName !== "msger") {
23
+ throw new Error(`mailx-host: "${_hostName}" adapter not implemented yet — set MAILX_HOST=msger to fall back, ` +
24
+ `or implement @bobfrankston/mailx-host-msgview (see docs/host-abstraction-plan.md).`);
25
+ }
26
+ export const showMessageBox = msger.showMessageBox;
27
+ export const showService = msger.showService;
28
+ export const setAppName = msger.setAppName;
29
+ export const hostName = _hostName;
30
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@bobfrankston/mailx-host",
3
+ "version": "0.1.1",
4
+ "description": "Host abstraction for mailx — dispatches to msger or msgview",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "release": "npmglobalize"
11
+ },
12
+ "license": "ISC",
13
+ "dependencies": {
14
+ "@bobfrankston/msger": "file:../../../../utils/msgx/msger"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git@github.com:BobFrankston/mailx-host.git"
19
+ }
20
+ }
@@ -15,6 +15,15 @@ export declare class MailxService {
15
15
  getUnifiedInbox(page?: number, pageSize?: number): any;
16
16
  getMessages(accountId: string, folderId: number, page?: number, pageSize?: number, sort?: string, sortDir?: string, search?: string, flaggedOnly?: boolean): any;
17
17
  getMessage(accountId: string, uid: number, allowRemote?: boolean, folderId?: number): Promise<any>;
18
+ /** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
19
+ * HTTPS URL the message's List-Unsubscribe header advertised. Done server-
20
+ * side because the unsubscribe endpoint usually doesn't set CORS headers,
21
+ * so a browser-side fetch would be blocked. */
22
+ unsubscribeOneClick(url: string): Promise<{
23
+ ok: boolean;
24
+ status: number;
25
+ statusText: string;
26
+ }>;
18
27
  updateFlags(accountId: string, uid: number, flags: string[]): Promise<void>;
19
28
  allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): Promise<void>;
20
29
  search(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number): Promise<any>;
@@ -67,6 +76,9 @@ export declare class MailxService {
67
76
  * Names are whitelisted so the UI can't read arbitrary files.
68
77
  * `config.jsonc` is the local per-machine config (not cloud-synced). */
69
78
  readJsoncFile(name: string): Promise<string | null>;
79
+ /** Return the help section for a named config file, extracted from docs/config-help.md.
80
+ * Matches a level-2 heading whose text equals the filename. Returns markdown. */
81
+ readConfigHelp(name: string): Promise<string>;
70
82
  /** Write a JSONC config file. Validates that the content parses as JSONC
71
83
  * (loosely — strips comments/trailing commas) before writing. */
72
84
  writeJsoncFile(name: string, content: string): Promise<void>;
@@ -6,9 +6,46 @@
6
6
  import * as dns from "node:dns/promises";
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
11
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
10
12
  import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
11
13
  import { simpleParser } from "mailparser";
14
+ /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
15
+ * mailparser only exposes ONE of mail/url even when both are present, so we
16
+ * also scan the raw header text for the full set of angle-bracketed URIs. */
17
+ function parseListUnsubscribe(headers) {
18
+ let mail = "";
19
+ let http = "";
20
+ let oneClick = false;
21
+ const raw = headers.get("list-unsubscribe");
22
+ const rawStr = typeof raw === "string" ? raw : (raw && typeof raw.text === "string" ? raw.text : "");
23
+ if (rawStr) {
24
+ const matches = rawStr.match(/<([^>]+)>/g) || [];
25
+ for (const m of matches) {
26
+ const url = m.slice(1, -1).trim();
27
+ if (!mail && /^mailto:/i.test(url))
28
+ mail = url;
29
+ else if (!http && /^https?:/i.test(url))
30
+ http = url;
31
+ }
32
+ }
33
+ if (!mail && !http) {
34
+ const listHeaders = headers.get("list");
35
+ if (listHeaders?.unsubscribe) {
36
+ const unsub = listHeaders.unsubscribe;
37
+ if (unsub.url)
38
+ http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
39
+ if (unsub.mail)
40
+ mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
41
+ }
42
+ }
43
+ const post = headers.get("list-unsubscribe-post");
44
+ const postStr = typeof post === "string" ? post : (post && typeof post.text === "string" ? post.text : "");
45
+ if (postStr && /one-?click/i.test(postStr))
46
+ oneClick = true;
47
+ return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
48
+ }
12
49
  // ── Email provider detection (MX-based) ──
13
50
  const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
14
51
  const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
@@ -161,6 +198,9 @@ export class MailxService {
161
198
  let deliveredTo = "";
162
199
  let returnPath = "";
163
200
  let listUnsubscribe = "";
201
+ let listUnsubscribeMail = "";
202
+ let listUnsubscribeHttp = "";
203
+ let listUnsubscribeOneClick = false;
164
204
  if (raw) {
165
205
  const parsed2 = await simpleParser(raw);
166
206
  const hdr = (key) => {
@@ -211,19 +251,32 @@ export class MailxService {
211
251
  }
212
252
  }
213
253
  returnPath = hdr("return-path").replace(/[<>]/g, "");
214
- const listHeaders = parsed2.headers.get("list");
215
- if (listHeaders?.unsubscribe) {
216
- const unsub = listHeaders.unsubscribe;
217
- listUnsubscribe = unsub.url || (unsub.mail ? `mailto:${unsub.mail}` : "");
218
- }
254
+ ({ listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
255
+ parseListUnsubscribe(parsed2.headers));
256
+ listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
219
257
  }
220
258
  const storePath = getStorePath();
221
259
  const emlPath = path.join(storePath, accountId, String(envelope.folderId), `${envelope.uid}.eml`);
222
260
  return {
223
261
  ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
224
- attachments, emlPath, deliveredTo, returnPath, listUnsubscribe,
262
+ attachments, emlPath, deliveredTo, returnPath,
263
+ listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
225
264
  };
226
265
  }
266
+ /** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
267
+ * HTTPS URL the message's List-Unsubscribe header advertised. Done server-
268
+ * side because the unsubscribe endpoint usually doesn't set CORS headers,
269
+ * so a browser-side fetch would be blocked. */
270
+ async unsubscribeOneClick(url) {
271
+ if (!/^https:\/\//i.test(url))
272
+ throw new Error("one-click unsubscribe requires an https URL");
273
+ const resp = await fetch(url, {
274
+ method: "POST",
275
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
276
+ body: "List-Unsubscribe=One-Click",
277
+ });
278
+ return { ok: resp.ok, status: resp.status, statusText: resp.statusText };
279
+ }
227
280
  async updateFlags(accountId, uid, flags) {
228
281
  const envelope = this.db.getMessageByUid(accountId, uid);
229
282
  await this.imapManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
@@ -677,6 +730,45 @@ export class MailxService {
677
730
  const { cloudRead } = await import("@bobfrankston/mailx-settings");
678
731
  return cloudRead(name);
679
732
  }
733
+ /** Return the help section for a named config file, extracted from docs/config-help.md.
734
+ * Matches a level-2 heading whose text equals the filename. Returns markdown. */
735
+ async readConfigHelp(name) {
736
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc"];
737
+ if (!WHITELIST.includes(name))
738
+ return "";
739
+ // Look in the repo root (dev) and in the installed package dir (production).
740
+ const candidates = [
741
+ path.join(__dirname, "..", "..", "docs", "config-help.md"),
742
+ path.join(__dirname, "config-help.md"),
743
+ ];
744
+ let md = "";
745
+ for (const p of candidates) {
746
+ try {
747
+ md = fs.readFileSync(p, "utf-8");
748
+ break;
749
+ }
750
+ catch { /* try next */ }
751
+ }
752
+ if (!md)
753
+ return "";
754
+ const lines = md.split(/\r?\n/);
755
+ let inSection = false;
756
+ const out = [];
757
+ for (const line of lines) {
758
+ const h2 = /^##\s+(.+?)\s*$/.exec(line);
759
+ if (h2) {
760
+ if (inSection)
761
+ break; // next section — stop
762
+ if (h2[1].trim() === name) {
763
+ inSection = true;
764
+ continue;
765
+ }
766
+ }
767
+ if (inSection)
768
+ out.push(line);
769
+ }
770
+ return out.join("\n").trim();
771
+ }
680
772
  /** Write a JSONC config file. Validates that the content parses as JSONC
681
773
  * (loosely — strips comments/trailing commas) before writing. */
682
774
  async writeJsoncFile(name, content) {
@@ -105,6 +105,10 @@ async function dispatchAction(svc, action, p) {
105
105
  case "writeJsoncFile":
106
106
  await svc.writeJsoncFile(p.name, p.content);
107
107
  return { ok: true };
108
+ case "readConfigHelp":
109
+ return { content: await svc.readConfigHelp(p.name) };
110
+ case "unsubscribeOneClick":
111
+ return await svc.unsubscribeOneClick(p.url);
108
112
  // Settings
109
113
  case "getSettings":
110
114
  return svc.getSettings();
@@ -270,7 +270,7 @@ export class MailxDB {
270
270
  }
271
271
  getFolders(accountId) {
272
272
  const rows = this.db.prepare("SELECT * FROM folders WHERE account_id = ? ORDER BY path").all(accountId);
273
- return rows.map(r => ({
273
+ const folders = rows.map(r => ({
274
274
  id: r.id,
275
275
  accountId: r.account_id,
276
276
  path: r.path,
@@ -281,6 +281,35 @@ export class MailxDB {
281
281
  unreadCount: r.unread_count,
282
282
  children: []
283
283
  }));
284
+ // Sub-folder inheritance: a folder under Drafts/Sent/Trash/Junk/Archive
285
+ // inherits the parent's special role for UI purposes (column layout,
286
+ // open-in-compose, etc.). INBOX is intentionally excluded — its sub-
287
+ // folders are typically filtered mail and inheriting "inbox" would
288
+ // inflate All Inboxes. findFolder() still resolves to the canonical
289
+ // folder because rows are sorted by path and the parent sorts before
290
+ // its children.
291
+ const INHERITABLE = new Set(["sent", "drafts", "trash", "junk", "archive"]);
292
+ const roleByPath = new Map();
293
+ for (const f of folders) {
294
+ if (f.specialUse && INHERITABLE.has(f.specialUse)) {
295
+ roleByPath.set(f.path, f.specialUse);
296
+ }
297
+ }
298
+ for (const f of folders) {
299
+ if (f.specialUse)
300
+ continue;
301
+ const delim = f.delimiter || "/";
302
+ const parts = f.path.split(delim);
303
+ while (parts.length > 1) {
304
+ parts.pop();
305
+ const role = roleByPath.get(parts.join(delim));
306
+ if (role) {
307
+ f.specialUse = role;
308
+ break;
309
+ }
310
+ }
311
+ }
312
+ return folders;
284
313
  }
285
314
  deleteFolder(folderId) {
286
315
  this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
@@ -313,10 +342,23 @@ export class MailxDB {
313
342
  if (msg.providerId && !existing.provider_id) {
314
343
  this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
315
344
  }
316
- this.db.prepare(`
317
- UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
318
- WHERE id = ?
319
- `).run(JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id);
345
+ // Only overwrite body_path / preview when the caller actually has a
346
+ // body. Metadata-only syncs (Gmail API storeApiMessages, IMAP
347
+ // header-only fetches) pass bodyPath: "" and would otherwise wipe
348
+ // the path that prefetch just wrote, causing prefetch to re-download
349
+ // every message every cycle.
350
+ if (msg.bodyPath) {
351
+ this.db.prepare(`
352
+ UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
353
+ WHERE id = ?
354
+ `).run(JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id);
355
+ }
356
+ else {
357
+ this.db.prepare(`
358
+ UPDATE messages SET flags_json = ?, cached_at = ?
359
+ WHERE id = ?
360
+ `).run(JSON.stringify(msg.flags), Date.now(), existing.id);
361
+ }
320
362
  return existing.id;
321
363
  }
322
364
  const toText = msg.to.map(a => `${a.name} ${a.address}`).join(" ");