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
package/src/api-utils.ts CHANGED
@@ -120,6 +120,12 @@ export class ApiError extends errore.createTaggedError({
120
120
  message: 'API call failed: $reason',
121
121
  }) {}
122
122
 
123
+ /** Returned when a command requires a capability (e.g. gmail labels) that the account doesn't support. */
124
+ export class UnsupportedError extends errore.createTaggedError({
125
+ name: 'UnsupportedError',
126
+ message: '$feature is not available for $accountType accounts. $hint',
127
+ }) {}
128
+
123
129
  /** Detect auth-like errors from underlying libraries (tsdav string errors, googleapis structured errors).
124
130
  * Used inside clients to decide whether to return an AuthError.
125
131
  * NOTE: String matching here is intentional — this is the boundary layer that converts
@@ -133,6 +139,13 @@ export function isAuthLikeError(err: unknown): boolean {
133
139
  return msg.includes('Invalid credentials') || msg.includes('Unauthorized') || msg.includes('invalid_grant')
134
140
  }
135
141
 
142
+ /** Check if an error is specifically about missing OAuth scopes (not expired tokens or revoked access).
143
+ * When true, the user needs to `zele login` again to grant the new scope. */
144
+ export function isScopeError(err: unknown): boolean {
145
+ const msg = String(err)
146
+ return msg.includes('insufficient authentication scopes') || msg.includes('Insufficient Permission')
147
+ }
148
+
136
149
 
137
150
  // ---------------------------------------------------------------------------
138
151
  // Rate limit detection
package/src/auth.ts CHANGED
@@ -16,7 +16,51 @@ import { getPrisma } from './db.js'
16
16
  import { GmailClient } from './gmail-client.js'
17
17
  import { CalendarClient } from './calendar-client.js'
18
18
  import * as errore from 'errore'
19
- import { AuthError } from './api-utils.js'
19
+ import { AuthError, ApiError, UnsupportedError } from './api-utils.js'
20
+ import { ImapSmtpClient } from './imap-smtp-client.js'
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Account types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export type AccountType = 'google' | 'imap_smtp'
27
+
28
+ export const IMAP_SMTP_APP_ID = 'imap_smtp' as const
29
+
30
+ export interface ImapCredentials {
31
+ host: string
32
+ port: number
33
+ user: string
34
+ password: string
35
+ tls: boolean
36
+ }
37
+
38
+ export interface SmtpCredentials {
39
+ host: string
40
+ port: number
41
+ user: string
42
+ password: string
43
+ tls: boolean
44
+ }
45
+
46
+ /** Stored in the `tokens` column for imap_smtp accounts. */
47
+ export interface ImapSmtpCredentials {
48
+ imap?: ImapCredentials
49
+ smtp?: SmtpCredentials
50
+ }
51
+
52
+ /** Capabilities an account can have. */
53
+ export type AccountCapability = 'gmail' | 'calendar' | 'smtp' | 'imap'
54
+
55
+ export function parseCapabilities(raw: string): AccountCapability[] {
56
+ if (!raw) return []
57
+ return raw.split(',').map((s) => s.trim()).filter(Boolean) as AccountCapability[]
58
+ }
59
+
60
+ export function hasCapability(capabilities: string | AccountCapability[], cap: AccountCapability): boolean {
61
+ const list = typeof capabilities === 'string' ? parseCapabilities(capabilities) : capabilities
62
+ return list.includes(cap)
63
+ }
20
64
 
21
65
  // ---------------------------------------------------------------------------
22
66
  // Known open-source Google OAuth clients (Desktop app type).
@@ -59,7 +103,7 @@ const CLIENT_SECRET =
59
103
  process.env.ZELE_CLIENT_SECRET ?? ACTIVE_CLIENT.clientSecret
60
104
 
61
105
  const SCOPES = [
62
- 'https://mail.google.com/', // Gmail (full)
106
+ 'https://mail.google.com/', // Gmail (full — covers settings, filters, etc.)
63
107
  'https://www.googleapis.com/auth/calendar', // Calendar (full)
64
108
  'https://www.googleapis.com/auth/userinfo.email', // Email identity
65
109
  ]
@@ -123,6 +167,8 @@ export function createOAuth2Client(appId?: string): OAuth2Client {
123
167
  export interface AccountId {
124
168
  email: string
125
169
  appId: string
170
+ accountType: AccountType
171
+ capabilities: AccountCapability[]
126
172
  }
127
173
 
128
174
  // ---------------------------------------------------------------------------
@@ -530,6 +576,7 @@ export async function login(
530
576
 
531
577
  // Upsert account in DB
532
578
  const prisma = await getPrisma()
579
+ const googleCapabilities = 'gmail,calendar,smtp'
533
580
  const upsertResult = await errore.tryAsync({
534
581
  try: () =>
535
582
  prisma.account.upsert({
@@ -537,12 +584,14 @@ export async function login(
537
584
  create: {
538
585
  email,
539
586
  appId: resolved.clientId,
587
+ accountType: 'google',
588
+ capabilities: googleCapabilities,
540
589
  accountStatus: 'active',
541
590
  tokens: JSON.stringify(tokens),
542
591
  createdAt: new Date(),
543
592
  updatedAt: new Date(),
544
593
  },
545
- update: { tokens: JSON.stringify(tokens), updatedAt: new Date() },
594
+ update: { tokens: JSON.stringify(tokens), capabilities: googleCapabilities, updatedAt: new Date() },
546
595
  }),
547
596
  catch: (err) => new Error(`Failed to save account ${email}`, { cause: err }),
548
597
  })
@@ -551,6 +600,143 @@ export async function login(
551
600
  return { email, appId: resolved.clientId, client }
552
601
  }
553
602
 
603
+ // ---------------------------------------------------------------------------
604
+ // Login: IMAP/SMTP credentials → save to DB
605
+ // ---------------------------------------------------------------------------
606
+
607
+ export interface LoginImapOptions {
608
+ email: string
609
+ imapHost: string
610
+ imapPort?: number
611
+ smtpHost?: string
612
+ smtpPort?: number
613
+ password?: string
614
+ imapUser?: string
615
+ imapPassword?: string
616
+ smtpUser?: string
617
+ smtpPassword?: string
618
+ tls?: boolean
619
+ }
620
+
621
+ /**
622
+ * Login with IMAP/SMTP credentials. Tests connections before saving.
623
+ * Returns an error value if validation or connection test fails.
624
+ */
625
+ export async function loginImap(
626
+ options: LoginImapOptions,
627
+ ): Promise<{ email: string; appId: string } | Error> {
628
+ const {
629
+ email,
630
+ imapHost,
631
+ imapPort = 993,
632
+ smtpHost,
633
+ smtpPort = 465,
634
+ password,
635
+ imapUser,
636
+ imapPassword,
637
+ smtpUser,
638
+ smtpPassword,
639
+ tls = true,
640
+ } = options
641
+
642
+ const imapPass = imapPassword ?? password
643
+ if (!imapPass) {
644
+ return new Error('IMAP password is required (--password or --imap-password)')
645
+ }
646
+
647
+ const credentials: ImapSmtpCredentials = {
648
+ imap: {
649
+ host: imapHost,
650
+ port: imapPort,
651
+ user: imapUser ?? email,
652
+ password: imapPass,
653
+ tls,
654
+ },
655
+ }
656
+
657
+ // Test IMAP connection
658
+ const { ImapFlow } = await import('imapflow')
659
+ const testClient = new ImapFlow({
660
+ host: imapHost,
661
+ port: imapPort,
662
+ secure: tls,
663
+ auth: { user: imapUser ?? email, pass: imapPass },
664
+ logger: false,
665
+ })
666
+
667
+ const imapTest = await errore.tryAsync({
668
+ try: async () => {
669
+ await testClient.connect()
670
+ await testClient.logout()
671
+ },
672
+ catch: (err) => new AuthError({ email, reason: `IMAP connection failed: ${String(err)}` }),
673
+ })
674
+ if (imapTest instanceof Error) return imapTest
675
+
676
+ // Configure SMTP if provided
677
+ const capabilities: AccountCapability[] = ['imap']
678
+
679
+ if (smtpHost) {
680
+ const smtpPass = smtpPassword ?? password
681
+ if (!smtpPass) {
682
+ return new Error('SMTP password is required when --smtp-host is provided (--password or --smtp-password)')
683
+ }
684
+
685
+ credentials.smtp = {
686
+ host: smtpHost,
687
+ port: smtpPort,
688
+ user: smtpUser ?? email,
689
+ password: smtpPass,
690
+ tls: smtpPort === 465,
691
+ }
692
+
693
+ // Test SMTP connection
694
+ const nodemailer = await import('nodemailer')
695
+ const transporter = nodemailer.default.createTransport({
696
+ host: smtpHost,
697
+ port: smtpPort,
698
+ secure: smtpPort === 465,
699
+ auth: { user: smtpUser ?? email, pass: smtpPass },
700
+ })
701
+
702
+ const smtpTest = await errore.tryAsync({
703
+ try: () => transporter.verify(),
704
+ catch: (err) => new AuthError({ email, reason: `SMTP connection failed: ${String(err)}` }),
705
+ })
706
+ if (smtpTest instanceof Error) return smtpTest
707
+
708
+ capabilities.push('smtp')
709
+ }
710
+
711
+ // Save to DB
712
+ const prisma = await getPrisma()
713
+ const upsertResult = await errore.tryAsync({
714
+ try: () =>
715
+ prisma.account.upsert({
716
+ where: { email_appId: { email, appId: IMAP_SMTP_APP_ID } },
717
+ create: {
718
+ email,
719
+ appId: IMAP_SMTP_APP_ID,
720
+ accountType: 'imap_smtp',
721
+ capabilities: capabilities.join(','),
722
+ accountStatus: 'active',
723
+ tokens: JSON.stringify(credentials),
724
+ createdAt: new Date(),
725
+ updatedAt: new Date(),
726
+ },
727
+ update: {
728
+ capabilities: capabilities.join(','),
729
+ tokens: JSON.stringify(credentials),
730
+ updatedAt: new Date(),
731
+ },
732
+ }),
733
+ catch: (err) => new Error(`Failed to save account ${email}`, { cause: err }),
734
+ })
735
+ if (upsertResult instanceof Error) return upsertResult
736
+
737
+ return { email, appId: IMAP_SMTP_APP_ID }
738
+ }
739
+
554
740
  // ---------------------------------------------------------------------------
555
741
  // Logout: remove account from DB
556
742
  // ---------------------------------------------------------------------------
@@ -571,8 +757,13 @@ export async function logout(email: string): Promise<void | Error> {
571
757
 
572
758
  export async function listAccounts(): Promise<AccountId[]> {
573
759
  const prisma = await getPrisma()
574
- const rows = await prisma.account.findMany({ select: { email: true, appId: true } })
575
- return rows.map((r) => ({ email: r.email, appId: r.appId }))
760
+ const rows = await prisma.account.findMany({ select: { email: true, appId: true, accountType: true, capabilities: true } })
761
+ return rows.map((r) => ({
762
+ email: r.email,
763
+ appId: r.appId,
764
+ accountType: r.accountType as AccountType,
765
+ capabilities: parseCapabilities(r.capabilities),
766
+ }))
576
767
  }
577
768
 
578
769
  // ---------------------------------------------------------------------------
@@ -613,13 +804,23 @@ async function authenticateAccount(account: AccountId): Promise<OAuth2Client> {
613
804
  return oauth2Client
614
805
  }
615
806
 
807
+ /** Entry returned by getClients — client is GmailClient for Google accounts, ImapSmtpClient for IMAP/SMTP. */
808
+ export interface ClientEntry {
809
+ email: string
810
+ appId: string
811
+ accountType: AccountType
812
+ capabilities: AccountCapability[]
813
+ client: GmailClient | ImapSmtpClient
814
+ }
815
+
616
816
  /**
617
- * Get authenticated GmailClient instances for all accounts (or filtered by email list).
817
+ * Get authenticated client instances for all accounts (or filtered by email list).
818
+ * Returns GmailClient for Google accounts and ImapSmtpClient for IMAP/SMTP accounts.
618
819
  * If no accounts are registered, throws with a helpful message.
619
820
  */
620
821
  export async function getClients(
621
822
  accounts?: string[],
622
- ): Promise<Array<{ email: string; appId: string; client: GmailClient }>> {
823
+ ): Promise<ClientEntry[]> {
623
824
  const allAccounts = await listAccounts()
624
825
  if (allAccounts.length === 0) {
625
826
  throw new Error('No accounts registered. Run: zele login')
@@ -634,10 +835,33 @@ export async function getClients(
634
835
  throw new Error(`No matching accounts. Available: ${available}`)
635
836
  }
636
837
 
838
+ const prisma = await getPrisma()
637
839
  const results = await Promise.all(
638
- filtered.map(async (account) => {
840
+ filtered.map(async (account): Promise<ClientEntry> => {
841
+ if (account.accountType === 'imap_smtp') {
842
+ const row = await prisma.account.findUnique({
843
+ where: { email_appId: { email: account.email, appId: account.appId } },
844
+ })
845
+ if (!row) throw new Error(`No account found for ${account.email}. Run: zele login`)
846
+ const credentials: ImapSmtpCredentials = JSON.parse(row.tokens)
847
+ return {
848
+ email: account.email,
849
+ appId: account.appId,
850
+ accountType: 'imap_smtp',
851
+ capabilities: account.capabilities,
852
+ client: new ImapSmtpClient({ credentials, account }),
853
+ }
854
+ }
855
+
856
+ // Google account
639
857
  const auth = await authenticateAccount(account)
640
- return { email: account.email, appId: account.appId, client: new GmailClient({ auth, account }) }
858
+ return {
859
+ email: account.email,
860
+ appId: account.appId,
861
+ accountType: 'google',
862
+ capabilities: account.capabilities,
863
+ client: new GmailClient({ auth, account }),
864
+ }
641
865
  }),
642
866
  )
643
867
 
@@ -645,12 +869,12 @@ export async function getClients(
645
869
  }
646
870
 
647
871
  /**
648
- * Get a single authenticated GmailClient. Errors if multiple accounts exist
872
+ * Get a single authenticated client. Errors if multiple accounts exist
649
873
  * and no --account filter was provided.
650
874
  */
651
875
  export async function getClient(
652
876
  accounts?: string[],
653
- ): Promise<{ email: string; appId: string; client: GmailClient }> {
877
+ ): Promise<ClientEntry> {
654
878
  const clients = await getClients(accounts)
655
879
  if (clients.length === 1) {
656
880
  return clients[0]!
@@ -662,12 +886,31 @@ export async function getClient(
662
886
  )
663
887
  }
664
888
 
889
+ /**
890
+ * Get a single authenticated GmailClient. Errors if account is not a Google account.
891
+ * Use this for commands that require Gmail-specific features.
892
+ */
893
+ export async function getGmailClient(
894
+ accounts?: string[],
895
+ ): Promise<{ email: string; appId: string; client: GmailClient }> {
896
+ const entry = await getClient(accounts)
897
+ if (entry.accountType !== 'google') {
898
+ throw new UnsupportedError({
899
+ feature: 'This command',
900
+ accountType: 'IMAP/SMTP',
901
+ hint: 'It requires a Google account.',
902
+ })
903
+ }
904
+ return { email: entry.email, appId: entry.appId, client: entry.client as GmailClient }
905
+ }
906
+
665
907
  // ---------------------------------------------------------------------------
666
908
  // Calendar client helpers
667
909
  // ---------------------------------------------------------------------------
668
910
 
669
911
  /**
670
- * Get authenticated CalendarClient instances for all accounts (or filtered by email list).
912
+ * Get authenticated CalendarClient instances for Google accounts only.
913
+ * IMAP/SMTP accounts are silently skipped (calendar requires Google OAuth).
671
914
  */
672
915
  export async function getCalendarClients(
673
916
  accounts?: string[],
@@ -686,8 +929,18 @@ export async function getCalendarClients(
686
929
  throw new Error(`No matching accounts. Available: ${available}`)
687
930
  }
688
931
 
932
+ // Only Google accounts support calendar
933
+ const googleAccounts = filtered.filter((a) => a.accountType === 'google')
934
+ if (googleAccounts.length === 0) {
935
+ throw new UnsupportedError({
936
+ feature: 'Calendar',
937
+ accountType: 'IMAP/SMTP',
938
+ hint: 'Calendar requires a Google account.',
939
+ })
940
+ }
941
+
689
942
  const results = await Promise.all(
690
- filtered.map(async (account) => {
943
+ googleAccounts.map(async (account) => {
691
944
  const auth = await authenticateAccount(account)
692
945
  const { token } = await auth.getAccessToken()
693
946
  if (!token) throw new Error(`Failed to get access token for ${account.email}`)
@@ -723,6 +976,8 @@ export async function getCalendarClient(
723
976
  export interface AuthStatus {
724
977
  email: string
725
978
  appId: string
979
+ accountType: AccountType
980
+ capabilities: AccountCapability[]
726
981
  expiresAt?: Date
727
982
  }
728
983
 
@@ -731,11 +986,24 @@ export async function getAuthStatuses(): Promise<AuthStatus[]> {
731
986
  const rows = await prisma.account.findMany()
732
987
 
733
988
  return rows.map((row) => {
734
- const tokens: Credentials = JSON.parse(row.tokens)
989
+ const accountType = row.accountType as AccountType
990
+ const capabilities = parseCapabilities(row.capabilities)
991
+ if (accountType === 'google') {
992
+ const tokens: Credentials = JSON.parse(row.tokens)
993
+ return {
994
+ email: row.email,
995
+ appId: row.appId,
996
+ accountType,
997
+ capabilities,
998
+ expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined,
999
+ }
1000
+ }
1001
+ // IMAP/SMTP — no expiry
735
1002
  return {
736
1003
  email: row.email,
737
1004
  appId: row.appId,
738
- expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined,
1005
+ accountType,
1006
+ capabilities,
739
1007
  }
740
1008
  })
741
1009
  }
@@ -0,0 +1,35 @@
1
+ // Tests for calendar time range resolution logic.
2
+
3
+ import { expect, test, describe } from 'vitest'
4
+ import { resolveTimeRange, parseTimeExpression, isDateOnly } from './calendar-time.js'
5
+
6
+ const tz = 'America/New_York'
7
+
8
+ describe('resolveTimeRange', () => {
9
+ test('same-day --from and --to produces full day range', () => {
10
+ const range = resolveTimeRange({ from: '2026-03-12', to: '2026-03-12' }, tz)
11
+ // --from date-only → start of day (00:00)
12
+ expect(range.timeMin).toMatch(/^2026-03-12T00:00:00/)
13
+ // --to date-only → end of day (23:59)
14
+ expect(range.timeMax).toMatch(/^2026-03-12T23:59:00/)
15
+ })
16
+
17
+ test('--to date-only resolves to end of day', () => {
18
+ const range = resolveTimeRange({ from: '2026-03-10', to: '2026-03-14' }, tz)
19
+ expect(range.timeMin).toMatch(/^2026-03-10T00:00:00/)
20
+ expect(range.timeMax).toMatch(/^2026-03-14T23:59:00/)
21
+ })
22
+
23
+ test('--to with explicit time is preserved', () => {
24
+ const range = resolveTimeRange({ from: '2026-03-12', to: '2026-03-12T14:00' }, tz)
25
+ expect(range.timeMin).toMatch(/^2026-03-12T00:00:00/)
26
+ expect(range.timeMax).toMatch(/^2026-03-12T14:00:00/)
27
+ })
28
+
29
+ test('--to with duration is relative to --from', () => {
30
+ const range = resolveTimeRange({ from: '2026-03-12', to: '+2d' }, tz)
31
+ expect(range.timeMin).toMatch(/^2026-03-12T00:00:00/)
32
+ // 2 days after 2026-03-12T00:00
33
+ expect(range.timeMax).toMatch(/^2026-03-14/)
34
+ })
35
+ })
@@ -278,6 +278,11 @@ export function resolveTimeRange(opts: TimeRangeOptions, tz: string): ResolvedTi
278
278
  if (durationMs !== null) {
279
279
  const fromDate = new Date(fromStr)
280
280
  toStr = new Date(fromDate.getTime() + durationMs).toISOString()
281
+ } else if (isDateOnly(opts.to)) {
282
+ // Date-only --to means "through end of that day" (23:59),
283
+ // consistent with --today/--tomorrow/--week convenience flags.
284
+ const [y, m, d] = opts.to.split('-').map(Number)
285
+ toStr = dateToRfc3339(y!, m!, d!, 23, 59, tz)
281
286
  } else {
282
287
  toStr = parseTimeExpression(opts.to, tz)
283
288
  }
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // Entry point: registers all commands, global options, help, and version.
5
5
  // Uses goke for command parsing with zod schemas for type-safe options.
6
6
 
7
+ import { createRequire } from 'node:module'
7
8
  import { goke } from 'goke'
8
9
  import { z } from 'zod'
9
10
  import React from 'react'
@@ -17,6 +18,7 @@ import { registerAttachmentCommands } from './commands/attachment.js'
17
18
  import { registerProfileCommands } from './commands/profile.js'
18
19
  import { registerCalendarCommands } from './commands/calendar.js'
19
20
  import { registerWatchCommands } from './commands/watch.js'
21
+ import { registerFilterCommands } from './commands/filter.js'
20
22
  import { handleCommandError } from './output.js'
21
23
 
22
24
  const cli = goke('zele')
@@ -79,13 +81,16 @@ registerLabelCommands(cli)
79
81
  registerAttachmentCommands(cli)
80
82
  registerCalendarCommands(cli)
81
83
  registerWatchCommands(cli)
84
+ registerFilterCommands(cli)
82
85
 
83
86
  // ---------------------------------------------------------------------------
84
87
  // Help & version
85
88
  // ---------------------------------------------------------------------------
86
89
 
87
90
  cli.help()
88
- cli.version('0.3.15')
91
+ const require = createRequire(import.meta.url)
92
+ const { version } = require('../package.json')
93
+ cli.version(version)
89
94
 
90
95
  // ---------------------------------------------------------------------------
91
96
  // Parse & run
@@ -86,6 +86,7 @@ export function registerAttachmentCommands(cli: Goke) {
86
86
 
87
87
  // Download
88
88
  const base64Data = await client.getAttachment({ messageId, attachmentId })
89
+ if (base64Data instanceof Error) return handleCommandError(base64Data)
89
90
  const buffer = Buffer.from(base64Data, 'base64')
90
91
 
91
92
  // Ensure output directory exists
@@ -1,17 +1,72 @@
1
- // Auth commands: login, logout, whoami.
2
- // Manages OAuth2 authentication for zele.
1
+ // Auth commands: login, login imap, logout, whoami.
2
+ // Manages authentication for zele (Google OAuth and IMAP/SMTP credentials).
3
3
  // Supports multiple accounts: login adds accounts, logout removes one.
4
4
 
5
5
  import type { Goke } from 'goke'
6
- import { login, logout, listAccounts, getAuthStatuses } from '../auth.js'
6
+ import { z } from 'zod'
7
+ import pc from 'picocolors'
8
+ import { login, loginImap, logout, listAccounts, getAuthStatuses } from '../auth.js'
7
9
  import { closePrisma } from '../db.js'
8
10
  import * as out from '../output.js'
9
11
  import { handleCommandError } from '../output.js'
10
12
 
11
13
  export function registerAuthCommands(cli: Goke) {
12
14
  cli
13
- .command('login', 'Authenticate with Google (opens browser). Run in background via tmux for remote/headless environments. The command prints an authorization URL — show it to the user, ask them to complete consent in their browser, then paste back the localhost redirect URL containing the auth code.')
15
+ .command('login', 'Authenticate with Google (opens browser) or show IMAP/SMTP login instructions')
14
16
  .action(async () => {
17
+ // In a TTY, ask if they want Google or Other
18
+ if (process.stdin.isTTY) {
19
+ const readline = await import('node:readline')
20
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
21
+
22
+ console.error(pc.bold('\nChoose authentication method:\n'))
23
+ console.error(' ' + pc.cyan('1') + ' Google (opens browser for OAuth)')
24
+ console.error(' ' + pc.cyan('2') + ' Other (IMAP/SMTP with password)\n')
25
+
26
+ const answer = await new Promise<string>((resolve) => {
27
+ rl.question('Enter choice [1]: ', resolve)
28
+ })
29
+ rl.close()
30
+
31
+ const choice = answer.trim() || '1'
32
+
33
+ if (choice === '2') {
34
+ console.error(pc.bold('\nTo add an IMAP/SMTP account, run:\n'))
35
+ console.error(pc.dim(' # Fastmail'))
36
+ console.error(` zele login imap \\`)
37
+ console.error(` --email you@fastmail.com \\`)
38
+ console.error(` --imap-host imap.fastmail.com --imap-port 993 \\`)
39
+ console.error(` --smtp-host smtp.fastmail.com --smtp-port 465 \\`)
40
+ console.error(` --password "your-app-password"`)
41
+ console.error()
42
+ console.error(pc.dim(' # Gmail (app password)'))
43
+ console.error(` zele login imap \\`)
44
+ console.error(` --email you@gmail.com \\`)
45
+ console.error(` --imap-host imap.gmail.com --imap-port 993 \\`)
46
+ console.error(` --smtp-host smtp.gmail.com --smtp-port 465 \\`)
47
+ console.error(` --password "your-app-password"`)
48
+ console.error()
49
+ console.error(pc.dim(' # Outlook/Hotmail'))
50
+ console.error(` zele login imap \\`)
51
+ console.error(` --email you@outlook.com \\`)
52
+ console.error(` --imap-host outlook.office365.com --imap-port 993 \\`)
53
+ console.error(` --smtp-host smtp-mail.outlook.com --smtp-port 587 \\`)
54
+ console.error(` --password "your-password"`)
55
+ console.error()
56
+ console.error(pc.dim(' # Generic (any IMAP/SMTP provider)'))
57
+ console.error(` zele login imap \\`)
58
+ console.error(` --email you@example.com \\`)
59
+ console.error(` --imap-host imap.example.com --imap-port 993 \\`)
60
+ console.error(` --smtp-host smtp.example.com --smtp-port 465 \\`)
61
+ console.error(` --password "your-password"`)
62
+ console.error()
63
+ console.error(pc.dim('Omit --smtp-host for read-only (IMAP only, no sending).'))
64
+ console.error(pc.dim('Use --imap-user/--smtp-user if the login username differs from your email.'))
65
+ return
66
+ }
67
+ }
68
+
69
+ // Default: Google OAuth flow
15
70
  const result = await login()
16
71
  if (result instanceof Error) handleCommandError(result)
17
72
  const { email } = result
@@ -20,6 +75,56 @@ export function registerAuthCommands(cli: Goke) {
20
75
  process.exit(0)
21
76
  })
22
77
 
78
+ cli
79
+ .command('login imap', 'Add an IMAP/SMTP email account (non-interactive, designed for agents)')
80
+ .option('--email <email>', z.string().describe('Email address'))
81
+ .option('--imap-host <imapHost>', z.string().describe('IMAP server hostname'))
82
+ .option('--imap-port <imapPort>', z.string().describe('IMAP server port (default: 993)'))
83
+ .option('--smtp-host <smtpHost>', z.string().describe('SMTP server hostname (optional, enables sending)'))
84
+ .option('--smtp-port <smtpPort>', z.string().describe('SMTP server port (default: 465)'))
85
+ .option('--password <password>', z.string().describe('Password (shared for IMAP and SMTP unless overridden)'))
86
+ .option('--imap-user <imapUser>', z.string().describe('IMAP username (defaults to --email)'))
87
+ .option('--imap-password <imapPassword>', z.string().describe('IMAP password (overrides --password)'))
88
+ .option('--smtp-user <smtpUser>', z.string().describe('SMTP username (defaults to --email)'))
89
+ .option('--smtp-password <smtpPassword>', z.string().describe('SMTP password (overrides --password)'))
90
+ .option('--no-tls', 'Disable TLS (not recommended)')
91
+ .action(async (options) => {
92
+ if (!options.email) {
93
+ out.error('--email is required')
94
+ process.exit(1)
95
+ }
96
+ if (!options.imapHost) {
97
+ out.error('--imap-host is required')
98
+ process.exit(1)
99
+ }
100
+ if (!options.password && !options.imapPassword) {
101
+ out.error('--password or --imap-password is required')
102
+ process.exit(1)
103
+ }
104
+
105
+ out.hint('Testing IMAP connection...')
106
+
107
+ const result = await loginImap({
108
+ email: options.email,
109
+ imapHost: options.imapHost,
110
+ imapPort: options.imapPort ? Number(options.imapPort) : undefined,
111
+ smtpHost: options.smtpHost,
112
+ smtpPort: options.smtpPort ? Number(options.smtpPort) : undefined,
113
+ password: options.password,
114
+ imapUser: options.imapUser,
115
+ imapPassword: options.imapPassword,
116
+ smtpUser: options.smtpUser,
117
+ smtpPassword: options.smtpPassword,
118
+ tls: options.noTls !== true,
119
+ })
120
+ if (result instanceof Error) handleCommandError(result)
121
+
122
+ const caps = options.smtpHost ? 'IMAP + SMTP' : 'IMAP only'
123
+ out.success(`Authenticated ${result.email} (${caps})`)
124
+ await closePrisma()
125
+ process.exit(0)
126
+ })
127
+
23
128
  cli
24
129
  .command('logout [email]', 'Remove stored credentials for an account')
25
130
  .option('--force', 'Skip confirmation')
@@ -88,9 +193,10 @@ export function registerAuthCommands(cli: Goke) {
88
193
  out.printList(
89
194
  statuses.map((s) => ({
90
195
  email: s.email,
91
- app_id: s.appId,
196
+ type: s.accountType,
197
+ capabilities: s.capabilities.join(', '),
92
198
  status: 'Authenticated',
93
- expires: s.expiresAt?.toISOString() ?? 'unknown',
199
+ ...(s.expiresAt ? { expires: s.expiresAt.toISOString() } : {}),
94
200
  })),
95
201
  { summary: `${statuses.length} account(s)` },
96
202
  )