@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/README.md +1 -0
- package/bin/mailx.js +1 -1
- package/client/app.js +336 -7
- package/client/components/message-viewer.js +76 -18
- package/client/compose/compose.css +50 -0
- package/client/compose/compose.js +49 -11
- package/client/index.html +1 -0
- package/client/lib/api-client.js +6 -0
- package/client/lib/mailxapi.js +13 -7
- package/client/styles/components.css +115 -0
- package/package.json +6 -3
- package/packages/mailx-core/index.d.ts +3 -0
- package/packages/mailx-core/index.js +45 -7
- package/packages/mailx-host/index.d.ts +20 -0
- package/packages/mailx-host/index.js +30 -0
- package/packages/mailx-host/package.json +20 -0
- package/packages/mailx-service/index.d.ts +12 -0
- package/packages/mailx-service/index.js +98 -6
- package/packages/mailx-service/jsonrpc.js +4 -0
- package/packages/mailx-store/db.js +47 -5
- package/packages/mailx-store-web/android-bootstrap.js +91 -2
- package/packages/mailx-store-web/db.js +4 -1
- package/packages/mailx-store-web/main-thread-host.js +2 -2
- package/tempfix.cmd +77 -0
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>
|
package/client/lib/api-client.js
CHANGED
|
@@ -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
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -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 →
|
|
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.
|
|
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
|
|
44
|
-
window.
|
|
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
|
|
53
|
-
window.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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(" ");
|