@bobfrankston/mailx 1.0.378 → 1.0.382

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.
@@ -109,6 +109,60 @@ const SCHEMA = `
109
109
  last_error TEXT,
110
110
  UNIQUE(account_id, action, uid, folder_id)
111
111
  );
112
+
113
+ -- Calendar events two-way cache (Android parity with desktop's
114
+ -- packages/mailx-store/db.ts calendar_events table). uuid = stable
115
+ -- local identity, provider_id = Google Calendar event id when known,
116
+ -- dirty = local edit not yet pushed, deleted = tombstone pending delete.
117
+ CREATE TABLE IF NOT EXISTS calendar_events (
118
+ uuid TEXT PRIMARY KEY,
119
+ account_id TEXT NOT NULL,
120
+ provider_id TEXT,
121
+ calendar_id TEXT DEFAULT 'primary',
122
+ title TEXT NOT NULL DEFAULT '',
123
+ start_ms INTEGER NOT NULL,
124
+ end_ms INTEGER NOT NULL,
125
+ all_day INTEGER DEFAULT 0,
126
+ location TEXT DEFAULT '',
127
+ notes TEXT DEFAULT '',
128
+ etag TEXT,
129
+ last_synced INTEGER DEFAULT 0,
130
+ dirty INTEGER DEFAULT 0,
131
+ deleted INTEGER DEFAULT 0,
132
+ updated_at INTEGER NOT NULL
133
+ );
134
+ CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
135
+
136
+ CREATE TABLE IF NOT EXISTS tasks (
137
+ uuid TEXT PRIMARY KEY,
138
+ account_id TEXT NOT NULL,
139
+ provider_id TEXT,
140
+ list_id TEXT DEFAULT '@default',
141
+ title TEXT NOT NULL DEFAULT '',
142
+ notes TEXT DEFAULT '',
143
+ due_ms INTEGER,
144
+ completed_ms INTEGER,
145
+ etag TEXT,
146
+ last_synced INTEGER DEFAULT 0,
147
+ dirty INTEGER DEFAULT 0,
148
+ deleted INTEGER DEFAULT 0,
149
+ updated_at INTEGER NOT NULL
150
+ );
151
+ CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
152
+
153
+ -- Generic store-sync queue for non-message domains (calendar/tasks/contacts).
154
+ CREATE TABLE IF NOT EXISTS store_sync (
155
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ kind TEXT NOT NULL,
157
+ op TEXT NOT NULL,
158
+ account_id TEXT NOT NULL,
159
+ target_uuid TEXT NOT NULL,
160
+ payload TEXT,
161
+ attempts INTEGER DEFAULT 0,
162
+ last_error TEXT,
163
+ created_at INTEGER NOT NULL,
164
+ UNIQUE(kind, target_uuid, op)
165
+ );
112
166
  `;
113
167
  const IDB_NAME = "mailx-sqldb";
114
168
  const IDB_STORE = "database";
@@ -311,21 +311,30 @@ export class WebMailxService {
311
311
  }
312
312
  async syncAccount(accountId) {
313
313
  const folders = await this.syncManager.syncFolders(accountId);
314
- const sorted = [...folders].sort((a, b) => {
315
- if (a.specialUse === "inbox")
316
- return -1;
317
- if (b.specialUse === "inbox")
318
- return 1;
319
- return 0;
320
- });
321
- for (const folder of sorted) {
314
+ // INBOX-first: await INBOX so the UI re-renders new mail immediately,
315
+ // then fire the rest in the background so labels don't block the
316
+ // list. S57 (Android parity with desktop's local-first rule).
317
+ const inbox = folders.find(f => f.specialUse === "inbox");
318
+ const others = folders.filter(f => f.specialUse !== "inbox");
319
+ if (inbox) {
322
320
  try {
323
- await this.syncManager.syncFolder(accountId, folder.id);
321
+ await this.syncManager.syncFolder(accountId, inbox.id);
324
322
  }
325
323
  catch (e) {
326
- console.error(` Skipping folder ${folder.path}: ${e.message}`);
324
+ console.error(` Skipping INBOX ${inbox.path}: ${e.message}`);
327
325
  }
328
326
  }
327
+ // Background fan-out: don't await; errors log. UI already has INBOX.
328
+ (async () => {
329
+ for (const folder of others) {
330
+ try {
331
+ await this.syncManager.syncFolder(accountId, folder.id);
332
+ }
333
+ catch (e) {
334
+ console.error(` Skipping folder ${folder.path}: ${e.message}`);
335
+ }
336
+ }
337
+ })().catch(() => { });
329
338
  }
330
339
  async reauthenticate(accountId) {
331
340
  return this.syncManager.reauthenticate(accountId);
@@ -278,10 +278,22 @@ export declare function sanitizeHtml(html: string): {
278
278
  /** Encode text as RFC 2045 quoted-printable. */
279
279
  export declare function encodeQuotedPrintable(text: string): string;
280
280
  /** Parse search query into structured conditions.
281
- * Supports qualifiers: from:, to:, subject: (unqualified terms search everything).
282
- * Returns { conditions, params } for SQL WHERE clause with LIKE. */
281
+ * Supports qualifiers: from:, to:, subject:, date:, has:attachment,
282
+ * is:flagged, is:unread, is:read. Unqualified terms search across subject /
283
+ * from / preview. Returns { conditions, params } for SQL WHERE clause with
284
+ * LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
285
+ * range comparisons).
286
+ *
287
+ * Date syntax (matches Gmail-ish conventions):
288
+ * - date:2026-04-22 exact day
289
+ * - date:2026-04 month
290
+ * - date:>2026-04-01 after
291
+ * - date:<2026-04-01 before
292
+ * - date:2026-04-01..2026-04-30 range
293
+ * - date:today / yesterday / last7 / last30
294
+ */
283
295
  export declare function parseSearchQuery(query: string): {
284
296
  conditions: string[];
285
- params: string[];
297
+ params: (string | number)[];
286
298
  };
287
299
  //# sourceMappingURL=index.d.ts.map
@@ -66,16 +66,68 @@ export function encodeQuotedPrintable(text) {
66
66
  return result;
67
67
  }
68
68
  /** Parse search query into structured conditions.
69
- * Supports qualifiers: from:, to:, subject: (unqualified terms search everything).
70
- * Returns { conditions, params } for SQL WHERE clause with LIKE. */
69
+ * Supports qualifiers: from:, to:, subject:, date:, has:attachment,
70
+ * is:flagged, is:unread, is:read. Unqualified terms search across subject /
71
+ * from / preview. Returns { conditions, params } for SQL WHERE clause with
72
+ * LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
73
+ * range comparisons).
74
+ *
75
+ * Date syntax (matches Gmail-ish conventions):
76
+ * - date:2026-04-22 exact day
77
+ * - date:2026-04 month
78
+ * - date:>2026-04-01 after
79
+ * - date:<2026-04-01 before
80
+ * - date:2026-04-01..2026-04-30 range
81
+ * - date:today / yesterday / last7 / last30
82
+ */
71
83
  export function parseSearchQuery(query) {
72
84
  const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
73
85
  const conditions = [];
74
86
  const params = [];
87
+ const dayStart = (y, m, d) => new Date(y, m - 1, d).getTime();
88
+ const parseDateSpec = (spec) => {
89
+ const now = new Date();
90
+ const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
91
+ if (spec === "today")
92
+ return { from: today0, to: today0 + 86400_000 };
93
+ if (spec === "yesterday")
94
+ return { from: today0 - 86400_000, to: today0 };
95
+ const lastN = spec.match(/^last(\d+)$/i);
96
+ if (lastN)
97
+ return { from: today0 - parseInt(lastN[1]) * 86400_000 };
98
+ const rangeMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})\.\.(\d{4})-(\d{2})-(\d{2})$/);
99
+ if (rangeMatch)
100
+ return {
101
+ from: dayStart(+rangeMatch[1], +rangeMatch[2], +rangeMatch[3]),
102
+ to: dayStart(+rangeMatch[4], +rangeMatch[5], +rangeMatch[6]) + 86400_000,
103
+ };
104
+ const gtMatch = spec.match(/^>(\d{4})-(\d{2})-(\d{2})$/);
105
+ if (gtMatch)
106
+ return { from: dayStart(+gtMatch[1], +gtMatch[2], +gtMatch[3]) + 86400_000 };
107
+ const ltMatch = spec.match(/^<(\d{4})-(\d{2})-(\d{2})$/);
108
+ if (ltMatch)
109
+ return { to: dayStart(+ltMatch[1], +ltMatch[2], +ltMatch[3]) };
110
+ const monthMatch = spec.match(/^(\d{4})-(\d{2})$/);
111
+ if (monthMatch) {
112
+ const y = +monthMatch[1], m = +monthMatch[2];
113
+ const from = dayStart(y, m, 1);
114
+ const to = m === 12 ? dayStart(y + 1, 1, 1) : dayStart(y, m + 1, 1);
115
+ return { from, to };
116
+ }
117
+ const dayMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})$/);
118
+ if (dayMatch) {
119
+ const from = dayStart(+dayMatch[1], +dayMatch[2], +dayMatch[3]);
120
+ return { from, to: from + 86400_000 };
121
+ }
122
+ return null;
123
+ };
75
124
  for (const part of parts) {
76
125
  const fromMatch = part.match(/^from:(.+)$/i);
77
126
  const toMatch = part.match(/^to:(.+)$/i);
78
127
  const subjectMatch = part.match(/^subject:(.+)$/i);
128
+ const hasMatch = part.match(/^has:(.+)$/i);
129
+ const isMatch = part.match(/^is:(.+)$/i);
130
+ const dateMatch = part.match(/^date:(.+)$/i);
79
131
  if (fromMatch) {
80
132
  const term = `%${fromMatch[1].replace(/"/g, "")}%`;
81
133
  conditions.push("(from_name LIKE ? OR from_address LIKE ?)");
@@ -91,6 +143,42 @@ export function parseSearchQuery(query) {
91
143
  conditions.push("subject LIKE ?");
92
144
  params.push(term);
93
145
  }
146
+ else if (hasMatch) {
147
+ const v = hasMatch[1].toLowerCase();
148
+ if (v === "attachment" || v === "attachments") {
149
+ conditions.push("has_attachments = 1");
150
+ }
151
+ // Unknown has: qualifier — silently drop; treating as a literal
152
+ // search term would be confusing.
153
+ }
154
+ else if (isMatch) {
155
+ const v = isMatch[1].toLowerCase();
156
+ if (v === "flagged" || v === "starred") {
157
+ conditions.push("flags_json LIKE ?");
158
+ params.push("%\\\\Flagged%");
159
+ }
160
+ else if (v === "unread") {
161
+ conditions.push("flags_json NOT LIKE ?");
162
+ params.push("%\\\\Seen%");
163
+ }
164
+ else if (v === "read") {
165
+ conditions.push("flags_json LIKE ?");
166
+ params.push("%\\\\Seen%");
167
+ }
168
+ }
169
+ else if (dateMatch) {
170
+ const spec = parseDateSpec(dateMatch[1]);
171
+ if (spec) {
172
+ if (spec.from !== undefined) {
173
+ conditions.push("date >= ?");
174
+ params.push(spec.from);
175
+ }
176
+ if (spec.to !== undefined) {
177
+ conditions.push("date < ?");
178
+ params.push(spec.to);
179
+ }
180
+ }
181
+ }
94
182
  else {
95
183
  const term = `%${part}%`;
96
184
  conditions.push("(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)");