@bobfrankston/mailx-types 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.d.ts +127 -3
  2. package/index.js +237 -1
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -8,7 +8,8 @@ export type AuthMethod = "password" | "oauth2";
8
8
  /** Mail account configuration */
9
9
  export interface AccountConfig {
10
10
  id: string; /** Unique account identifier (e.g., "iecc", "gmail-bob") */
11
- name: string; /** Display name */
11
+ name: string; /** Sender name for From header (e.g., "Bob Frankston") */
12
+ label?: string; /** UI label for account list (e.g., "Gmail"). Falls back to name if not set */
12
13
  email: string; /** Email address */
13
14
  imap: {
14
15
  host: string;
@@ -28,6 +29,24 @@ export interface AccountConfig {
28
29
  password?: string;
29
30
  };
30
31
  enabled: boolean;
32
+ primary?: boolean; /** Catch-all "this is my main account" — default source for Calendar / Tasks / Contacts when no per-feature override set. */
33
+ primaryCalendar?: boolean; /** Per-feature override: use this account's Google Calendar. Falls back to `primary` if unset. */
34
+ primaryTasks?: boolean; /** Per-feature override: use this account's Google Tasks. Falls back to `primary` if unset. */
35
+ primaryContacts?: boolean; /** Per-feature override: use this account's Google Contacts. Falls back to `primary` if unset. */
36
+ defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */
37
+ syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
38
+ relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
39
+ deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
40
+ identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
41
+ spam?: string; /** IMAP folder path for "Mark as spam" button (e.g., "_spam"). Button hidden when not set. */
42
+ signature?: string; /** Legacy: HTML signature appended to all outgoing messages (new + reply + forward). Plain text or HTML allowed. Superseded by `sig`. */
43
+ sig?: AccountSignature; /** Per-account signature object. Initially appended only to NEW messages; later options will cover replies/forwards. */
44
+ }
45
+ /** Signature configuration in accounts.jsonc. Initial shape carries `text`
46
+ * only; `html: true` reserved for future support of raw HTML signatures. */
47
+ export interface AccountSignature {
48
+ text: string; /** Plain-text signature body. Newlines preserved. Appended to NEW messages with the standard "-- " RFC 3676 separator. */
49
+ html?: boolean; /** Future flag: when true, `text` is treated as raw HTML rather than escaped plain text. Currently ignored. */
31
50
  }
32
51
  /** Standard IMAP special-use folder types */
33
52
  export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";
@@ -53,10 +72,13 @@ export interface MessageEnvelope {
53
72
  id: number; /** Local store ID */
54
73
  accountId: string;
55
74
  folderId: number;
56
- uid: number; /** IMAP UID */
75
+ folderName?: string; /** Leaf folder name; populated by cross-folder search so the UI can tag each hit */
76
+ uid: number; /** IMAP UID (server-side identity; changes on move, UIDVALIDITY bump) */
77
+ uuid?: string; /** Stable local identity, minted once at first-sight; never changes */
57
78
  messageId: string; /** RFC Message-ID header */
58
79
  inReplyTo: string; /** For threading */
59
80
  references: string[]; /** For threading */
81
+ threadId?: string; /** Computed thread id (root Message-ID of the conversation) */
60
82
  date: number; /** Epoch ms */
61
83
  subject: string;
62
84
  from: EmailAddress;
@@ -66,6 +88,9 @@ export interface MessageEnvelope {
66
88
  size: number;
67
89
  hasAttachments: boolean;
68
90
  preview: string; /** First ~200 chars of body text */
91
+ bodyPath?: string; /** Local body location: "idb:..." or "gmail:<id>" */
92
+ providerId?: string; /** Native server id (Gmail hex id, Outlook Graph id) — bypasses UID→id pagination on body fetch */
93
+ pending?: boolean; /** True when a queued local action (move/flag/delete) hasn't been ACK'd by the server yet — UI renders pink */
69
94
  }
70
95
  /** Full message with body content */
71
96
  export interface Message extends MessageEnvelope {
@@ -97,6 +122,10 @@ export interface MessageQuery {
97
122
  sort?: "date" | "from" | "subject";
98
123
  sortDir?: "asc" | "desc";
99
124
  search?: string;
125
+ /** Restrict to messages with the \Flagged flag set (whole-folder, not
126
+ * just the currently-rendered page — lets the "show flagged" filter
127
+ * find stars on messages that haven't been paged in yet). */
128
+ flaggedOnly?: boolean;
100
129
  }
101
130
  /** Compose/send a message */
102
131
  export interface ComposeMessage {
@@ -150,6 +179,13 @@ export type WsEvent = {
150
179
  total: number;
151
180
  unread: number;
152
181
  }>;
182
+ } | {
183
+ type: "folderSynced";
184
+ accountId: string;
185
+ entries: {
186
+ folderId: number;
187
+ syncedAt: number;
188
+ }[];
153
189
  } | {
154
190
  type: "syncProgress";
155
191
  accountId: string;
@@ -167,11 +203,18 @@ export type WsEvent = {
167
203
  } | {
168
204
  type: "error";
169
205
  message: string;
206
+ } | {
207
+ type: "accountError";
208
+ accountId: string;
209
+ error: string;
210
+ hint: string;
211
+ isOAuth: boolean;
170
212
  };
171
213
  export interface MailxSettings {
172
214
  accounts: AccountConfig[];
173
215
  ui: {
174
- theme: "dark" | "light";
216
+ theme: "system" | "dark" | "light";
217
+ editor: "quill" | "tiptap";
175
218
  folderWidth: number;
176
219
  listViewerSplit: number; /** Percentage for message list height */
177
220
  fontSize: number;
@@ -179,11 +222,53 @@ export interface MailxSettings {
179
222
  sync: {
180
223
  intervalMinutes: number;
181
224
  historyDays: number; /** 0 = all history */
225
+ prefetch: boolean; /** Download message bodies during sync (default true) */
182
226
  };
183
227
  store: {
184
228
  basePath: string; /** Where message bodies are stored */
185
229
  compressionBoundaryDays: number; /** Messages older than this get compressed */
186
230
  };
231
+ autocomplete?: AutocompleteSettings;
232
+ }
233
+ export interface AutocompleteSettings {
234
+ enabled: boolean;
235
+ provider: "ollama" | "claude" | "openai" | "off";
236
+ ollamaUrl: string;
237
+ ollamaModel: string;
238
+ cloudApiKey: string;
239
+ cloudModel: string;
240
+ debounceMs: number;
241
+ maxTokens: number;
242
+ /** Per-feature opt-in for non-autocomplete AI helpers. All default false
243
+ * per user preference (2026-04-21): AI features should be controlled by
244
+ * a flag, initially OFF in settings. Provider config is shared with
245
+ * autocomplete (provider, cloudApiKey, cloudModel, etc.). */
246
+ translateEnabled?: boolean;
247
+ proofreadEnabled?: boolean;
248
+ }
249
+ export interface AutocompleteRequest {
250
+ subject: string;
251
+ to: string;
252
+ bodyText: string;
253
+ cursorOffset: number;
254
+ }
255
+ export interface AutocompleteResponse {
256
+ suggestion: string;
257
+ }
258
+ export interface AiTransformRequest {
259
+ /** translate = render in `targetLang`; proofread = corrected version
260
+ * with grammar/spelling fixes; summarize = short paragraph summary. */
261
+ action: "translate" | "proofread" | "summarize";
262
+ text: string;
263
+ /** ISO-639-1 (or BCP-47) language code for translate. Defaults to "en". */
264
+ targetLang?: string;
265
+ }
266
+ export interface AiTransformResponse {
267
+ /** Transformed text. Empty when AI is disabled / provider error / feature
268
+ * not enabled — caller should treat empty as "no result". */
269
+ text: string;
270
+ /** Optional reason for empty result, surfaced to UI status bar. */
271
+ reason?: string;
187
272
  }
188
273
  /** Body storage backend interface -- implementations are swappable */
189
274
  export interface MessageStore {
@@ -192,4 +277,43 @@ export interface MessageStore {
192
277
  deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
193
278
  hasMessage(accountId: string, folderId: number, uid: number): Promise<boolean>;
194
279
  }
280
+ /** Sanitize HTML for safe display — strips scripts, inline handlers, remote images, forms, iframes. */
281
+ export declare function sanitizeHtml(html: string): {
282
+ html: string;
283
+ hasRemoteContent: boolean;
284
+ };
285
+ /** Encode text as RFC 2045 quoted-printable. */
286
+ export declare function encodeQuotedPrintable(text: string): string;
287
+ /** Render an HTML document as a plain-text approximation suitable for the
288
+ * text/plain alternative part of a multipart/alternative outgoing MIME
289
+ * message. Not a full HTML-to-text engine — just enough to give non-HTML
290
+ * clients (plain-text readers, spam filters scoring on text/plain, people
291
+ * who turned HTML off) a readable fallback. Preserves line breaks for
292
+ * `<br>` / `</p>` / `</div>` / `<li>`, strips all other tags, decodes the
293
+ * common HTML entities, and collapses runs of whitespace.
294
+ *
295
+ * Spam filters (SpamAssassin, Rspamd) penalise HTML-only mail aggressively;
296
+ * shipping a real text part typically drops the score by 1–2 points. Also
297
+ * matches the behaviour of every other mainstream mail client — sending a
298
+ * text/html part alone marks mailx as an outlier in mail logs. */
299
+ export declare function htmlToPlainText(html: string): string;
300
+ /** Parse search query into structured conditions.
301
+ * Supports qualifiers: from:, to:, subject:, date:, has:attachment,
302
+ * is:flagged, is:unread, is:read. Unqualified terms search across subject /
303
+ * from / preview. Returns { conditions, params } for SQL WHERE clause with
304
+ * LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
305
+ * range comparisons).
306
+ *
307
+ * Date syntax (matches Gmail-ish conventions):
308
+ * - date:2026-04-22 exact day
309
+ * - date:2026-04 month
310
+ * - date:>2026-04-01 after
311
+ * - date:<2026-04-01 before
312
+ * - date:2026-04-01..2026-04-30 range
313
+ * - date:today / yesterday / last7 / last30
314
+ */
315
+ export declare function parseSearchQuery(query: string): {
316
+ conditions: string[];
317
+ params: (string | number)[];
318
+ };
195
319
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -3,5 +3,241 @@
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
+ // ── Shared Utilities ──
7
+ // Pure functions used by both desktop (mailx-service) and Android (web-service).
8
+ // Kept here to avoid duplication — both platforms import from mailx-types.
9
+ /** Sanitize HTML for safe display — strips scripts, inline handlers, remote images, forms, iframes. */
10
+ export function sanitizeHtml(html) {
11
+ let hasRemoteContent = false;
12
+ let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
13
+ clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
14
+ clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
15
+ const url = src.slice(1, -1);
16
+ if (url.startsWith("data:") || url.startsWith("cid:"))
17
+ return match;
18
+ hasRemoteContent = true;
19
+ 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"`;
20
+ });
21
+ clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
22
+ hasRemoteContent = true;
23
+ return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
24
+ });
25
+ clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (_match, _q, url) => {
26
+ hasRemoteContent = true;
27
+ return `url("") /* blocked: ${url} */`;
28
+ });
29
+ clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
30
+ clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
31
+ return { html: clean, hasRemoteContent };
32
+ }
33
+ /** Encode text as RFC 2045 quoted-printable. */
34
+ export function encodeQuotedPrintable(text) {
35
+ const encoder = new TextEncoder();
36
+ const bytes = encoder.encode(text);
37
+ let line = "";
38
+ let result = "";
39
+ for (let i = 0; i < bytes.length; i++) {
40
+ const b = bytes[i];
41
+ let encoded;
42
+ if (b === 0x0D && bytes[i + 1] === 0x0A) {
43
+ result += line + "\r\n";
44
+ line = "";
45
+ i++;
46
+ continue;
47
+ }
48
+ else if (b === 0x0A) {
49
+ result += line + "\r\n";
50
+ line = "";
51
+ continue;
52
+ }
53
+ else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {
54
+ encoded = String.fromCharCode(b);
55
+ }
56
+ else {
57
+ encoded = "=" + b.toString(16).toUpperCase().padStart(2, "0");
58
+ }
59
+ if (line.length + encoded.length > 75) {
60
+ result += line + "=\r\n";
61
+ line = "";
62
+ }
63
+ line += encoded;
64
+ }
65
+ result += line;
66
+ return result;
67
+ }
68
+ /** Render an HTML document as a plain-text approximation suitable for the
69
+ * text/plain alternative part of a multipart/alternative outgoing MIME
70
+ * message. Not a full HTML-to-text engine — just enough to give non-HTML
71
+ * clients (plain-text readers, spam filters scoring on text/plain, people
72
+ * who turned HTML off) a readable fallback. Preserves line breaks for
73
+ * `<br>` / `</p>` / `</div>` / `<li>`, strips all other tags, decodes the
74
+ * common HTML entities, and collapses runs of whitespace.
75
+ *
76
+ * Spam filters (SpamAssassin, Rspamd) penalise HTML-only mail aggressively;
77
+ * shipping a real text part typically drops the score by 1–2 points. Also
78
+ * matches the behaviour of every other mainstream mail client — sending a
79
+ * text/html part alone marks mailx as an outlier in mail logs. */
80
+ export function htmlToPlainText(html) {
81
+ if (!html)
82
+ return "";
83
+ let s = html;
84
+ // Drop <style> / <script> entirely (their contents aren't readable text).
85
+ s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
86
+ s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
87
+ // Block-level breaks — treat closing tags as line terminators so
88
+ // paragraphs don't run together.
89
+ s = s.replace(/<br\s*\/?\s*>/gi, "\n");
90
+ s = s.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|pre|section|article)\s*>/gi, "\n");
91
+ // List-item leading bullet (rough but readable).
92
+ s = s.replace(/<li\b[^>]*>/gi, " • ");
93
+ // Anchor: keep href in parens after the text so URLs survive.
94
+ s = s.replace(/<a\b[^>]*href\s*=\s*(['"])([^'"]*)\1[^>]*>([\s\S]*?)<\/a>/gi, (_m, _q, href, text) => {
95
+ const t = text.replace(/<[^>]+>/g, "").trim();
96
+ return t && t !== href ? `${t} (${href})` : href;
97
+ });
98
+ // Strip remaining tags.
99
+ s = s.replace(/<[^>]+>/g, "");
100
+ // Decode a pragmatic set of HTML entities — the rare ones survive as-is.
101
+ s = s.replace(/&nbsp;/gi, " ")
102
+ .replace(/&amp;/gi, "&")
103
+ .replace(/&lt;/gi, "<")
104
+ .replace(/&gt;/gi, ">")
105
+ .replace(/&quot;/gi, "\"")
106
+ .replace(/&#39;/gi, "'")
107
+ .replace(/&apos;/gi, "'")
108
+ .replace(/&mdash;/gi, "—")
109
+ .replace(/&ndash;/gi, "–")
110
+ .replace(/&hellip;/gi, "…")
111
+ .replace(/&#(\d+);/g, (_m, n) => String.fromCodePoint(parseInt(n, 10)))
112
+ .replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16)));
113
+ // Normalise whitespace: collapse runs of spaces/tabs, trim per-line,
114
+ // cap consecutive blank lines at 2.
115
+ s = s.replace(/[ \t]+/g, " ")
116
+ .split("\n").map(l => l.replace(/^[ \t]+|[ \t]+$/g, "")).join("\n")
117
+ .replace(/\n{3,}/g, "\n\n")
118
+ .trim();
119
+ return s;
120
+ }
121
+ /** Parse search query into structured conditions.
122
+ * Supports qualifiers: from:, to:, subject:, date:, has:attachment,
123
+ * is:flagged, is:unread, is:read. Unqualified terms search across subject /
124
+ * from / preview. Returns { conditions, params } for SQL WHERE clause with
125
+ * LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
126
+ * range comparisons).
127
+ *
128
+ * Date syntax (matches Gmail-ish conventions):
129
+ * - date:2026-04-22 exact day
130
+ * - date:2026-04 month
131
+ * - date:>2026-04-01 after
132
+ * - date:<2026-04-01 before
133
+ * - date:2026-04-01..2026-04-30 range
134
+ * - date:today / yesterday / last7 / last30
135
+ */
136
+ export function parseSearchQuery(query) {
137
+ const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
138
+ const conditions = [];
139
+ const params = [];
140
+ const dayStart = (y, m, d) => new Date(y, m - 1, d).getTime();
141
+ const parseDateSpec = (spec) => {
142
+ const now = new Date();
143
+ const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
144
+ if (spec === "today")
145
+ return { from: today0, to: today0 + 86400_000 };
146
+ if (spec === "yesterday")
147
+ return { from: today0 - 86400_000, to: today0 };
148
+ const lastN = spec.match(/^last(\d+)$/i);
149
+ if (lastN)
150
+ return { from: today0 - parseInt(lastN[1]) * 86400_000 };
151
+ const rangeMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})\.\.(\d{4})-(\d{2})-(\d{2})$/);
152
+ if (rangeMatch)
153
+ return {
154
+ from: dayStart(+rangeMatch[1], +rangeMatch[2], +rangeMatch[3]),
155
+ to: dayStart(+rangeMatch[4], +rangeMatch[5], +rangeMatch[6]) + 86400_000,
156
+ };
157
+ const gtMatch = spec.match(/^>(\d{4})-(\d{2})-(\d{2})$/);
158
+ if (gtMatch)
159
+ return { from: dayStart(+gtMatch[1], +gtMatch[2], +gtMatch[3]) + 86400_000 };
160
+ const ltMatch = spec.match(/^<(\d{4})-(\d{2})-(\d{2})$/);
161
+ if (ltMatch)
162
+ return { to: dayStart(+ltMatch[1], +ltMatch[2], +ltMatch[3]) };
163
+ const monthMatch = spec.match(/^(\d{4})-(\d{2})$/);
164
+ if (monthMatch) {
165
+ const y = +monthMatch[1], m = +monthMatch[2];
166
+ const from = dayStart(y, m, 1);
167
+ const to = m === 12 ? dayStart(y + 1, 1, 1) : dayStart(y, m + 1, 1);
168
+ return { from, to };
169
+ }
170
+ const dayMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})$/);
171
+ if (dayMatch) {
172
+ const from = dayStart(+dayMatch[1], +dayMatch[2], +dayMatch[3]);
173
+ return { from, to: from + 86400_000 };
174
+ }
175
+ return null;
176
+ };
177
+ for (const part of parts) {
178
+ const fromMatch = part.match(/^from:(.+)$/i);
179
+ const toMatch = part.match(/^to:(.+)$/i);
180
+ const subjectMatch = part.match(/^subject:(.+)$/i);
181
+ const hasMatch = part.match(/^has:(.+)$/i);
182
+ const isMatch = part.match(/^is:(.+)$/i);
183
+ const dateMatch = part.match(/^date:(.+)$/i);
184
+ if (fromMatch) {
185
+ const term = `%${fromMatch[1].replace(/"/g, "")}%`;
186
+ conditions.push("(from_name LIKE ? OR from_address LIKE ?)");
187
+ params.push(term, term);
188
+ }
189
+ else if (toMatch) {
190
+ const term = `%${toMatch[1].replace(/"/g, "")}%`;
191
+ conditions.push("(to_json LIKE ? OR cc_json LIKE ?)");
192
+ params.push(term, term);
193
+ }
194
+ else if (subjectMatch) {
195
+ const term = `%${subjectMatch[1].replace(/"/g, "")}%`;
196
+ conditions.push("subject LIKE ?");
197
+ params.push(term);
198
+ }
199
+ else if (hasMatch) {
200
+ const v = hasMatch[1].toLowerCase();
201
+ if (v === "attachment" || v === "attachments") {
202
+ conditions.push("has_attachments = 1");
203
+ }
204
+ // Unknown has: qualifier — silently drop; treating as a literal
205
+ // search term would be confusing.
206
+ }
207
+ else if (isMatch) {
208
+ const v = isMatch[1].toLowerCase();
209
+ if (v === "flagged" || v === "starred") {
210
+ conditions.push("flags_json LIKE ?");
211
+ params.push("%\\\\Flagged%");
212
+ }
213
+ else if (v === "unread") {
214
+ conditions.push("flags_json NOT LIKE ?");
215
+ params.push("%\\\\Seen%");
216
+ }
217
+ else if (v === "read") {
218
+ conditions.push("flags_json LIKE ?");
219
+ params.push("%\\\\Seen%");
220
+ }
221
+ }
222
+ else if (dateMatch) {
223
+ const spec = parseDateSpec(dateMatch[1]);
224
+ if (spec) {
225
+ if (spec.from !== undefined) {
226
+ conditions.push("date >= ?");
227
+ params.push(spec.from);
228
+ }
229
+ if (spec.to !== undefined) {
230
+ conditions.push("date < ?");
231
+ params.push(spec.to);
232
+ }
233
+ }
234
+ }
235
+ else {
236
+ const term = `%${part}%`;
237
+ conditions.push("(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)");
238
+ params.push(term, term, term, term);
239
+ }
240
+ }
241
+ return { conditions, params };
242
+ }
7
243
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",