zele 0.3.14 → 0.3.16

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 (56) hide show
  1. package/README.md +25 -0
  2. package/dist/api-utils.d.ts +3 -0
  3. package/dist/api-utils.js +6 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/auth.js +1 -1
  6. package/dist/auth.js.map +1 -1
  7. package/dist/calendar-time.js +6 -0
  8. package/dist/calendar-time.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/attachment.js +42 -2
  12. package/dist/commands/attachment.js.map +1 -1
  13. package/dist/commands/auth-cmd.js +1 -1
  14. package/dist/commands/auth-cmd.js.map +1 -1
  15. package/dist/commands/calendar.js +1 -1
  16. package/dist/commands/calendar.js.map +1 -1
  17. package/dist/commands/draft.js +2 -2
  18. package/dist/commands/draft.js.map +1 -1
  19. package/dist/commands/filter.d.ts +2 -0
  20. package/dist/commands/filter.js +59 -0
  21. package/dist/commands/filter.js.map +1 -0
  22. package/dist/commands/mail-actions.js +12 -2
  23. package/dist/commands/mail-actions.js.map +1 -1
  24. package/dist/commands/mail.js +176 -93
  25. package/dist/commands/mail.js.map +1 -1
  26. package/dist/db.js +24 -1
  27. package/dist/db.js.map +1 -1
  28. package/dist/gmail-client.d.ts +28 -0
  29. package/dist/gmail-client.js +168 -13
  30. package/dist/gmail-client.js.map +1 -1
  31. package/dist/mail-tui.js +34 -9
  32. package/dist/mail-tui.js.map +1 -1
  33. package/dist/output.d.ts +2 -0
  34. package/dist/output.js +4 -0
  35. package/dist/output.js.map +1 -1
  36. package/package.json +8 -3
  37. package/skills/zele/SKILL.md +112 -0
  38. package/src/api-utils.ts +7 -0
  39. package/src/app.log +9 -0
  40. package/src/auth.ts +1 -1
  41. package/src/calendar-time.test.ts +35 -0
  42. package/src/calendar-time.ts +5 -0
  43. package/src/cli.ts +6 -1
  44. package/src/commands/attachment.ts +47 -2
  45. package/src/commands/auth-cmd.ts +1 -1
  46. package/src/commands/calendar.ts +1 -1
  47. package/src/commands/draft.ts +2 -2
  48. package/src/commands/filter.ts +68 -0
  49. package/src/commands/mail-actions.ts +14 -2
  50. package/src/commands/mail.ts +186 -98
  51. package/src/db.ts +26 -1
  52. package/src/gmail-client.ts +202 -20
  53. package/src/mail-tui.test.ts +170 -0
  54. package/src/mail-tui.tsx +56 -9
  55. package/src/output.ts +8 -1
  56. package/src/opentui-react.d.ts +0 -9
@@ -54,10 +54,16 @@ export interface ThreadListItem {
54
54
  snippet: string;
55
55
  subject: string;
56
56
  from: Sender;
57
+ to: Sender[];
58
+ cc: Sender[];
57
59
  date: string;
58
60
  labelIds: string[];
59
61
  unread: boolean;
62
+ starred: boolean;
60
63
  messageCount: number;
64
+ inReplyTo: string | null;
65
+ hasAttachments: boolean;
66
+ listUnsubscribe: string | null;
61
67
  }
62
68
  export interface ThreadListResult {
63
69
  threads: ThreadListItem[];
@@ -244,6 +250,12 @@ export declare class GmailClient {
244
250
  archive({ threadIds }: {
245
251
  threadIds: string[];
246
252
  }): Promise<void | AuthError | ApiError>;
253
+ markAsSpam({ threadIds }: {
254
+ threadIds: string[];
255
+ }): Promise<void | AuthError | ApiError>;
256
+ unmarkSpam({ threadIds }: {
257
+ threadIds: string[];
258
+ }): Promise<void | AuthError | ApiError>;
247
259
  /** Invalidate thread cache after a thread mutation. */
248
260
  private invalidateAfterThreadMutation;
249
261
  /** Moves all spam threads to trash. Does not permanently delete. */
@@ -282,6 +294,18 @@ export declare class GmailClient {
282
294
  deleteLabel({ labelId }: {
283
295
  labelId: string;
284
296
  }): Promise<void>;
297
+ listFilters(): Promise<{
298
+ parsed: gmail_v1.Schema$Filter[];
299
+ } | AuthError | ApiError>;
300
+ createFilter(opts: {
301
+ from?: string;
302
+ query?: string;
303
+ addLabelIds?: string[];
304
+ removeLabelIds?: string[];
305
+ }): Promise<gmail_v1.Schema$Filter | AuthError | ApiError>;
306
+ deleteFilter(filterId: string): Promise<void | AuthError | ApiError>;
307
+ /** Resolve a label name to its ID, auto-creating if missing. Public wrapper for filter commands. */
308
+ resolveLabel(nameOrId: string): Promise<string | AuthError | ApiError>;
285
309
  getLabelCounts(): Promise<{
286
310
  parsed: Array<{
287
311
  label: string;
@@ -327,6 +351,10 @@ export declare class GmailClient {
327
351
  private extractBody;
328
352
  private findBodyPart;
329
353
  private extractAttachmentMeta;
354
+ /** Quick check: does the message payload tree contain any non-inline attachments?
355
+ * Checks the root part itself (some messages have attachment metadata there)
356
+ * then recurses into child parts. */
357
+ private hasNonInlineAttachments;
330
358
  getEmailAliases(): Promise<Array<{
331
359
  email: string;
332
360
  name?: string;
@@ -71,6 +71,14 @@ function sanitizeSnippet(snippet) {
71
71
  .replace(/\s+/g, ' ')
72
72
  .trim();
73
73
  }
74
+ /**
75
+ * Sanitize header values to prevent CRLF injection attacks.
76
+ * The mimetext library does not sanitize custom header values, so newlines
77
+ * in In-Reply-To or References could inject arbitrary headers.
78
+ */
79
+ function sanitizeHeaderValue(value) {
80
+ return value.replace(/[\r\n]/g, ' ').trim();
81
+ }
74
82
  function encodeBase64Url(data) {
75
83
  const buf = typeof data === 'string' ? Buffer.from(data) : data;
76
84
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@ -98,6 +106,23 @@ function gmailBoundary(email, fn) {
98
106
  : new ApiError({ reason: String(err), cause: err }),
99
107
  });
100
108
  }
109
+ /**
110
+ * Known folder names that map to Gmail system folders/labels.
111
+ * Used to validate custom label names and prevent query injection.
112
+ */
113
+ const KNOWN_FOLDERS = new Set([
114
+ 'inbox',
115
+ 'sent',
116
+ 'trash',
117
+ 'bin',
118
+ 'spam',
119
+ 'drafts',
120
+ 'draft',
121
+ 'starred',
122
+ 'archive',
123
+ 'snoozed',
124
+ 'all',
125
+ ]);
101
126
  export class GmailClient {
102
127
  gmail;
103
128
  labelIdCache = {};
@@ -503,7 +528,9 @@ export class GmailClient {
503
528
  return messageIds;
504
529
  if (messageIds.length === 0)
505
530
  return;
506
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] });
531
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] });
532
+ if (mod instanceof Error)
533
+ return mod;
507
534
  await this.invalidateAfterThreadMutation(threadIds);
508
535
  }
509
536
  async markAsUnread({ threadIds }) {
@@ -512,7 +539,9 @@ export class GmailClient {
512
539
  return messageIds;
513
540
  if (messageIds.length === 0)
514
541
  return;
515
- await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] });
542
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] });
543
+ if (mod instanceof Error)
544
+ return mod;
516
545
  await this.invalidateAfterThreadMutation(threadIds);
517
546
  }
518
547
  async star({ threadIds }) {
@@ -521,7 +550,9 @@ export class GmailClient {
521
550
  return messageIds;
522
551
  if (messageIds.length === 0)
523
552
  return;
524
- await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] });
553
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] });
554
+ if (mod instanceof Error)
555
+ return mod;
525
556
  await this.invalidateAfterThreadMutation(threadIds);
526
557
  }
527
558
  async unstar({ threadIds }) {
@@ -530,7 +561,9 @@ export class GmailClient {
530
561
  return messageIds;
531
562
  if (messageIds.length === 0)
532
563
  return;
533
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] });
564
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] });
565
+ if (mod instanceof Error)
566
+ return mod;
534
567
  await this.invalidateAfterThreadMutation(threadIds);
535
568
  }
536
569
  async modifyLabels({ threadIds, addLabelIds = [], removeLabelIds = [], }) {
@@ -549,10 +582,12 @@ export class GmailClient {
549
582
  return messageIds;
550
583
  if (messageIds.length === 0)
551
584
  return;
552
- await this.batchModifyMessages(messageIds, {
585
+ const mod = await this.batchModifyMessages(messageIds, {
553
586
  addLabelIds: resolvedAdd.filter((r) => typeof r === 'string'),
554
587
  removeLabelIds: resolvedRemove,
555
588
  });
589
+ if (mod instanceof Error)
590
+ return mod;
556
591
  await this.invalidateAfterThreadMutation(threadIds);
557
592
  }
558
593
  async trash({ threadId }) {
@@ -575,7 +610,31 @@ export class GmailClient {
575
610
  return messageIds;
576
611
  if (messageIds.length === 0)
577
612
  return;
578
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] });
613
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] });
614
+ if (mod instanceof Error)
615
+ return mod;
616
+ await this.invalidateAfterThreadMutation(threadIds);
617
+ }
618
+ async markAsSpam({ threadIds }) {
619
+ const messageIds = await this.getMessageIdsForThreads(threadIds);
620
+ if (messageIds instanceof Error)
621
+ return messageIds;
622
+ if (messageIds.length === 0)
623
+ return;
624
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['SPAM'], removeLabelIds: ['INBOX'] });
625
+ if (mod instanceof Error)
626
+ return mod;
627
+ await this.invalidateAfterThreadMutation(threadIds);
628
+ }
629
+ async unmarkSpam({ threadIds }) {
630
+ const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) => labelIds.includes('SPAM'));
631
+ if (messageIds instanceof Error)
632
+ return messageIds;
633
+ if (messageIds.length === 0)
634
+ return;
635
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['SPAM'], addLabelIds: ['INBOX'] });
636
+ if (mod instanceof Error)
637
+ return mod;
579
638
  await this.invalidateAfterThreadMutation(threadIds);
580
639
  }
581
640
  /** Invalidate thread cache after a thread mutation. */
@@ -600,10 +659,12 @@ export class GmailClient {
600
659
  const messageIds = await this.getMessageIdsForThreads(threadIds);
601
660
  if (messageIds instanceof Error)
602
661
  return messageIds;
603
- await this.batchModifyMessages(messageIds, {
662
+ const mod = await this.batchModifyMessages(messageIds, {
604
663
  addLabelIds: ['TRASH'],
605
664
  removeLabelIds: ['SPAM', 'INBOX'],
606
665
  });
666
+ if (mod instanceof Error)
667
+ return mod;
607
668
  totalDeleted += threadIds.length;
608
669
  pageToken = res.nextPageToken ?? undefined;
609
670
  if (!pageToken)
@@ -674,6 +735,46 @@ export class GmailClient {
674
735
  await this.invalidateLabels();
675
736
  }
676
737
  // =========================================================================
738
+ // Filters
739
+ // =========================================================================
740
+ async listFilters() {
741
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.settings.filters.list({ userId: 'me' })));
742
+ if (res instanceof Error)
743
+ return res;
744
+ return { parsed: res.data.filter ?? [] };
745
+ }
746
+ async createFilter(opts) {
747
+ const criteria = {};
748
+ if (opts.from)
749
+ criteria.from = opts.from;
750
+ if (opts.query)
751
+ criteria.query = opts.query;
752
+ const action = {};
753
+ if (opts.addLabelIds?.length)
754
+ action.addLabelIds = opts.addLabelIds;
755
+ if (opts.removeLabelIds?.length)
756
+ action.removeLabelIds = opts.removeLabelIds;
757
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.settings.filters.create({
758
+ userId: 'me',
759
+ requestBody: { criteria, action },
760
+ })));
761
+ if (res instanceof Error)
762
+ return res;
763
+ return res.data;
764
+ }
765
+ async deleteFilter(filterId) {
766
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.settings.filters.delete({
767
+ userId: 'me',
768
+ id: filterId,
769
+ })));
770
+ if (res instanceof Error)
771
+ return res;
772
+ }
773
+ /** Resolve a label name to its ID, auto-creating if missing. Public wrapper for filter commands. */
774
+ async resolveLabel(nameOrId) {
775
+ return this.resolveLabelId(nameOrId);
776
+ }
777
+ // =========================================================================
677
778
  // Label counts (unread counts per folder/label)
678
779
  // =========================================================================
679
780
  async getLabelCounts() {
@@ -865,16 +966,39 @@ export class GmailClient {
865
966
  }
866
967
  }
867
968
  }
969
+ // Parse recipients from latest message
970
+ const toHeader = getHeader('to') ?? '';
971
+ const ccHeaders = headers
972
+ .filter((h) => h.name?.toLowerCase() === 'cc')
973
+ .map((h) => h.value ?? '')
974
+ .filter((v) => v.length > 0);
975
+ // Check if any non-draft message in the thread is a reply (has In-Reply-To header)
976
+ const inReplyTo = nonDraftMessages
977
+ .map((m) => m.payload?.headers?.find((h) => h.name?.toLowerCase() === 'in-reply-to')?.value ??
978
+ null)
979
+ .find((v) => v !== null) ?? null;
980
+ // Check if any message has attachments (non-inline)
981
+ const hasAttachments = messages.some((m) => this.hasNonInlineAttachments(m.payload));
982
+ // List-Unsubscribe from latest message
983
+ const listUnsubscribe = getHeader('list-unsubscribe') ?? null;
868
984
  return {
869
985
  id: raw.id ?? '',
870
986
  historyId: raw.historyId ?? null,
871
987
  snippet: sanitizeSnippet(latest?.snippet ?? ''),
872
988
  subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
873
989
  from: displayFrom,
990
+ to: toHeader ? parseAddressList(toHeader) : [],
991
+ cc: ccHeaders.length > 0
992
+ ? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
993
+ : [],
874
994
  date: getHeader('date') ?? '',
875
995
  labelIds: allLabels,
876
996
  unread: allLabels.includes('UNREAD'),
997
+ starred: allLabels.includes('STARRED'),
877
998
  messageCount: nonDraftMessages.length,
999
+ inReplyTo,
1000
+ hasAttachments,
1001
+ listUnsubscribe,
878
1002
  };
879
1003
  }
880
1004
  /** Parse raw gmail_v1.Schema$Label[] from labels.list into our label objects. */
@@ -976,6 +1100,25 @@ export class GmailClient {
976
1100
  }
977
1101
  return results;
978
1102
  }
1103
+ /** Quick check: does the message payload tree contain any non-inline attachments?
1104
+ * Checks the root part itself (some messages have attachment metadata there)
1105
+ * then recurses into child parts. */
1106
+ hasNonInlineAttachments(part) {
1107
+ if (!part)
1108
+ return false;
1109
+ if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
1110
+ const disposition = part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? '';
1111
+ const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id');
1112
+ const isInline = disposition.toLowerCase().includes('inline');
1113
+ if (!isInline || !hasContentId)
1114
+ return true;
1115
+ }
1116
+ for (const child of part.parts ?? []) {
1117
+ if (this.hasNonInlineAttachments(child))
1118
+ return true;
1119
+ }
1120
+ return false;
1121
+ }
979
1122
  async getEmailAliases() {
980
1123
  const profile = await this.getProfile();
981
1124
  if (profile instanceof Error)
@@ -1162,13 +1305,14 @@ export class GmailClient {
1162
1305
  data: body,
1163
1306
  });
1164
1307
  if (inReplyTo) {
1165
- msg.setHeader('In-Reply-To', inReplyTo);
1308
+ msg.setHeader('In-Reply-To', sanitizeHeaderValue(inReplyTo));
1166
1309
  }
1167
1310
  if (references) {
1168
1311
  const refs = references
1169
1312
  .split(' ')
1170
1313
  .filter(Boolean)
1171
1314
  .map((ref) => {
1315
+ ref = sanitizeHeaderValue(ref);
1172
1316
  if (!ref.startsWith('<'))
1173
1317
  ref = `<${ref}`;
1174
1318
  if (!ref.endsWith('>'))
@@ -1244,7 +1388,16 @@ export class GmailClient {
1244
1388
  }
1245
1389
  // For non-inbox folders, use Gmail search syntax.
1246
1390
  // Caller-provided labelIds are preserved as additional filters.
1247
- switch (folder) {
1391
+ // Normalize folder name to lowercase for consistent matching
1392
+ const normalizedFolder = folder.toLowerCase();
1393
+ // Validate custom label names to prevent query injection.
1394
+ // Gmail query operators like "OR", "from:", parentheses, etc. could manipulate search results.
1395
+ // Known folders are handled by the switch cases below; custom labels must be safe characters only.
1396
+ // Slashes are allowed for nested labels (e.g., "work/projects").
1397
+ if (!KNOWN_FOLDERS.has(normalizedFolder) && !/^[\w\/-]+$/.test(normalizedFolder)) {
1398
+ throw new Error(`Invalid folder/label name: "${folder}". Use alphanumeric characters, underscores, hyphens, and slashes only.`);
1399
+ }
1400
+ switch (normalizedFolder) {
1248
1401
  case 'sent':
1249
1402
  q = `in:sent ${q}`.trim();
1250
1403
  break;
@@ -1272,8 +1425,8 @@ export class GmailClient {
1272
1425
  q = `in:anywhere ${q}`.trim();
1273
1426
  break;
1274
1427
  default:
1275
- // Treat as a label name
1276
- q = `label:${folder} ${q}`.trim();
1428
+ // Treat as a label name (use normalized for consistency)
1429
+ q = `label:${normalizedFolder} ${q}`.trim();
1277
1430
  break;
1278
1431
  }
1279
1432
  return { q, resolvedLabelIds };
@@ -1310,13 +1463,15 @@ export class GmailClient {
1310
1463
  const chunkSize = 1000;
1311
1464
  for (let i = 0; i < messageIds.length; i += chunkSize) {
1312
1465
  const chunk = messageIds.slice(i, i + chunkSize);
1313
- await withRetry(() => this.gmail.users.messages.batchModify({
1466
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.messages.batchModify({
1314
1467
  userId: 'me',
1315
1468
  requestBody: {
1316
1469
  ids: chunk,
1317
1470
  ...body,
1318
1471
  },
1319
- }));
1472
+ })));
1473
+ if (res instanceof Error)
1474
+ return res;
1320
1475
  }
1321
1476
  }
1322
1477
  // =========================================================================