@bobfrankston/mailx-types 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-rules.js +33 -0
- package/contact-rules.d.ts +11 -0
- package/contact-rules.js +13 -0
- package/contact-rules.jsonc +28 -0
- package/groups.d.ts +38 -0
- package/groups.js +99 -0
- package/index.d.ts +130 -3
- package/index.js +246 -1
- package/package.json +5 -1
package/build-rules.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generates contact-rules.ts from contact-rules.jsonc.
|
|
4
|
+
*
|
|
5
|
+
* The .jsonc is the source-of-truth (editable, comments, versioned in
|
|
6
|
+
* source). The .ts is what mailx-store and mailx-store-web import — Android
|
|
7
|
+
* has no `fs` to parse JSONC at runtime, and inlining the parsed object
|
|
8
|
+
* sidesteps that constraint cleanly.
|
|
9
|
+
*
|
|
10
|
+
* Run via `npm run build` (prebuild hook) or directly: `node build-rules.js`.
|
|
11
|
+
* Both .jsonc and generated .ts are checked in; the .ts is regenerated on
|
|
12
|
+
* every build so its mtime is deterministic relative to the .jsonc.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
const __dirname = import.meta.dirname;
|
|
18
|
+
const srcPath = path.join(__dirname, "contact-rules.jsonc");
|
|
19
|
+
const outPath = path.join(__dirname, "contact-rules.ts");
|
|
20
|
+
|
|
21
|
+
const raw = fs.readFileSync(srcPath, "utf-8");
|
|
22
|
+
// Loose JSONC handler: strip // line comments and trailing commas. Avoids
|
|
23
|
+
// pulling in jsonc-parser as a dep for what's effectively config.
|
|
24
|
+
const stripped = raw
|
|
25
|
+
.replace(/^\s*\/\/.*$/gm, "")
|
|
26
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
27
|
+
const obj = JSON.parse(stripped);
|
|
28
|
+
|
|
29
|
+
const banner = "// AUTO-GENERATED from contact-rules.jsonc — do not edit.\n" +
|
|
30
|
+
"// Edit the .jsonc and run `node build-rules.js` (or `npm run build`).\n\n";
|
|
31
|
+
const ts = banner + `export const CONTACT_RULES = ${JSON.stringify(obj, null, 4)} as const;\n`;
|
|
32
|
+
fs.writeFileSync(outPath, ts);
|
|
33
|
+
console.log(`[mailx-types] regenerated ${path.basename(outPath)}`);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const CONTACT_RULES: {
|
|
2
|
+
readonly rulesVersion: "v3-domain-oneoff";
|
|
3
|
+
readonly junk: {
|
|
4
|
+
readonly localExact: "^(no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|abuse|automated|bounce(s|d)?|list-?(server|admin|owner|manager)?|notification|notifications?|admin@.*automated|root|daemon|nobody|undisclosed)$";
|
|
5
|
+
readonly localSuffix: "(-bounces|\\+bounces|-noreply|-no-reply|-notifications?|-mailer)$";
|
|
6
|
+
readonly localPrefix: "^(no-?reply|noreply|do-?not-?reply|donotreply|notifications?|alerts?|bounces?|mailer)[-_+]";
|
|
7
|
+
readonly localOneoff: "^[0-9a-f]{4}\\.[0-9a-f]{4}(\\.[0-9a-z]{6})?$";
|
|
8
|
+
readonly domain: "^(txt\\.voice\\.google\\.com|reply\\.facebook\\.com|reply\\.linkedin\\.com)$";
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=contact-rules.d.ts.map
|
package/contact-rules.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// AUTO-GENERATED from contact-rules.jsonc — do not edit.
|
|
2
|
+
// Edit the .jsonc and run `node build-rules.js` (or `npm run build`).
|
|
3
|
+
export const CONTACT_RULES = {
|
|
4
|
+
"rulesVersion": "v3-domain-oneoff",
|
|
5
|
+
"junk": {
|
|
6
|
+
"localExact": "^(no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|abuse|automated|bounce(s|d)?|list-?(server|admin|owner|manager)?|notification|notifications?|admin@.*automated|root|daemon|nobody|undisclosed)$",
|
|
7
|
+
"localSuffix": "(-bounces|\\+bounces|-noreply|-no-reply|-notifications?|-mailer)$",
|
|
8
|
+
"localPrefix": "^(no-?reply|noreply|do-?not-?reply|donotreply|notifications?|alerts?|bounces?|mailer)[-_+]",
|
|
9
|
+
"localOneoff": "^[0-9a-f]{4}\\.[0-9a-f]{4}(\\.[0-9a-z]{6})?$",
|
|
10
|
+
"domain": "^(txt\\.voice\\.google\\.com|reply\\.facebook\\.com|reply\\.linkedin\\.com)$"
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=contact-rules.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// mailx — global contact-filter rules (data, NOT user prefs).
|
|
2
|
+
//
|
|
3
|
+
// Edit the regex strings below; run `node build-rules.js` from this package
|
|
4
|
+
// (or `npm run build`) to regenerate `contact-rules.ts`, which is what
|
|
5
|
+
// mailx-store and mailx-store-web import. The .ts export is generated;
|
|
6
|
+
// the .jsonc you're reading is the source of truth.
|
|
7
|
+
//
|
|
8
|
+
// rulesVersion: bump whenever the patterns tighten. The contacts table
|
|
9
|
+
// runs a one-shot purge keyed off this string so historical rows under
|
|
10
|
+
// older rules get cleaned up exactly once per device.
|
|
11
|
+
//
|
|
12
|
+
// Rule shape:
|
|
13
|
+
// localExact — whole local-part match (e.g. "noreply", "mailer-daemon")
|
|
14
|
+
// localSuffix — local-part ends with (e.g. "-bounces", "+bounces")
|
|
15
|
+
// localPrefix — local-part starts with + separator (e.g. "noreply-<rand>")
|
|
16
|
+
// localOneoff — full-shape match for per-message gateway addresses
|
|
17
|
+
// (e.g. "16179691997.15082868100.nfblcbll1x")
|
|
18
|
+
// domain — entire domain match (Google Voice SMS, reply.fb.com)
|
|
19
|
+
{
|
|
20
|
+
"rulesVersion": "v3-domain-oneoff",
|
|
21
|
+
"junk": {
|
|
22
|
+
"localExact": "^(no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|abuse|automated|bounce(s|d)?|list-?(server|admin|owner|manager)?|notification|notifications?|admin@.*automated|root|daemon|nobody|undisclosed)$",
|
|
23
|
+
"localSuffix": "(-bounces|\\+bounces|-noreply|-no-reply|-notifications?|-mailer)$",
|
|
24
|
+
"localPrefix": "^(no-?reply|noreply|do-?not-?reply|donotreply|notifications?|alerts?|bounces?|mailer)[-_+]",
|
|
25
|
+
"localOneoff": "^[0-9a-f]{4,}\\.[0-9a-f]{4,}(\\.[0-9a-z]{6,})?$",
|
|
26
|
+
"domain": "^(txt\\.voice\\.google\\.com|reply\\.facebook\\.com|reply\\.linkedin\\.com)$"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/groups.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group expansion — turns a recipient string with group names into a flat
|
|
3
|
+
* deduplicated address list. Shared between desktop (mailx-service) and
|
|
4
|
+
* Android (mailx-store-web) so send paths produce the same result.
|
|
5
|
+
*
|
|
6
|
+
* Group source: contacts.jsonc → groups: Record<string, string[]>. Each
|
|
7
|
+
* value is an array of either email addresses ("a@b" or "Name <a@b>") or
|
|
8
|
+
* names of other groups (recursive aliasing). Cycles are detected; depth
|
|
9
|
+
* is capped at 10 nested levels.
|
|
10
|
+
*/
|
|
11
|
+
/** Raw recipient string from a To/Cc/Bcc field; may contain group names. */
|
|
12
|
+
export type RecipientToken = string;
|
|
13
|
+
/** Map of group name → member list (each entry: address or another group name). */
|
|
14
|
+
export type GroupMap = Record<string, string[]>;
|
|
15
|
+
/** Result of expanding a recipient string. */
|
|
16
|
+
export interface ExpansionResult {
|
|
17
|
+
/** Flat unique address list (preserving order of first occurrence). */
|
|
18
|
+
addresses: string[];
|
|
19
|
+
/** Group names that couldn't be resolved (typo / cycle) — surface in UI. */
|
|
20
|
+
unresolved: string[];
|
|
21
|
+
}
|
|
22
|
+
/** Split a comma-or-semicolon recipient string into tokens. Respects
|
|
23
|
+
* angle-bracket address blocks ("Name, Suffix <a@b>") so a comma inside
|
|
24
|
+
* the display name doesn't split the entry. */
|
|
25
|
+
export declare function splitRecipients(raw: string): RecipientToken[];
|
|
26
|
+
/** Quick test: does the token look like an email address (with or without
|
|
27
|
+
* a display name)? Anything containing "@" with non-whitespace on both
|
|
28
|
+
* sides counts. */
|
|
29
|
+
export declare function isAddressToken(token: string): boolean;
|
|
30
|
+
/** Extract the bare email address from a token. Returns the original token
|
|
31
|
+
* lowercased if it has no angle-bracket form. */
|
|
32
|
+
export declare function extractAddress(token: string): string;
|
|
33
|
+
/** Expand a recipient string by resolving group names against `groups`.
|
|
34
|
+
* Group names take precedence: if a token matches a group name AND looks
|
|
35
|
+
* like an address, it's treated as a group. (In practice no one names a
|
|
36
|
+
* group "x@y.com", so this is rarely a real conflict.) */
|
|
37
|
+
export declare function expandRecipients(raw: string, groups: GroupMap): ExpansionResult;
|
|
38
|
+
//# sourceMappingURL=groups.d.ts.map
|
package/groups.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group expansion — turns a recipient string with group names into a flat
|
|
3
|
+
* deduplicated address list. Shared between desktop (mailx-service) and
|
|
4
|
+
* Android (mailx-store-web) so send paths produce the same result.
|
|
5
|
+
*
|
|
6
|
+
* Group source: contacts.jsonc → groups: Record<string, string[]>. Each
|
|
7
|
+
* value is an array of either email addresses ("a@b" or "Name <a@b>") or
|
|
8
|
+
* names of other groups (recursive aliasing). Cycles are detected; depth
|
|
9
|
+
* is capped at 10 nested levels.
|
|
10
|
+
*/
|
|
11
|
+
/** Split a comma-or-semicolon recipient string into tokens. Respects
|
|
12
|
+
* angle-bracket address blocks ("Name, Suffix <a@b>") so a comma inside
|
|
13
|
+
* the display name doesn't split the entry. */
|
|
14
|
+
export function splitRecipients(raw) {
|
|
15
|
+
const out = [];
|
|
16
|
+
let buf = "";
|
|
17
|
+
let depth = 0;
|
|
18
|
+
for (let i = 0; i < raw.length; i++) {
|
|
19
|
+
const c = raw[i];
|
|
20
|
+
if (c === "<")
|
|
21
|
+
depth++;
|
|
22
|
+
else if (c === ">")
|
|
23
|
+
depth = Math.max(0, depth - 1);
|
|
24
|
+
if ((c === "," || c === ";") && depth === 0) {
|
|
25
|
+
const t = buf.trim();
|
|
26
|
+
if (t)
|
|
27
|
+
out.push(t);
|
|
28
|
+
buf = "";
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
buf += c;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const t = buf.trim();
|
|
35
|
+
if (t)
|
|
36
|
+
out.push(t);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** Quick test: does the token look like an email address (with or without
|
|
40
|
+
* a display name)? Anything containing "@" with non-whitespace on both
|
|
41
|
+
* sides counts. */
|
|
42
|
+
export function isAddressToken(token) {
|
|
43
|
+
return /^[^@\s][^@]*@[^@\s]+(\s|$)|<[^>]+@[^>]+>/.test(token) || /^[^\s<>@]+@[^\s<>@]+$/.test(token);
|
|
44
|
+
}
|
|
45
|
+
/** Extract the bare email address from a token. Returns the original token
|
|
46
|
+
* lowercased if it has no angle-bracket form. */
|
|
47
|
+
export function extractAddress(token) {
|
|
48
|
+
const m = token.match(/<([^>]+)>/);
|
|
49
|
+
if (m)
|
|
50
|
+
return m[1].trim().toLowerCase();
|
|
51
|
+
return token.trim().toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
/** Expand a recipient string by resolving group names against `groups`.
|
|
54
|
+
* Group names take precedence: if a token matches a group name AND looks
|
|
55
|
+
* like an address, it's treated as a group. (In practice no one names a
|
|
56
|
+
* group "x@y.com", so this is rarely a real conflict.) */
|
|
57
|
+
export function expandRecipients(raw, groups) {
|
|
58
|
+
const tokens = splitRecipients(raw);
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const addresses = [];
|
|
61
|
+
const unresolved = [];
|
|
62
|
+
const visit = (token, depth, visited) => {
|
|
63
|
+
if (depth > 10) {
|
|
64
|
+
unresolved.push(`${token} (depth limit)`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Group name match — case-insensitive
|
|
68
|
+
const groupKey = Object.keys(groups).find(k => k.toLowerCase() === token.trim().toLowerCase());
|
|
69
|
+
if (groupKey) {
|
|
70
|
+
if (visited.has(groupKey.toLowerCase())) {
|
|
71
|
+
unresolved.push(`${groupKey} (cycle)`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const next = new Set(visited);
|
|
75
|
+
next.add(groupKey.toLowerCase());
|
|
76
|
+
for (const member of groups[groupKey] || []) {
|
|
77
|
+
visit(member, depth + 1, next);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Address — keep the original (with display name if present), but
|
|
82
|
+
// dedupe by lowercased bare address.
|
|
83
|
+
if (isAddressToken(token)) {
|
|
84
|
+
const key = extractAddress(token);
|
|
85
|
+
if (!seen.has(key)) {
|
|
86
|
+
seen.add(key);
|
|
87
|
+
addresses.push(token.trim());
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Neither group nor address — flag.
|
|
92
|
+
unresolved.push(token);
|
|
93
|
+
};
|
|
94
|
+
for (const t of tokens) {
|
|
95
|
+
visit(t, 0, new Set());
|
|
96
|
+
}
|
|
97
|
+
return { addresses, unresolved };
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=groups.js.map
|
package/index.d.ts
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* Shared type definitions for the mailx email client.
|
|
4
4
|
* This is the contract between client and server.
|
|
5
5
|
*/
|
|
6
|
+
export { CONTACT_RULES } from "./contact-rules.js";
|
|
7
|
+
export { expandRecipients, splitRecipients, isAddressToken, extractAddress, } from "./groups.js";
|
|
8
|
+
export type { GroupMap, RecipientToken, ExpansionResult } from "./groups.js";
|
|
6
9
|
/** Supported authentication methods */
|
|
7
10
|
export type AuthMethod = "password" | "oauth2";
|
|
8
11
|
/** Mail account configuration */
|
|
9
12
|
export interface AccountConfig {
|
|
10
13
|
id: string; /** Unique account identifier (e.g., "iecc", "gmail-bob") */
|
|
11
|
-
name: string; /**
|
|
14
|
+
name: string; /** Sender name for From header (e.g., "Bob Frankston") */
|
|
15
|
+
label?: string; /** UI label for account list (e.g., "Gmail"). Falls back to name if not set */
|
|
12
16
|
email: string; /** Email address */
|
|
13
17
|
imap: {
|
|
14
18
|
host: string;
|
|
@@ -28,6 +32,24 @@ export interface AccountConfig {
|
|
|
28
32
|
password?: string;
|
|
29
33
|
};
|
|
30
34
|
enabled: boolean;
|
|
35
|
+
primary?: boolean; /** Catch-all "this is my main account" — default source for Calendar / Tasks / Contacts when no per-feature override set. */
|
|
36
|
+
primaryCalendar?: boolean; /** Per-feature override: use this account's Google Calendar. Falls back to `primary` if unset. */
|
|
37
|
+
primaryTasks?: boolean; /** Per-feature override: use this account's Google Tasks. Falls back to `primary` if unset. */
|
|
38
|
+
primaryContacts?: boolean; /** Per-feature override: use this account's Google Contacts. Falls back to `primary` if unset. */
|
|
39
|
+
defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */
|
|
40
|
+
syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
|
|
41
|
+
relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
|
|
42
|
+
deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
|
|
43
|
+
identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
|
|
44
|
+
spam?: string; /** IMAP folder path for "Mark as spam" button (e.g., "_spam"). Button hidden when not set. */
|
|
45
|
+
signature?: string; /** Legacy: HTML signature appended to all outgoing messages (new + reply + forward). Plain text or HTML allowed. Superseded by `sig`. */
|
|
46
|
+
sig?: AccountSignature; /** Per-account signature object. Initially appended only to NEW messages; later options will cover replies/forwards. */
|
|
47
|
+
}
|
|
48
|
+
/** Signature configuration in accounts.jsonc. Initial shape carries `text`
|
|
49
|
+
* only; `html: true` reserved for future support of raw HTML signatures. */
|
|
50
|
+
export interface AccountSignature {
|
|
51
|
+
text: string; /** Plain-text signature body. Newlines preserved. Appended to NEW messages with the standard "-- " RFC 3676 separator. */
|
|
52
|
+
html?: boolean; /** Future flag: when true, `text` is treated as raw HTML rather than escaped plain text. Currently ignored. */
|
|
31
53
|
}
|
|
32
54
|
/** Standard IMAP special-use folder types */
|
|
33
55
|
export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";
|
|
@@ -53,10 +75,13 @@ export interface MessageEnvelope {
|
|
|
53
75
|
id: number; /** Local store ID */
|
|
54
76
|
accountId: string;
|
|
55
77
|
folderId: number;
|
|
56
|
-
|
|
78
|
+
folderName?: string; /** Leaf folder name; populated by cross-folder search so the UI can tag each hit */
|
|
79
|
+
uid: number; /** IMAP UID (server-side identity; changes on move, UIDVALIDITY bump) */
|
|
80
|
+
uuid?: string; /** Stable local identity, minted once at first-sight; never changes */
|
|
57
81
|
messageId: string; /** RFC Message-ID header */
|
|
58
82
|
inReplyTo: string; /** For threading */
|
|
59
83
|
references: string[]; /** For threading */
|
|
84
|
+
threadId?: string; /** Computed thread id (root Message-ID of the conversation) */
|
|
60
85
|
date: number; /** Epoch ms */
|
|
61
86
|
subject: string;
|
|
62
87
|
from: EmailAddress;
|
|
@@ -66,6 +91,9 @@ export interface MessageEnvelope {
|
|
|
66
91
|
size: number;
|
|
67
92
|
hasAttachments: boolean;
|
|
68
93
|
preview: string; /** First ~200 chars of body text */
|
|
94
|
+
bodyPath?: string; /** Local body location: "idb:..." or "gmail:<id>" */
|
|
95
|
+
providerId?: string; /** Native server id (Gmail hex id, Outlook Graph id) — bypasses UID→id pagination on body fetch */
|
|
96
|
+
pending?: boolean; /** True when a queued local action (move/flag/delete) hasn't been ACK'd by the server yet — UI renders pink */
|
|
69
97
|
}
|
|
70
98
|
/** Full message with body content */
|
|
71
99
|
export interface Message extends MessageEnvelope {
|
|
@@ -97,6 +125,10 @@ export interface MessageQuery {
|
|
|
97
125
|
sort?: "date" | "from" | "subject";
|
|
98
126
|
sortDir?: "asc" | "desc";
|
|
99
127
|
search?: string;
|
|
128
|
+
/** Restrict to messages with the \Flagged flag set (whole-folder, not
|
|
129
|
+
* just the currently-rendered page — lets the "show flagged" filter
|
|
130
|
+
* find stars on messages that haven't been paged in yet). */
|
|
131
|
+
flaggedOnly?: boolean;
|
|
100
132
|
}
|
|
101
133
|
/** Compose/send a message */
|
|
102
134
|
export interface ComposeMessage {
|
|
@@ -150,6 +182,13 @@ export type WsEvent = {
|
|
|
150
182
|
total: number;
|
|
151
183
|
unread: number;
|
|
152
184
|
}>;
|
|
185
|
+
} | {
|
|
186
|
+
type: "folderSynced";
|
|
187
|
+
accountId: string;
|
|
188
|
+
entries: {
|
|
189
|
+
folderId: number;
|
|
190
|
+
syncedAt: number;
|
|
191
|
+
}[];
|
|
153
192
|
} | {
|
|
154
193
|
type: "syncProgress";
|
|
155
194
|
accountId: string;
|
|
@@ -167,11 +206,18 @@ export type WsEvent = {
|
|
|
167
206
|
} | {
|
|
168
207
|
type: "error";
|
|
169
208
|
message: string;
|
|
209
|
+
} | {
|
|
210
|
+
type: "accountError";
|
|
211
|
+
accountId: string;
|
|
212
|
+
error: string;
|
|
213
|
+
hint: string;
|
|
214
|
+
isOAuth: boolean;
|
|
170
215
|
};
|
|
171
216
|
export interface MailxSettings {
|
|
172
217
|
accounts: AccountConfig[];
|
|
173
218
|
ui: {
|
|
174
|
-
theme: "dark" | "light";
|
|
219
|
+
theme: "system" | "dark" | "light";
|
|
220
|
+
editor: "quill" | "tiptap";
|
|
175
221
|
folderWidth: number;
|
|
176
222
|
listViewerSplit: number; /** Percentage for message list height */
|
|
177
223
|
fontSize: number;
|
|
@@ -179,11 +225,53 @@ export interface MailxSettings {
|
|
|
179
225
|
sync: {
|
|
180
226
|
intervalMinutes: number;
|
|
181
227
|
historyDays: number; /** 0 = all history */
|
|
228
|
+
prefetch: boolean; /** Download message bodies during sync (default true) */
|
|
182
229
|
};
|
|
183
230
|
store: {
|
|
184
231
|
basePath: string; /** Where message bodies are stored */
|
|
185
232
|
compressionBoundaryDays: number; /** Messages older than this get compressed */
|
|
186
233
|
};
|
|
234
|
+
autocomplete?: AutocompleteSettings;
|
|
235
|
+
}
|
|
236
|
+
export interface AutocompleteSettings {
|
|
237
|
+
enabled: boolean;
|
|
238
|
+
provider: "ollama" | "claude" | "openai" | "off";
|
|
239
|
+
ollamaUrl: string;
|
|
240
|
+
ollamaModel: string;
|
|
241
|
+
cloudApiKey: string;
|
|
242
|
+
cloudModel: string;
|
|
243
|
+
debounceMs: number;
|
|
244
|
+
maxTokens: number;
|
|
245
|
+
/** Per-feature opt-in for non-autocomplete AI helpers. All default false
|
|
246
|
+
* per user preference (2026-04-21): AI features should be controlled by
|
|
247
|
+
* a flag, initially OFF in settings. Provider config is shared with
|
|
248
|
+
* autocomplete (provider, cloudApiKey, cloudModel, etc.). */
|
|
249
|
+
translateEnabled?: boolean;
|
|
250
|
+
proofreadEnabled?: boolean;
|
|
251
|
+
}
|
|
252
|
+
export interface AutocompleteRequest {
|
|
253
|
+
subject: string;
|
|
254
|
+
to: string;
|
|
255
|
+
bodyText: string;
|
|
256
|
+
cursorOffset: number;
|
|
257
|
+
}
|
|
258
|
+
export interface AutocompleteResponse {
|
|
259
|
+
suggestion: string;
|
|
260
|
+
}
|
|
261
|
+
export interface AiTransformRequest {
|
|
262
|
+
/** translate = render in `targetLang`; proofread = corrected version
|
|
263
|
+
* with grammar/spelling fixes; summarize = short paragraph summary. */
|
|
264
|
+
action: "translate" | "proofread" | "summarize";
|
|
265
|
+
text: string;
|
|
266
|
+
/** ISO-639-1 (or BCP-47) language code for translate. Defaults to "en". */
|
|
267
|
+
targetLang?: string;
|
|
268
|
+
}
|
|
269
|
+
export interface AiTransformResponse {
|
|
270
|
+
/** Transformed text. Empty when AI is disabled / provider error / feature
|
|
271
|
+
* not enabled — caller should treat empty as "no result". */
|
|
272
|
+
text: string;
|
|
273
|
+
/** Optional reason for empty result, surfaced to UI status bar. */
|
|
274
|
+
reason?: string;
|
|
187
275
|
}
|
|
188
276
|
/** Body storage backend interface -- implementations are swappable */
|
|
189
277
|
export interface MessageStore {
|
|
@@ -192,4 +280,43 @@ export interface MessageStore {
|
|
|
192
280
|
deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
|
|
193
281
|
hasMessage(accountId: string, folderId: number, uid: number): Promise<boolean>;
|
|
194
282
|
}
|
|
283
|
+
/** Sanitize HTML for safe display — strips scripts, inline handlers, remote images, forms, iframes. */
|
|
284
|
+
export declare function sanitizeHtml(html: string): {
|
|
285
|
+
html: string;
|
|
286
|
+
hasRemoteContent: boolean;
|
|
287
|
+
};
|
|
288
|
+
/** Encode text as RFC 2045 quoted-printable. */
|
|
289
|
+
export declare function encodeQuotedPrintable(text: string): string;
|
|
290
|
+
/** Render an HTML document as a plain-text approximation suitable for the
|
|
291
|
+
* text/plain alternative part of a multipart/alternative outgoing MIME
|
|
292
|
+
* message. Not a full HTML-to-text engine — just enough to give non-HTML
|
|
293
|
+
* clients (plain-text readers, spam filters scoring on text/plain, people
|
|
294
|
+
* who turned HTML off) a readable fallback. Preserves line breaks for
|
|
295
|
+
* `<br>` / `</p>` / `</div>` / `<li>`, strips all other tags, decodes the
|
|
296
|
+
* common HTML entities, and collapses runs of whitespace.
|
|
297
|
+
*
|
|
298
|
+
* Spam filters (SpamAssassin, Rspamd) penalise HTML-only mail aggressively;
|
|
299
|
+
* shipping a real text part typically drops the score by 1–2 points. Also
|
|
300
|
+
* matches the behaviour of every other mainstream mail client — sending a
|
|
301
|
+
* text/html part alone marks mailx as an outlier in mail logs. */
|
|
302
|
+
export declare function htmlToPlainText(html: string): string;
|
|
303
|
+
/** Parse search query into structured conditions.
|
|
304
|
+
* Supports qualifiers: from:, to:, subject:, date:, has:attachment,
|
|
305
|
+
* is:flagged, is:unread, is:read. Unqualified terms search across subject /
|
|
306
|
+
* from / preview. Returns { conditions, params } for SQL WHERE clause with
|
|
307
|
+
* LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
|
|
308
|
+
* range comparisons).
|
|
309
|
+
*
|
|
310
|
+
* Date syntax (matches Gmail-ish conventions):
|
|
311
|
+
* - date:2026-04-22 exact day
|
|
312
|
+
* - date:2026-04 month
|
|
313
|
+
* - date:>2026-04-01 after
|
|
314
|
+
* - date:<2026-04-01 before
|
|
315
|
+
* - date:2026-04-01..2026-04-30 range
|
|
316
|
+
* - date:today / yesterday / last7 / last30
|
|
317
|
+
*/
|
|
318
|
+
export declare function parseSearchQuery(query: string): {
|
|
319
|
+
conditions: string[];
|
|
320
|
+
params: (string | number)[];
|
|
321
|
+
};
|
|
195
322
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -3,5 +3,250 @@
|
|
|
3
3
|
* Shared type definitions for the mailx email client.
|
|
4
4
|
* This is the contract between client and server.
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
// Generated rule data — both desktop store and Android store import via
|
|
7
|
+
// this barrel so a single source-of-truth (contact-rules.jsonc) drives
|
|
8
|
+
// junk-contact filtering on every platform.
|
|
9
|
+
export { CONTACT_RULES } from "./contact-rules.js";
|
|
10
|
+
// Group-name expansion for recipient fields. Lets users type a group name
|
|
11
|
+
// (e.g. "family") in To/Cc/Bcc and have it expand to the address list at
|
|
12
|
+
// send time. Both desktop and Android send paths consume this expander
|
|
13
|
+
// against contacts.jsonc → groups.
|
|
14
|
+
export { expandRecipients, splitRecipients, isAddressToken, extractAddress, } from "./groups.js";
|
|
15
|
+
// ── Shared Utilities ──
|
|
16
|
+
// Pure functions used by both desktop (mailx-service) and Android (web-service).
|
|
17
|
+
// Kept here to avoid duplication — both platforms import from mailx-types.
|
|
18
|
+
/** Sanitize HTML for safe display — strips scripts, inline handlers, remote images, forms, iframes. */
|
|
19
|
+
export function sanitizeHtml(html) {
|
|
20
|
+
let hasRemoteContent = false;
|
|
21
|
+
let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
|
|
22
|
+
clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
23
|
+
clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
|
|
24
|
+
const url = src.slice(1, -1);
|
|
25
|
+
if (url.startsWith("data:") || url.startsWith("cid:"))
|
|
26
|
+
return match;
|
|
27
|
+
hasRemoteContent = true;
|
|
28
|
+
return `<img${before}src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect fill='%23888' width='20' height='20' rx='3'/%3E%3Ctext x='10' y='14' text-anchor='middle' fill='white' font-size='12'%3E⊘%3C/text%3E%3C/svg%3E" data-blocked-src=${src} title="Remote image blocked"`;
|
|
29
|
+
});
|
|
30
|
+
clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
|
|
31
|
+
hasRemoteContent = true;
|
|
32
|
+
return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
|
|
33
|
+
});
|
|
34
|
+
clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (_match, _q, url) => {
|
|
35
|
+
hasRemoteContent = true;
|
|
36
|
+
return `url("") /* blocked: ${url} */`;
|
|
37
|
+
});
|
|
38
|
+
clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
|
|
39
|
+
clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
|
|
40
|
+
return { html: clean, hasRemoteContent };
|
|
41
|
+
}
|
|
42
|
+
/** Encode text as RFC 2045 quoted-printable. */
|
|
43
|
+
export function encodeQuotedPrintable(text) {
|
|
44
|
+
const encoder = new TextEncoder();
|
|
45
|
+
const bytes = encoder.encode(text);
|
|
46
|
+
let line = "";
|
|
47
|
+
let result = "";
|
|
48
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
49
|
+
const b = bytes[i];
|
|
50
|
+
let encoded;
|
|
51
|
+
if (b === 0x0D && bytes[i + 1] === 0x0A) {
|
|
52
|
+
result += line + "\r\n";
|
|
53
|
+
line = "";
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
else if (b === 0x0A) {
|
|
58
|
+
result += line + "\r\n";
|
|
59
|
+
line = "";
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {
|
|
63
|
+
encoded = String.fromCharCode(b);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
encoded = "=" + b.toString(16).toUpperCase().padStart(2, "0");
|
|
67
|
+
}
|
|
68
|
+
if (line.length + encoded.length > 75) {
|
|
69
|
+
result += line + "=\r\n";
|
|
70
|
+
line = "";
|
|
71
|
+
}
|
|
72
|
+
line += encoded;
|
|
73
|
+
}
|
|
74
|
+
result += line;
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/** Render an HTML document as a plain-text approximation suitable for the
|
|
78
|
+
* text/plain alternative part of a multipart/alternative outgoing MIME
|
|
79
|
+
* message. Not a full HTML-to-text engine — just enough to give non-HTML
|
|
80
|
+
* clients (plain-text readers, spam filters scoring on text/plain, people
|
|
81
|
+
* who turned HTML off) a readable fallback. Preserves line breaks for
|
|
82
|
+
* `<br>` / `</p>` / `</div>` / `<li>`, strips all other tags, decodes the
|
|
83
|
+
* common HTML entities, and collapses runs of whitespace.
|
|
84
|
+
*
|
|
85
|
+
* Spam filters (SpamAssassin, Rspamd) penalise HTML-only mail aggressively;
|
|
86
|
+
* shipping a real text part typically drops the score by 1–2 points. Also
|
|
87
|
+
* matches the behaviour of every other mainstream mail client — sending a
|
|
88
|
+
* text/html part alone marks mailx as an outlier in mail logs. */
|
|
89
|
+
export function htmlToPlainText(html) {
|
|
90
|
+
if (!html)
|
|
91
|
+
return "";
|
|
92
|
+
let s = html;
|
|
93
|
+
// Drop <style> / <script> entirely (their contents aren't readable text).
|
|
94
|
+
s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
|
|
95
|
+
s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
|
|
96
|
+
// Block-level breaks — treat closing tags as line terminators so
|
|
97
|
+
// paragraphs don't run together.
|
|
98
|
+
s = s.replace(/<br\s*\/?\s*>/gi, "\n");
|
|
99
|
+
s = s.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|pre|section|article)\s*>/gi, "\n");
|
|
100
|
+
// List-item leading bullet (rough but readable).
|
|
101
|
+
s = s.replace(/<li\b[^>]*>/gi, " • ");
|
|
102
|
+
// Anchor: keep href in parens after the text so URLs survive.
|
|
103
|
+
s = s.replace(/<a\b[^>]*href\s*=\s*(['"])([^'"]*)\1[^>]*>([\s\S]*?)<\/a>/gi, (_m, _q, href, text) => {
|
|
104
|
+
const t = text.replace(/<[^>]+>/g, "").trim();
|
|
105
|
+
return t && t !== href ? `${t} (${href})` : href;
|
|
106
|
+
});
|
|
107
|
+
// Strip remaining tags.
|
|
108
|
+
s = s.replace(/<[^>]+>/g, "");
|
|
109
|
+
// Decode a pragmatic set of HTML entities — the rare ones survive as-is.
|
|
110
|
+
s = s.replace(/ /gi, " ")
|
|
111
|
+
.replace(/&/gi, "&")
|
|
112
|
+
.replace(/</gi, "<")
|
|
113
|
+
.replace(/>/gi, ">")
|
|
114
|
+
.replace(/"/gi, "\"")
|
|
115
|
+
.replace(/'/gi, "'")
|
|
116
|
+
.replace(/'/gi, "'")
|
|
117
|
+
.replace(/—/gi, "—")
|
|
118
|
+
.replace(/–/gi, "–")
|
|
119
|
+
.replace(/…/gi, "…")
|
|
120
|
+
.replace(/&#(\d+);/g, (_m, n) => String.fromCodePoint(parseInt(n, 10)))
|
|
121
|
+
.replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16)));
|
|
122
|
+
// Normalise whitespace: collapse runs of spaces/tabs, trim per-line,
|
|
123
|
+
// cap consecutive blank lines at 2.
|
|
124
|
+
s = s.replace(/[ \t]+/g, " ")
|
|
125
|
+
.split("\n").map(l => l.replace(/^[ \t]+|[ \t]+$/g, "")).join("\n")
|
|
126
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
127
|
+
.trim();
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
/** Parse search query into structured conditions.
|
|
131
|
+
* Supports qualifiers: from:, to:, subject:, date:, has:attachment,
|
|
132
|
+
* is:flagged, is:unread, is:read. Unqualified terms search across subject /
|
|
133
|
+
* from / preview. Returns { conditions, params } for SQL WHERE clause with
|
|
134
|
+
* LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
|
|
135
|
+
* range comparisons).
|
|
136
|
+
*
|
|
137
|
+
* Date syntax (matches Gmail-ish conventions):
|
|
138
|
+
* - date:2026-04-22 exact day
|
|
139
|
+
* - date:2026-04 month
|
|
140
|
+
* - date:>2026-04-01 after
|
|
141
|
+
* - date:<2026-04-01 before
|
|
142
|
+
* - date:2026-04-01..2026-04-30 range
|
|
143
|
+
* - date:today / yesterday / last7 / last30
|
|
144
|
+
*/
|
|
145
|
+
export function parseSearchQuery(query) {
|
|
146
|
+
const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
147
|
+
const conditions = [];
|
|
148
|
+
const params = [];
|
|
149
|
+
const dayStart = (y, m, d) => new Date(y, m - 1, d).getTime();
|
|
150
|
+
const parseDateSpec = (spec) => {
|
|
151
|
+
const now = new Date();
|
|
152
|
+
const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
153
|
+
if (spec === "today")
|
|
154
|
+
return { from: today0, to: today0 + 86400_000 };
|
|
155
|
+
if (spec === "yesterday")
|
|
156
|
+
return { from: today0 - 86400_000, to: today0 };
|
|
157
|
+
const lastN = spec.match(/^last(\d+)$/i);
|
|
158
|
+
if (lastN)
|
|
159
|
+
return { from: today0 - parseInt(lastN[1]) * 86400_000 };
|
|
160
|
+
const rangeMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})\.\.(\d{4})-(\d{2})-(\d{2})$/);
|
|
161
|
+
if (rangeMatch)
|
|
162
|
+
return {
|
|
163
|
+
from: dayStart(+rangeMatch[1], +rangeMatch[2], +rangeMatch[3]),
|
|
164
|
+
to: dayStart(+rangeMatch[4], +rangeMatch[5], +rangeMatch[6]) + 86400_000,
|
|
165
|
+
};
|
|
166
|
+
const gtMatch = spec.match(/^>(\d{4})-(\d{2})-(\d{2})$/);
|
|
167
|
+
if (gtMatch)
|
|
168
|
+
return { from: dayStart(+gtMatch[1], +gtMatch[2], +gtMatch[3]) + 86400_000 };
|
|
169
|
+
const ltMatch = spec.match(/^<(\d{4})-(\d{2})-(\d{2})$/);
|
|
170
|
+
if (ltMatch)
|
|
171
|
+
return { to: dayStart(+ltMatch[1], +ltMatch[2], +ltMatch[3]) };
|
|
172
|
+
const monthMatch = spec.match(/^(\d{4})-(\d{2})$/);
|
|
173
|
+
if (monthMatch) {
|
|
174
|
+
const y = +monthMatch[1], m = +monthMatch[2];
|
|
175
|
+
const from = dayStart(y, m, 1);
|
|
176
|
+
const to = m === 12 ? dayStart(y + 1, 1, 1) : dayStart(y, m + 1, 1);
|
|
177
|
+
return { from, to };
|
|
178
|
+
}
|
|
179
|
+
const dayMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
180
|
+
if (dayMatch) {
|
|
181
|
+
const from = dayStart(+dayMatch[1], +dayMatch[2], +dayMatch[3]);
|
|
182
|
+
return { from, to: from + 86400_000 };
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
};
|
|
186
|
+
for (const part of parts) {
|
|
187
|
+
const fromMatch = part.match(/^from:(.+)$/i);
|
|
188
|
+
const toMatch = part.match(/^to:(.+)$/i);
|
|
189
|
+
const subjectMatch = part.match(/^subject:(.+)$/i);
|
|
190
|
+
const hasMatch = part.match(/^has:(.+)$/i);
|
|
191
|
+
const isMatch = part.match(/^is:(.+)$/i);
|
|
192
|
+
const dateMatch = part.match(/^date:(.+)$/i);
|
|
193
|
+
if (fromMatch) {
|
|
194
|
+
const term = `%${fromMatch[1].replace(/"/g, "")}%`;
|
|
195
|
+
conditions.push("(from_name LIKE ? OR from_address LIKE ?)");
|
|
196
|
+
params.push(term, term);
|
|
197
|
+
}
|
|
198
|
+
else if (toMatch) {
|
|
199
|
+
const term = `%${toMatch[1].replace(/"/g, "")}%`;
|
|
200
|
+
conditions.push("(to_json LIKE ? OR cc_json LIKE ?)");
|
|
201
|
+
params.push(term, term);
|
|
202
|
+
}
|
|
203
|
+
else if (subjectMatch) {
|
|
204
|
+
const term = `%${subjectMatch[1].replace(/"/g, "")}%`;
|
|
205
|
+
conditions.push("subject LIKE ?");
|
|
206
|
+
params.push(term);
|
|
207
|
+
}
|
|
208
|
+
else if (hasMatch) {
|
|
209
|
+
const v = hasMatch[1].toLowerCase();
|
|
210
|
+
if (v === "attachment" || v === "attachments") {
|
|
211
|
+
conditions.push("has_attachments = 1");
|
|
212
|
+
}
|
|
213
|
+
// Unknown has: qualifier — silently drop; treating as a literal
|
|
214
|
+
// search term would be confusing.
|
|
215
|
+
}
|
|
216
|
+
else if (isMatch) {
|
|
217
|
+
const v = isMatch[1].toLowerCase();
|
|
218
|
+
if (v === "flagged" || v === "starred") {
|
|
219
|
+
conditions.push("flags_json LIKE ?");
|
|
220
|
+
params.push("%\\\\Flagged%");
|
|
221
|
+
}
|
|
222
|
+
else if (v === "unread") {
|
|
223
|
+
conditions.push("flags_json NOT LIKE ?");
|
|
224
|
+
params.push("%\\\\Seen%");
|
|
225
|
+
}
|
|
226
|
+
else if (v === "read") {
|
|
227
|
+
conditions.push("flags_json LIKE ?");
|
|
228
|
+
params.push("%\\\\Seen%");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (dateMatch) {
|
|
232
|
+
const spec = parseDateSpec(dateMatch[1]);
|
|
233
|
+
if (spec) {
|
|
234
|
+
if (spec.from !== undefined) {
|
|
235
|
+
conditions.push("date >= ?");
|
|
236
|
+
params.push(spec.from);
|
|
237
|
+
}
|
|
238
|
+
if (spec.to !== undefined) {
|
|
239
|
+
conditions.push("date < ?");
|
|
240
|
+
params.push(spec.to);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
const term = `%${part}%`;
|
|
246
|
+
conditions.push("(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)");
|
|
247
|
+
params.push(term, term, term, term);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { conditions, params };
|
|
251
|
+
}
|
|
7
252
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-types",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
|
+
"prebuild": "node build-rules.js",
|
|
8
9
|
"build": "tsc",
|
|
9
10
|
"release": "npmglobalize"
|
|
10
11
|
},
|
|
@@ -12,5 +13,8 @@
|
|
|
12
13
|
"repository": {
|
|
13
14
|
"type": "git",
|
|
14
15
|
"url": "https://github.com/BobFrankston/mailx-types.git"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
15
19
|
}
|
|
16
20
|
}
|