inboxd 1.0.11 → 1.0.13

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.
package/src/cli.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { program } = require('commander');
4
- const { getUnreadEmails, getEmailCount, trashEmails, getEmailById, untrashEmails, markAsRead, archiveEmails, groupEmailsBySender } = require('./gmail-monitor');
4
+ const { getUnreadEmails, getEmailCount, trashEmails, getEmailById, untrashEmails, markAsRead, markAsUnread, archiveEmails, groupEmailsBySender, getEmailContent, searchEmails, sendEmail, replyToEmail, extractLinks } = require('./gmail-monitor');
5
5
  const { getState, updateLastCheck, markEmailsSeen, getNewEmailIds, clearOldSeenEmails } = require('./state');
6
6
  const { notifyNewEmails } = require('./notifier');
7
7
  const { authorize, addAccount, getAccounts, getAccountEmail, removeAccount, removeAllAccounts, renameTokenFile, validateCredentialsFile, hasCredentials, isConfigured, installCredentials } = require('./gmail-auth');
8
8
  const { logDeletions, getRecentDeletions, getLogPath, readLog, removeLogEntries } = require('./deletion-log');
9
9
  const { getSkillStatus, checkForUpdate, installSkill, SKILL_DEST_DIR, SOURCE_MARKER } = require('./skill-installer');
10
+ const { logSentEmail, getSentLogPath } = require('./sent-log');
10
11
  const readline = require('readline');
11
12
  const path = require('path');
12
13
  const os = require('os');
@@ -62,6 +63,61 @@ function parseSinceDuration(duration) {
62
63
  }
63
64
  }
64
65
 
66
+ /**
67
+ * Parses a duration string for Gmail's older_than query
68
+ * Gmail only supports days (d) for older_than, so we convert weeks/months to days
69
+ * @param {string} duration - Duration string (e.g., "30d", "2w", "1m")
70
+ * @returns {string|null} Gmail query component (e.g., "30d") or null if invalid
71
+ */
72
+ function parseOlderThanDuration(duration) {
73
+ const match = duration.match(/^(\d+)([dwm])$/i);
74
+ if (!match) {
75
+ return null;
76
+ }
77
+
78
+ const value = parseInt(match[1], 10);
79
+ const unit = match[2].toLowerCase();
80
+
81
+ switch (unit) {
82
+ case 'd': // days
83
+ return `${value}d`;
84
+ case 'w': // weeks -> days
85
+ return `${value * 7}d`;
86
+ case 'm': // months (approximate as 30 days)
87
+ return `${value * 30}d`;
88
+ default:
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Resolves the account to use, prompting user if ambiguous
95
+ * @param {string|undefined} specifiedAccount - Account specified via option
96
+ * @param {Object} chalk - Chalk instance for coloring
97
+ * @returns {{account: string|null, error: string|null}} Account name or error
98
+ */
99
+ function resolveAccount(specifiedAccount, chalk) {
100
+ if (specifiedAccount) {
101
+ return { account: specifiedAccount, error: null };
102
+ }
103
+
104
+ const accounts = getAccounts();
105
+ if (accounts.length === 0) {
106
+ return { account: 'default', error: null };
107
+ }
108
+ if (accounts.length === 1) {
109
+ return { account: accounts[0].name, error: null };
110
+ }
111
+
112
+ // Multiple accounts, must specify
113
+ let errorMsg = chalk.yellow('Multiple accounts configured. Please specify --account <name>\n');
114
+ errorMsg += chalk.gray('Available accounts:\n');
115
+ accounts.forEach(a => {
116
+ errorMsg += chalk.gray(` - ${a.name} (${a.email || 'unknown'})\n`);
117
+ });
118
+ return { account: null, error: errorMsg };
119
+ }
120
+
65
121
  async function main() {
66
122
  const chalk = (await import('chalk')).default;
67
123
  const boxen = (await import('boxen')).default;
@@ -512,6 +568,7 @@ async function main() {
512
568
  .option('-n, --count <number>', 'Number of emails to analyze per account', '20')
513
569
  .option('--all', 'Include read and unread emails (default: unread only)')
514
570
  .option('--since <duration>', 'Only include emails from last N days/hours (e.g., "7d", "24h", "3d")')
571
+ .option('--older-than <duration>', 'Only include emails older than N days/weeks (e.g., "30d", "2w", "1m")')
515
572
  .option('--group-by <field>', 'Group emails by field (sender)')
516
573
  .action(async (options) => {
517
574
  try {
@@ -527,12 +584,34 @@ async function main() {
527
584
  const includeRead = !!options.all;
528
585
  let allEmails = [];
529
586
 
587
+ // Build Gmail query for --older-than (server-side filtering)
588
+ let olderThanQuery = null;
589
+ if (options.olderThan) {
590
+ const olderThanDays = parseOlderThanDuration(options.olderThan);
591
+ if (!olderThanDays) {
592
+ console.error(JSON.stringify({
593
+ error: `Invalid --older-than format: "${options.olderThan}". Use format like "30d", "2w", "1m"`
594
+ }));
595
+ process.exit(1);
596
+ }
597
+ olderThanQuery = `older_than:${olderThanDays}`;
598
+ }
599
+
530
600
  for (const account of accounts) {
531
- const emails = await getUnreadEmails(account, maxPerAccount, includeRead);
601
+ let emails;
602
+ if (olderThanQuery) {
603
+ // Use searchEmails for server-side filtering when --older-than is specified
604
+ const query = includeRead
605
+ ? olderThanQuery
606
+ : `is:unread ${olderThanQuery}`;
607
+ emails = await searchEmails(account, query, maxPerAccount);
608
+ } else {
609
+ emails = await getUnreadEmails(account, maxPerAccount, includeRead);
610
+ }
532
611
  allEmails.push(...emails);
533
612
  }
534
613
 
535
- // Filter by --since if provided
614
+ // Filter by --since if provided (client-side, for newer emails)
536
615
  if (options.since) {
537
616
  const sinceDate = parseSinceDuration(options.since);
538
617
  if (sinceDate) {
@@ -561,6 +640,288 @@ async function main() {
561
640
  }
562
641
  });
563
642
 
643
+ program
644
+ .command('read')
645
+ .description('Read full content of an email')
646
+ .requiredOption('--id <id>', 'Message ID to read')
647
+ .option('-a, --account <name>', 'Account name')
648
+ .option('--json', 'Output as JSON')
649
+ .option('--links', 'Extract and display links from email')
650
+ .action(async (options) => {
651
+ try {
652
+ const id = options.id.trim();
653
+ if (!id) {
654
+ console.log(chalk.yellow('No message ID provided.'));
655
+ return;
656
+ }
657
+
658
+ const { account, error } = resolveAccount(options.account, chalk);
659
+ if (error) {
660
+ console.log(error);
661
+ return;
662
+ }
663
+
664
+ // When --links is used, prefer HTML for better link extraction
665
+ const emailOptions = options.links ? { preferHtml: true } : {};
666
+ const email = await getEmailContent(account, id, emailOptions);
667
+
668
+ if (!email) {
669
+ console.log(chalk.red(`Email ${id} not found in account "${account}".`));
670
+ return;
671
+ }
672
+
673
+ // If --links flag is used, extract and display links
674
+ if (options.links) {
675
+ const links = extractLinks(email.body, email.mimeType);
676
+
677
+ if (options.json) {
678
+ console.log(JSON.stringify({
679
+ id: email.id,
680
+ subject: email.subject,
681
+ from: email.from,
682
+ linkCount: links.length,
683
+ links
684
+ }, null, 2));
685
+ return;
686
+ }
687
+
688
+ console.log(chalk.cyan('From: ') + chalk.white(email.from));
689
+ console.log(chalk.cyan('Subject: ') + chalk.white(email.subject));
690
+ console.log(chalk.gray('─'.repeat(50)));
691
+
692
+ if (links.length === 0) {
693
+ console.log(chalk.gray('No links found in this email.'));
694
+ } else {
695
+ console.log(chalk.bold(`\nLinks (${links.length}):\n`));
696
+ links.forEach((link, i) => {
697
+ if (link.text) {
698
+ console.log(chalk.white(`${i + 1}. ${link.text}`));
699
+ console.log(chalk.cyan(` ${link.url}`));
700
+ } else {
701
+ console.log(chalk.cyan(`${i + 1}. ${link.url}`));
702
+ }
703
+ });
704
+ }
705
+ return;
706
+ }
707
+
708
+ if (options.json) {
709
+ console.log(JSON.stringify(email, null, 2));
710
+ return;
711
+ }
712
+
713
+ console.log(chalk.cyan('From: ') + chalk.white(email.from));
714
+ if (email.to) {
715
+ console.log(chalk.cyan('To: ') + chalk.white(email.to));
716
+ }
717
+ console.log(chalk.cyan('Date: ') + chalk.white(email.date));
718
+ console.log(chalk.cyan('Subject: ') + chalk.white(email.subject));
719
+ console.log(chalk.gray('─'.repeat(50)));
720
+ console.log(email.body || chalk.gray('(No content)'));
721
+ console.log(chalk.gray('─'.repeat(50)));
722
+
723
+ } catch (error) {
724
+ console.error(chalk.red('Error reading email:'), error.message);
725
+ process.exit(1);
726
+ }
727
+ });
728
+
729
+ program
730
+ .command('search')
731
+ .description('Search emails using Gmail query syntax')
732
+ .requiredOption('-q, --query <query>', 'Search query (e.g. "from:boss is:unread")')
733
+ .option('-a, --account <name>', 'Account to search')
734
+ .option('-n, --limit <number>', 'Max results', '20')
735
+ .option('--json', 'Output as JSON')
736
+ .action(async (options) => {
737
+ try {
738
+ const { account, error } = resolveAccount(options.account, chalk);
739
+ if (error) {
740
+ console.log(error);
741
+ return;
742
+ }
743
+
744
+ const limit = parseInt(options.limit, 10);
745
+ const emails = await searchEmails(account, options.query, limit);
746
+
747
+ if (options.json) {
748
+ console.log(JSON.stringify(emails, null, 2));
749
+ return;
750
+ }
751
+
752
+ if (emails.length === 0) {
753
+ console.log(chalk.gray('No emails found matching query.'));
754
+ return;
755
+ }
756
+
757
+ console.log(chalk.bold(`Found ${emails.length} emails matching "${options.query}":\n`));
758
+
759
+ emails.forEach(e => {
760
+ const from = e.from.length > 35 ? e.from.substring(0, 32) + '...' : e.from;
761
+ const subject = e.subject.length > 50 ? e.subject.substring(0, 47) + '...' : e.subject;
762
+ console.log(chalk.cyan(e.id) + ' ' + chalk.white(from));
763
+ console.log(chalk.gray(` ${subject}\n`));
764
+ });
765
+
766
+ } catch (error) {
767
+ console.error(chalk.red('Error searching emails:'), error.message);
768
+ process.exit(1);
769
+ }
770
+ });
771
+
772
+ program
773
+ .command('send')
774
+ .description('Send an email')
775
+ .requiredOption('-t, --to <email>', 'Recipient email')
776
+ .requiredOption('-s, --subject <subject>', 'Email subject')
777
+ .requiredOption('-b, --body <body>', 'Email body text')
778
+ .option('-a, --account <name>', 'Account to send from')
779
+ .option('--dry-run', 'Preview the email without sending')
780
+ .option('--confirm', 'Skip confirmation prompt')
781
+ .action(async (options) => {
782
+ try {
783
+ const { account, error } = resolveAccount(options.account, chalk);
784
+ if (error) {
785
+ console.log(error);
786
+ return;
787
+ }
788
+
789
+ // Get account email for display
790
+ const accountInfo = getAccounts().find(a => a.name === account);
791
+ const fromEmail = accountInfo?.email || account;
792
+
793
+ // Always show preview
794
+ console.log(chalk.bold('\nEmail to send:\n'));
795
+ console.log(chalk.cyan('From: ') + chalk.white(fromEmail));
796
+ console.log(chalk.cyan('To: ') + chalk.white(options.to));
797
+ console.log(chalk.cyan('Subject: ') + chalk.white(options.subject));
798
+ console.log(chalk.gray('─'.repeat(50)));
799
+ console.log(options.body);
800
+ console.log(chalk.gray('─'.repeat(50)));
801
+
802
+ if (options.dryRun) {
803
+ console.log(chalk.yellow('\nDry run: Email was not sent.'));
804
+ return;
805
+ }
806
+
807
+ if (!options.confirm) {
808
+ console.log(chalk.yellow('\nThis will send the email above.'));
809
+ console.log(chalk.gray('Use --confirm to skip this prompt, or --dry-run to preview without sending.\n'));
810
+ return;
811
+ }
812
+
813
+ console.log(chalk.cyan('\nSending...'));
814
+
815
+ const result = await sendEmail(account, {
816
+ to: options.to,
817
+ subject: options.subject,
818
+ body: options.body
819
+ });
820
+
821
+ if (result.success) {
822
+ // Log the sent email
823
+ logSentEmail({
824
+ account,
825
+ to: options.to,
826
+ subject: options.subject,
827
+ body: options.body,
828
+ id: result.id,
829
+ threadId: result.threadId
830
+ });
831
+
832
+ console.log(chalk.green(`\n✓ Email sent successfully!`));
833
+ console.log(chalk.gray(` ID: ${result.id}`));
834
+ console.log(chalk.gray(` Logged to: ${getSentLogPath()}`));
835
+ } else {
836
+ console.log(chalk.red(`\n✗ Failed to send email: ${result.error}`));
837
+ process.exit(1);
838
+ }
839
+ } catch (error) {
840
+ console.error(chalk.red('Error sending email:'), error.message);
841
+ process.exit(1);
842
+ }
843
+ });
844
+
845
+ program
846
+ .command('reply')
847
+ .description('Reply to an email')
848
+ .requiredOption('--id <id>', 'Message ID to reply to')
849
+ .requiredOption('-b, --body <body>', 'Reply body text')
850
+ .option('-a, --account <name>', 'Account to reply from')
851
+ .option('--dry-run', 'Preview the reply without sending')
852
+ .option('--confirm', 'Skip confirmation prompt')
853
+ .action(async (options) => {
854
+ try {
855
+ const { account, error } = resolveAccount(options.account, chalk);
856
+ if (error) {
857
+ console.log(error);
858
+ return;
859
+ }
860
+
861
+ // Fetch original email to show context
862
+ const original = await getEmailContent(account, options.id);
863
+ if (!original) {
864
+ console.log(chalk.red(`Email ${options.id} not found in account "${account}".`));
865
+ return;
866
+ }
867
+
868
+ // Build the subject we'll use
869
+ const replySubject = original.subject.toLowerCase().startsWith('re:')
870
+ ? original.subject
871
+ : `Re: ${original.subject}`;
872
+
873
+ // Show preview
874
+ console.log(chalk.bold('\nReply to:\n'));
875
+ console.log(chalk.gray('Original from: ') + chalk.white(original.from));
876
+ console.log(chalk.gray('Original subject: ') + chalk.white(original.subject));
877
+ console.log(chalk.gray('─'.repeat(50)));
878
+ console.log(chalk.bold('\nYour reply:\n'));
879
+ console.log(chalk.cyan('To: ') + chalk.white(original.from));
880
+ console.log(chalk.cyan('Subject: ') + chalk.white(replySubject));
881
+ console.log(chalk.gray('─'.repeat(50)));
882
+ console.log(options.body);
883
+ console.log(chalk.gray('─'.repeat(50)));
884
+
885
+ if (options.dryRun) {
886
+ console.log(chalk.yellow('\nDry run: Reply was not sent.'));
887
+ return;
888
+ }
889
+
890
+ if (!options.confirm) {
891
+ console.log(chalk.yellow('\nThis will send the reply above.'));
892
+ console.log(chalk.gray('Use --confirm to skip this prompt, or --dry-run to preview without sending.\n'));
893
+ return;
894
+ }
895
+
896
+ console.log(chalk.cyan('\nSending reply...'));
897
+
898
+ const result = await replyToEmail(account, options.id, options.body);
899
+
900
+ if (result.success) {
901
+ // Log the sent reply
902
+ logSentEmail({
903
+ account,
904
+ to: original.from,
905
+ subject: replySubject,
906
+ body: options.body,
907
+ id: result.id,
908
+ threadId: result.threadId,
909
+ replyToId: options.id
910
+ });
911
+
912
+ console.log(chalk.green(`\n✓ Reply sent successfully!`));
913
+ console.log(chalk.gray(` ID: ${result.id}`));
914
+ console.log(chalk.gray(` Logged to: ${getSentLogPath()}`));
915
+ } else {
916
+ console.log(chalk.red(`\n✗ Failed to send reply: ${result.error}`));
917
+ process.exit(1);
918
+ }
919
+ } catch (error) {
920
+ console.error(chalk.red('Error replying:'), error.message);
921
+ process.exit(1);
922
+ }
923
+ });
924
+
564
925
  program
565
926
  .command('delete')
566
927
  .description('Move emails to trash')
@@ -964,6 +1325,64 @@ async function main() {
964
1325
  }
965
1326
  });
966
1327
 
1328
+ program
1329
+ .command('mark-unread')
1330
+ .description('Mark emails as unread')
1331
+ .requiredOption('--ids <ids>', 'Comma-separated message IDs to mark as unread')
1332
+ .option('-a, --account <name>', 'Account name')
1333
+ .action(async (options) => {
1334
+ try {
1335
+ const ids = options.ids.split(',').map(id => id.trim()).filter(Boolean);
1336
+
1337
+ if (ids.length === 0) {
1338
+ console.log(chalk.yellow('No message IDs provided.'));
1339
+ return;
1340
+ }
1341
+
1342
+ // Get account - if not specified, try to find from configured accounts
1343
+ let account = options.account;
1344
+ if (!account) {
1345
+ const accounts = getAccounts();
1346
+ if (accounts.length === 1) {
1347
+ account = accounts[0].name;
1348
+ } else if (accounts.length > 1) {
1349
+ console.log(chalk.yellow('Multiple accounts configured. Please specify --account <name>'));
1350
+ console.log(chalk.gray('Available accounts:'));
1351
+ accounts.forEach(a => console.log(chalk.gray(` - ${a.name}`)));
1352
+ return;
1353
+ } else {
1354
+ account = 'default';
1355
+ }
1356
+ }
1357
+
1358
+ console.log(chalk.cyan(`Marking ${ids.length} email(s) as unread...`));
1359
+
1360
+ const results = await markAsUnread(account, ids);
1361
+
1362
+ const succeeded = results.filter(r => r.success).length;
1363
+ const failed = results.filter(r => !r.success).length;
1364
+
1365
+ if (succeeded > 0) {
1366
+ console.log(chalk.green(`\nMarked ${succeeded} email(s) as unread.`));
1367
+ }
1368
+ if (failed > 0) {
1369
+ console.log(chalk.red(`Failed to mark ${failed} email(s) as unread.`));
1370
+ results.filter(r => !r.success).forEach(r => {
1371
+ console.log(chalk.red(` - ${r.id}: ${r.error}`));
1372
+ });
1373
+ }
1374
+
1375
+ } catch (error) {
1376
+ if (error.message.includes('403') || error.code === 403) {
1377
+ console.error(chalk.red('Permission denied. You may need to re-authenticate with updated scopes.'));
1378
+ console.error(chalk.yellow('Run: inbox auth -a <account>'));
1379
+ } else {
1380
+ console.error(chalk.red('Error marking emails as unread:'), error.message);
1381
+ }
1382
+ process.exit(1);
1383
+ }
1384
+ });
1385
+
967
1386
  program
968
1387
  .command('archive')
969
1388
  .description('Archive emails (remove from inbox, keep in All Mail)')