zele 0.3.15 → 0.3.17

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 (80) hide show
  1. package/README.md +113 -33
  2. package/dist/api-utils.d.ts +7 -0
  3. package/dist/api-utils.js +12 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/auth.d.ts +71 -9
  6. package/dist/auth.js +187 -11
  7. package/dist/auth.js.map +1 -1
  8. package/dist/calendar-time.js +6 -0
  9. package/dist/calendar-time.js.map +1 -1
  10. package/dist/cli.js +6 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/attachment.js +2 -0
  13. package/dist/commands/attachment.js.map +1 -1
  14. package/dist/commands/auth-cmd.js +104 -6
  15. package/dist/commands/auth-cmd.js.map +1 -1
  16. package/dist/commands/calendar.js +1 -1
  17. package/dist/commands/calendar.js.map +1 -1
  18. package/dist/commands/draft.js +9 -3
  19. package/dist/commands/draft.js.map +1 -1
  20. package/dist/commands/filter.d.ts +2 -0
  21. package/dist/commands/filter.js +64 -0
  22. package/dist/commands/filter.js.map +1 -0
  23. package/dist/commands/label.js +19 -9
  24. package/dist/commands/label.js.map +1 -1
  25. package/dist/commands/mail-actions.js +12 -2
  26. package/dist/commands/mail-actions.js.map +1 -1
  27. package/dist/commands/mail.js +194 -84
  28. package/dist/commands/mail.js.map +1 -1
  29. package/dist/commands/profile.js +25 -18
  30. package/dist/commands/profile.js.map +1 -1
  31. package/dist/db.js +24 -0
  32. package/dist/db.js.map +1 -1
  33. package/dist/generated/internal/class.js +2 -2
  34. package/dist/generated/internal/class.js.map +1 -1
  35. package/dist/generated/internal/prismaNamespace.d.ts +2 -0
  36. package/dist/generated/internal/prismaNamespace.js +2 -0
  37. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  38. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +2 -0
  39. package/dist/generated/internal/prismaNamespaceBrowser.js +2 -0
  40. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  41. package/dist/generated/models/Account.d.ts +97 -1
  42. package/dist/gmail-client.d.ts +42 -0
  43. package/dist/gmail-client.js +175 -9
  44. package/dist/gmail-client.js.map +1 -1
  45. package/dist/imap-smtp-client.d.ts +235 -0
  46. package/dist/imap-smtp-client.js +1225 -0
  47. package/dist/imap-smtp-client.js.map +1 -0
  48. package/dist/mail-tui.js +3 -2
  49. package/dist/mail-tui.js.map +1 -1
  50. package/dist/output.d.ts +2 -0
  51. package/dist/output.js +4 -0
  52. package/dist/output.js.map +1 -1
  53. package/package.json +9 -5
  54. package/schema.prisma +7 -5
  55. package/skills/zele/SKILL.md +141 -0
  56. package/src/api-utils.ts +13 -0
  57. package/src/auth.ts +283 -15
  58. package/src/calendar-time.test.ts +35 -0
  59. package/src/calendar-time.ts +5 -0
  60. package/src/cli.ts +6 -1
  61. package/src/commands/attachment.ts +1 -0
  62. package/src/commands/auth-cmd.ts +112 -6
  63. package/src/commands/calendar.ts +1 -1
  64. package/src/commands/draft.ts +7 -3
  65. package/src/commands/filter.ts +74 -0
  66. package/src/commands/label.ts +22 -11
  67. package/src/commands/mail-actions.ts +16 -3
  68. package/src/commands/mail.ts +207 -89
  69. package/src/commands/profile.ts +27 -17
  70. package/src/db.ts +28 -0
  71. package/src/generated/internal/class.ts +2 -2
  72. package/src/generated/internal/prismaNamespace.ts +2 -0
  73. package/src/generated/internal/prismaNamespaceBrowser.ts +2 -0
  74. package/src/generated/models/Account.ts +97 -1
  75. package/src/gmail-client.test.ts +155 -2
  76. package/src/gmail-client.ts +221 -16
  77. package/src/imap-smtp-client.ts +1381 -0
  78. package/src/mail-tui.tsx +3 -3
  79. package/src/output.ts +8 -1
  80. package/src/schema.sql +2 -0
@@ -2,6 +2,16 @@ import { type gmail_v1 } from '@googleapis/gmail';
2
2
  import type { OAuth2Client } from 'google-auth-library';
3
3
  import { AuthError, ApiError, NotFoundError, EmptyThreadError, MissingDataError } from './api-utils.js';
4
4
  import type { AccountId } from './auth.js';
5
+ export type AuthVerdict = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror' | 'bestguesspass';
6
+ export interface AuthResult {
7
+ spf: AuthVerdict;
8
+ dkim: AuthVerdict;
9
+ dmarc: AuthVerdict;
10
+ /** true when all three are 'pass' — the email is fully authenticated */
11
+ authentic: boolean;
12
+ /** raw Authentication-Results header value */
13
+ raw: string;
14
+ }
5
15
  export interface Sender {
6
16
  name?: string;
7
17
  email: string;
@@ -29,6 +39,8 @@ export interface ParsedMessage {
29
39
  mimeType: string;
30
40
  textBody: string | null;
31
41
  attachments: AttachmentMeta[];
42
+ /** SPF/DKIM/DMARC authentication results from Gmail. null for sent/draft messages. */
43
+ auth: AuthResult | null;
32
44
  }
33
45
  export interface AttachmentMeta {
34
46
  attachmentId: string;
@@ -54,10 +66,16 @@ export interface ThreadListItem {
54
66
  snippet: string;
55
67
  subject: string;
56
68
  from: Sender;
69
+ to: Sender[];
70
+ cc: Sender[];
57
71
  date: string;
58
72
  labelIds: string[];
59
73
  unread: boolean;
74
+ starred: boolean;
60
75
  messageCount: number;
76
+ inReplyTo: string | null;
77
+ hasAttachments: boolean;
78
+ listUnsubscribe: string | null;
61
79
  }
62
80
  export interface ThreadListResult {
63
81
  threads: ThreadListItem[];
@@ -244,6 +262,12 @@ export declare class GmailClient {
244
262
  archive({ threadIds }: {
245
263
  threadIds: string[];
246
264
  }): Promise<void | AuthError | ApiError>;
265
+ markAsSpam({ threadIds }: {
266
+ threadIds: string[];
267
+ }): Promise<void | AuthError | ApiError>;
268
+ unmarkSpam({ threadIds }: {
269
+ threadIds: string[];
270
+ }): Promise<void | AuthError | ApiError>;
247
271
  /** Invalidate thread cache after a thread mutation. */
248
272
  private invalidateAfterThreadMutation;
249
273
  /** Moves all spam threads to trash. Does not permanently delete. */
@@ -282,6 +306,18 @@ export declare class GmailClient {
282
306
  deleteLabel({ labelId }: {
283
307
  labelId: string;
284
308
  }): Promise<void>;
309
+ listFilters(): Promise<{
310
+ parsed: gmail_v1.Schema$Filter[];
311
+ } | AuthError | ApiError>;
312
+ createFilter(opts: {
313
+ from?: string;
314
+ query?: string;
315
+ addLabelIds?: string[];
316
+ removeLabelIds?: string[];
317
+ }): Promise<gmail_v1.Schema$Filter | AuthError | ApiError>;
318
+ deleteFilter(filterId: string): Promise<void | AuthError | ApiError>;
319
+ /** Resolve a label name to its ID, auto-creating if missing. Public wrapper for filter commands. */
320
+ resolveLabel(nameOrId: string): Promise<string | AuthError | ApiError>;
285
321
  getLabelCounts(): Promise<{
286
322
  parsed: Array<{
287
323
  label: string;
@@ -327,6 +363,10 @@ export declare class GmailClient {
327
363
  private extractBody;
328
364
  private findBodyPart;
329
365
  private extractAttachmentMeta;
366
+ /** Quick check: does the message payload tree contain any non-inline attachments?
367
+ * Checks the root part itself (some messages have attachment metadata there)
368
+ * then recurses into child parts. */
369
+ private hasNonInlineAttachments;
330
370
  getEmailAliases(): Promise<Array<{
331
371
  email: string;
332
372
  name?: string;
@@ -366,3 +406,5 @@ export declare class GmailClient {
366
406
  private getHeaderAll;
367
407
  private getHeaderValues;
368
408
  }
409
+ /** Parse a Gmail Authentication-Results header into structured verdicts. */
410
+ export declare function parseAuthResults(header: string): AuthResult;
@@ -528,7 +528,9 @@ export class GmailClient {
528
528
  return messageIds;
529
529
  if (messageIds.length === 0)
530
530
  return;
531
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] });
531
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] });
532
+ if (mod instanceof Error)
533
+ return mod;
532
534
  await this.invalidateAfterThreadMutation(threadIds);
533
535
  }
534
536
  async markAsUnread({ threadIds }) {
@@ -537,7 +539,9 @@ export class GmailClient {
537
539
  return messageIds;
538
540
  if (messageIds.length === 0)
539
541
  return;
540
- await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] });
542
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] });
543
+ if (mod instanceof Error)
544
+ return mod;
541
545
  await this.invalidateAfterThreadMutation(threadIds);
542
546
  }
543
547
  async star({ threadIds }) {
@@ -546,7 +550,9 @@ export class GmailClient {
546
550
  return messageIds;
547
551
  if (messageIds.length === 0)
548
552
  return;
549
- await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] });
553
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] });
554
+ if (mod instanceof Error)
555
+ return mod;
550
556
  await this.invalidateAfterThreadMutation(threadIds);
551
557
  }
552
558
  async unstar({ threadIds }) {
@@ -555,7 +561,9 @@ export class GmailClient {
555
561
  return messageIds;
556
562
  if (messageIds.length === 0)
557
563
  return;
558
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] });
564
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] });
565
+ if (mod instanceof Error)
566
+ return mod;
559
567
  await this.invalidateAfterThreadMutation(threadIds);
560
568
  }
561
569
  async modifyLabels({ threadIds, addLabelIds = [], removeLabelIds = [], }) {
@@ -574,10 +582,12 @@ export class GmailClient {
574
582
  return messageIds;
575
583
  if (messageIds.length === 0)
576
584
  return;
577
- await this.batchModifyMessages(messageIds, {
585
+ const mod = await this.batchModifyMessages(messageIds, {
578
586
  addLabelIds: resolvedAdd.filter((r) => typeof r === 'string'),
579
587
  removeLabelIds: resolvedRemove,
580
588
  });
589
+ if (mod instanceof Error)
590
+ return mod;
581
591
  await this.invalidateAfterThreadMutation(threadIds);
582
592
  }
583
593
  async trash({ threadId }) {
@@ -600,7 +610,31 @@ export class GmailClient {
600
610
  return messageIds;
601
611
  if (messageIds.length === 0)
602
612
  return;
603
- 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;
604
638
  await this.invalidateAfterThreadMutation(threadIds);
605
639
  }
606
640
  /** Invalidate thread cache after a thread mutation. */
@@ -625,10 +659,12 @@ export class GmailClient {
625
659
  const messageIds = await this.getMessageIdsForThreads(threadIds);
626
660
  if (messageIds instanceof Error)
627
661
  return messageIds;
628
- await this.batchModifyMessages(messageIds, {
662
+ const mod = await this.batchModifyMessages(messageIds, {
629
663
  addLabelIds: ['TRASH'],
630
664
  removeLabelIds: ['SPAM', 'INBOX'],
631
665
  });
666
+ if (mod instanceof Error)
667
+ return mod;
632
668
  totalDeleted += threadIds.length;
633
669
  pageToken = res.nextPageToken ?? undefined;
634
670
  if (!pageToken)
@@ -699,6 +735,46 @@ export class GmailClient {
699
735
  await this.invalidateLabels();
700
736
  }
701
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
+ // =========================================================================
702
778
  // Label counts (unread counts per folder/label)
703
779
  // =========================================================================
704
780
  async getLabelCounts() {
@@ -831,6 +907,18 @@ export class GmailClient {
831
907
  .map((h) => h.value ?? '')
832
908
  .filter((v) => v.length > 0);
833
909
  const { body, mimeType, textBody } = this.extractBody(message.payload ?? {});
910
+ // Authentication-Results: multiple MTAs can add this header. Prefer the one
911
+ // stamped by Gmail's trusted authserv-id (mx.google.com) to avoid trusting
912
+ // headers injected by upstream/untrusted relays.
913
+ const authHeaders = headers
914
+ .filter((h) => h.name?.toLowerCase() === 'authentication-results')
915
+ .map((h) => h.value ?? '')
916
+ .filter((v) => v.length > 0);
917
+ const trustedAuthHeader = authHeaders.find((v) => v.trim().toLowerCase().startsWith('mx.google.com'))
918
+ ?? authHeaders[0]
919
+ ?? null;
920
+ const isSentOrDraft = labelIds.includes('SENT') || labelIds.includes('DRAFT');
921
+ const auth = trustedAuthHeader && !isSentOrDraft ? parseAuthResults(trustedAuthHeader) : null;
834
922
  return {
835
923
  id: message.id ?? '',
836
924
  threadId: message.threadId ?? '',
@@ -856,6 +944,7 @@ export class GmailClient {
856
944
  mimeType,
857
945
  textBody,
858
946
  attachments: this.extractAttachmentMeta(message.payload?.parts ?? []),
947
+ auth,
859
948
  };
860
949
  }
861
950
  /** Parse raw gmail_v1.Schema$Thread (format: metadata) into ThreadListItem.
@@ -890,16 +979,39 @@ export class GmailClient {
890
979
  }
891
980
  }
892
981
  }
982
+ // Parse recipients from latest message
983
+ const toHeader = getHeader('to') ?? '';
984
+ const ccHeaders = headers
985
+ .filter((h) => h.name?.toLowerCase() === 'cc')
986
+ .map((h) => h.value ?? '')
987
+ .filter((v) => v.length > 0);
988
+ // Check if any non-draft message in the thread is a reply (has In-Reply-To header)
989
+ const inReplyTo = nonDraftMessages
990
+ .map((m) => m.payload?.headers?.find((h) => h.name?.toLowerCase() === 'in-reply-to')?.value ??
991
+ null)
992
+ .find((v) => v !== null) ?? null;
993
+ // Check if any message has attachments (non-inline)
994
+ const hasAttachments = messages.some((m) => this.hasNonInlineAttachments(m.payload));
995
+ // List-Unsubscribe from latest message
996
+ const listUnsubscribe = getHeader('list-unsubscribe') ?? null;
893
997
  return {
894
998
  id: raw.id ?? '',
895
999
  historyId: raw.historyId ?? null,
896
1000
  snippet: sanitizeSnippet(latest?.snippet ?? ''),
897
1001
  subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
898
1002
  from: displayFrom,
1003
+ to: toHeader ? parseAddressList(toHeader) : [],
1004
+ cc: ccHeaders.length > 0
1005
+ ? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
1006
+ : [],
899
1007
  date: getHeader('date') ?? '',
900
1008
  labelIds: allLabels,
901
1009
  unread: allLabels.includes('UNREAD'),
1010
+ starred: allLabels.includes('STARRED'),
902
1011
  messageCount: nonDraftMessages.length,
1012
+ inReplyTo,
1013
+ hasAttachments,
1014
+ listUnsubscribe,
903
1015
  };
904
1016
  }
905
1017
  /** Parse raw gmail_v1.Schema$Label[] from labels.list into our label objects. */
@@ -1001,6 +1113,25 @@ export class GmailClient {
1001
1113
  }
1002
1114
  return results;
1003
1115
  }
1116
+ /** Quick check: does the message payload tree contain any non-inline attachments?
1117
+ * Checks the root part itself (some messages have attachment metadata there)
1118
+ * then recurses into child parts. */
1119
+ hasNonInlineAttachments(part) {
1120
+ if (!part)
1121
+ return false;
1122
+ if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
1123
+ const disposition = part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? '';
1124
+ const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id');
1125
+ const isInline = disposition.toLowerCase().includes('inline');
1126
+ if (!isInline || !hasContentId)
1127
+ return true;
1128
+ }
1129
+ for (const child of part.parts ?? []) {
1130
+ if (this.hasNonInlineAttachments(child))
1131
+ return true;
1132
+ }
1133
+ return false;
1134
+ }
1004
1135
  async getEmailAliases() {
1005
1136
  const profile = await this.getProfile();
1006
1137
  if (profile instanceof Error)
@@ -1345,13 +1476,15 @@ export class GmailClient {
1345
1476
  const chunkSize = 1000;
1346
1477
  for (let i = 0; i < messageIds.length; i += chunkSize) {
1347
1478
  const chunk = messageIds.slice(i, i + chunkSize);
1348
- await withRetry(() => this.gmail.users.messages.batchModify({
1479
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () => withRetry(() => this.gmail.users.messages.batchModify({
1349
1480
  userId: 'me',
1350
1481
  requestBody: {
1351
1482
  ids: chunk,
1352
1483
  ...body,
1353
1484
  },
1354
- }));
1485
+ })));
1486
+ if (res instanceof Error)
1487
+ return res;
1355
1488
  }
1356
1489
  }
1357
1490
  // =========================================================================
@@ -1375,6 +1508,39 @@ export class GmailClient {
1375
1508
  }
1376
1509
  }
1377
1510
  // ---------------------------------------------------------------------------
1511
+ // Email authentication: parse the Authentication-Results header
1512
+ // ---------------------------------------------------------------------------
1513
+ // Gmail adds an Authentication-Results header to every received message with
1514
+ // SPF, DKIM, and DMARC verdicts. Format is semi-structured:
1515
+ // Authentication-Results: mx.google.com;
1516
+ // dkim=pass header.i=@example.com header.s=sel1;
1517
+ // spf=pass (...) smtp.mailfrom=user@example.com;
1518
+ // dmarc=pass (p=REJECT) header.from=example.com
1519
+ // We extract the verdict keyword after each protocol name.
1520
+ // ---------------------------------------------------------------------------
1521
+ const AUTH_VERDICTS = new Set(['pass', 'fail', 'softfail', 'neutral', 'none', 'temperror', 'permerror', 'bestguesspass']);
1522
+ /** Parse a Gmail Authentication-Results header into structured verdicts. */
1523
+ export function parseAuthResults(header) {
1524
+ const lower = header.toLowerCase();
1525
+ const extract = (protocol) => {
1526
+ // Match "protocol=verdict" — verdict is a word that stops at whitespace, semicolons, or parens
1527
+ const re = new RegExp(`\\b${protocol}\\s*=\\s*([a-z]+)`);
1528
+ const match = re.exec(lower);
1529
+ const verdict = match?.[1] ?? 'none';
1530
+ return AUTH_VERDICTS.has(verdict) ? verdict : 'none';
1531
+ };
1532
+ const spf = extract('spf');
1533
+ const dkim = extract('dkim');
1534
+ const dmarc = extract('dmarc');
1535
+ return {
1536
+ spf,
1537
+ dkim,
1538
+ dmarc,
1539
+ authentic: spf === 'pass' && dkim === 'pass' && dmarc === 'pass',
1540
+ raw: header,
1541
+ };
1542
+ }
1543
+ // ---------------------------------------------------------------------------
1378
1544
  // Watch: folder label mapping
1379
1545
  // ---------------------------------------------------------------------------
1380
1546
  const WATCH_FOLDER_LABELS = {