@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 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
@@ -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; /** Display name */
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
- uid: number; /** IMAP UID */
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
- export {};
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(/&nbsp;/gi, " ")
111
+ .replace(/&amp;/gi, "&")
112
+ .replace(/&lt;/gi, "<")
113
+ .replace(/&gt;/gi, ">")
114
+ .replace(/&quot;/gi, "\"")
115
+ .replace(/&#39;/gi, "'")
116
+ .replace(/&apos;/gi, "'")
117
+ .replace(/&mdash;/gi, "—")
118
+ .replace(/&ndash;/gi, "–")
119
+ .replace(/&hellip;/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",
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
  }