zele 0.3.16 → 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 (63) hide show
  1. package/README.md +91 -36
  2. package/dist/api-utils.d.ts +4 -0
  3. package/dist/api-utils.js +6 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/auth.d.ts +71 -9
  6. package/dist/auth.js +186 -10
  7. package/dist/auth.js.map +1 -1
  8. package/dist/commands/attachment.js +2 -0
  9. package/dist/commands/attachment.js.map +1 -1
  10. package/dist/commands/auth-cmd.js +104 -6
  11. package/dist/commands/auth-cmd.js.map +1 -1
  12. package/dist/commands/draft.js +7 -1
  13. package/dist/commands/draft.js.map +1 -1
  14. package/dist/commands/filter.js +7 -2
  15. package/dist/commands/filter.js.map +1 -1
  16. package/dist/commands/label.js +19 -9
  17. package/dist/commands/label.js.map +1 -1
  18. package/dist/commands/mail-actions.js.map +1 -1
  19. package/dist/commands/mail.js +49 -22
  20. package/dist/commands/mail.js.map +1 -1
  21. package/dist/commands/profile.js +25 -18
  22. package/dist/commands/profile.js.map +1 -1
  23. package/dist/db.js +24 -0
  24. package/dist/db.js.map +1 -1
  25. package/dist/generated/internal/class.js +2 -2
  26. package/dist/generated/internal/class.js.map +1 -1
  27. package/dist/generated/internal/prismaNamespace.d.ts +2 -0
  28. package/dist/generated/internal/prismaNamespace.js +2 -0
  29. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  30. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +2 -0
  31. package/dist/generated/internal/prismaNamespaceBrowser.js +2 -0
  32. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  33. package/dist/generated/models/Account.d.ts +97 -1
  34. package/dist/gmail-client.d.ts +14 -0
  35. package/dist/gmail-client.js +46 -0
  36. package/dist/gmail-client.js.map +1 -1
  37. package/dist/imap-smtp-client.d.ts +235 -0
  38. package/dist/imap-smtp-client.js +1225 -0
  39. package/dist/imap-smtp-client.js.map +1 -0
  40. package/dist/mail-tui.js.map +1 -1
  41. package/package.json +5 -2
  42. package/schema.prisma +7 -5
  43. package/skills/zele/SKILL.md +50 -21
  44. package/src/api-utils.ts +6 -0
  45. package/src/auth.ts +282 -14
  46. package/src/commands/attachment.ts +1 -0
  47. package/src/commands/auth-cmd.ts +112 -6
  48. package/src/commands/draft.ts +5 -1
  49. package/src/commands/filter.ts +9 -3
  50. package/src/commands/label.ts +22 -11
  51. package/src/commands/mail-actions.ts +2 -1
  52. package/src/commands/mail.ts +52 -22
  53. package/src/commands/profile.ts +27 -17
  54. package/src/db.ts +28 -0
  55. package/src/generated/internal/class.ts +2 -2
  56. package/src/generated/internal/prismaNamespace.ts +2 -0
  57. package/src/generated/internal/prismaNamespaceBrowser.ts +2 -0
  58. package/src/generated/models/Account.ts +97 -1
  59. package/src/gmail-client.test.ts +155 -2
  60. package/src/gmail-client.ts +65 -0
  61. package/src/imap-smtp-client.ts +1381 -0
  62. package/src/mail-tui.tsx +2 -1
  63. package/src/schema.sql +2 -0
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).
@@ -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
  }
@@ -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). For headless/agent environments, run inside tmux: the command prints an authorization URL to open in a browser, then waits for the localhost redirect URL to be pasted back.')
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
  )
@@ -8,6 +8,7 @@ import { z } from 'zod'
8
8
  import fs from 'node:fs'
9
9
  import { getClients, getClient } from '../auth.js'
10
10
  import type { GmailClient } from '../gmail-client.js'
11
+ import type { ImapSmtpClient } from '../imap-smtp-client.js'
11
12
  import { AuthError } from '../api-utils.js'
12
13
  import * as out from '../output.js'
13
14
  import { handleCommandError } from '../output.js'
@@ -154,6 +155,7 @@ export function registerDraftCommands(cli: Goke) {
154
155
  threadId: options.thread,
155
156
  fromEmail: options.from,
156
157
  })
158
+ if (result instanceof Error) handleCommandError(result)
157
159
 
158
160
  out.printYaml(result)
159
161
  out.success('Draft created')
@@ -168,6 +170,7 @@ export function registerDraftCommands(cli: Goke) {
168
170
  .action(async (draftId, options) => {
169
171
  const { client } = await getClient(options.account)
170
172
  const result = await client.sendDraft({ draftId })
173
+ if (result instanceof Error) handleCommandError(result)
171
174
 
172
175
  out.printYaml(result)
173
176
  out.success('Draft sent')
@@ -197,7 +200,8 @@ export function registerDraftCommands(cli: Goke) {
197
200
 
198
201
  const { client } = await getClient(options.account)
199
202
 
200
- await client.deleteDraft({ draftId })
203
+ const deleteResult = await client.deleteDraft({ draftId })
204
+ if (deleteResult instanceof Error) handleCommandError(deleteResult)
201
205
 
202
206
  out.printYaml({ draft_id: draftId, deleted: true })
203
207
  })
@@ -3,8 +3,10 @@
3
3
 
4
4
  import type { Goke } from 'goke'
5
5
  import { getClients } from '../auth.js'
6
- import { AuthError, isScopeError } from '../api-utils.js'
6
+ import { AuthError, UnsupportedError, isScopeError } from '../api-utils.js'
7
+ import type { GmailClient } from '../gmail-client.js'
7
8
  import * as out from '../output.js'
9
+ import { handleCommandError } from '../output.js'
8
10
 
9
11
  export function registerFilterCommands(cli: Goke) {
10
12
  // =========================================================================
@@ -15,10 +17,14 @@ export function registerFilterCommands(cli: Goke) {
15
17
  .command('mail filter list', 'List all Gmail filters')
16
18
  .action(async (options) => {
17
19
  const clients = await getClients(options.account)
20
+ const googleClients = clients.filter((c) => c.accountType === 'google')
21
+ if (googleClients.length === 0) {
22
+ handleCommandError(new UnsupportedError({ feature: 'Filters', accountType: 'IMAP/SMTP', hint: 'Filters are a Gmail-specific feature.' }))
23
+ }
18
24
 
19
25
  const results = await Promise.all(
20
- clients.map(async ({ email, client }) => {
21
- const res = await client.listFilters()
26
+ googleClients.map(async ({ email, client }) => {
27
+ const res = await (client as GmailClient).listFilters()
22
28
  if (res instanceof Error) return res
23
29
  return { email, filters: res.parsed }
24
30
  }),