inboxd 1.3.1 → 1.4.0

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.
@@ -106,6 +106,69 @@ Unless the user says "inbox zero" or similar:
106
106
 
107
107
  ---
108
108
 
109
+ ## User Preferences
110
+
111
+ - At the start of every session, read `~/.config/inboxd/user-preferences.md` and apply the rules to all triage/cleanup decisions.
112
+ - The file is natural-language markdown. Keep it under 500 lines so it fits in context.
113
+ - Manage it with `inboxd preferences` (view, init, edit, validate, JSON).
114
+
115
+ ### First-Time Onboarding (when file is missing)
116
+ Offer to set up preferences once:
117
+ 1) People to **never auto-delete**
118
+ 2) Senders to **always clean up** (promotions, alerts)
119
+ 3) Specific workflows (e.g., summarize newsletters)
120
+ 4) Cleanup aggressiveness (conservative / moderate / aggressive)
121
+ Save answers to `~/.config/inboxd/user-preferences.md`.
122
+
123
+ ### Tracking Onboarding
124
+
125
+ After completing onboarding (or if user declines), add this marker to the end of the preferences file:
126
+
127
+ ```markdown
128
+ <!-- Internal: Onboarding completed -->
129
+ ```
130
+
131
+ **Before offering onboarding**, check if this marker exists. If it does, do NOT offer onboarding again—even if the file only contains template placeholders. This prevents annoying users who dismissed the initial prompt.
132
+
133
+ ### Learning from Feedback
134
+ - **Auto-save explicit requests:** "Always delete LinkedIn alerts", "Never touch mom@family.com", "I prefer brief summaries".
135
+ - **Confirm pattern suggestions:** "You keep deleting promo@site.com. Save a rule to clean these up?" Only suggest if the sender is active.
136
+ - Watch size: if approaching 500 lines, suggest consolidating older entries instead of appending endlessly.
137
+
138
+ ### Preference File Format
139
+ - Sections: `## About Me`, `## Important People`, `## Sender Behaviors`, `## Category Rules`, `## Behavioral Preferences`.
140
+ - When updating, **append to existing sections** (bullets), don't overwrite user content. Include brief context ("why") to help future decisions.
141
+ - Never delete the file; it lives outside the skill install path and must survive updates.
142
+
143
+ ### Smart Pattern Detection Window
144
+ When suggesting new preferences from behavior:
145
+ 1) Only consider deletions from the last 14 days.
146
+ 2) Confirm the sender is still active (recent unread emails).
147
+ 3) Require 3+ deletions within the window.
148
+ 4) Skip if the sender already exists in preferences.
149
+
150
+ ### Reading the Deletion Log
151
+
152
+ The deletion log is at `~/.config/inboxd/deletion-log.json`. Each entry:
153
+
154
+ ```json
155
+ {
156
+ "deletedAt": "2026-01-08T10:00:00.000Z",
157
+ "account": "personal",
158
+ "id": "abc123",
159
+ "from": "sender@example.com",
160
+ "subject": "Email subject",
161
+ "labelIds": ["UNREAD", "INBOX"]
162
+ }
163
+ ```
164
+
165
+ Use `inboxd cleanup-suggest --json` for pre-analyzed patterns (recommended), or read the raw log with:
166
+ ```bash
167
+ cat ~/.config/inboxd/deletion-log.json
168
+ ```
169
+
170
+ ---
171
+
109
172
  ## Heavy Inbox Strategy
110
173
 
111
174
  When a user has a heavy inbox (>20 unread emails), use this optimized workflow:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inboxd",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "CLI assistant for Gmail monitoring with multi-account support and AI-ready JSON output",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { program } = require('commander');
4
+ const fs = require('fs');
4
5
  const { getUnreadEmails, getEmailCount, trashEmails, getEmailById, untrashEmails, markAsRead, markAsUnread, archiveEmails, unarchiveEmails, groupEmailsBySender, getEmailContent, searchEmails, searchEmailsCount, searchEmailsPaginated, sendEmail, replyToEmail, extractLinks } = require('./gmail-monitor');
5
6
  const { logArchives, getRecentArchives, getArchiveLogPath, removeArchiveLogEntries } = require('./archive-log');
6
7
  const { authorize, addAccount, getAccounts, getAccountEmail, removeAccount, removeAllAccounts, renameTokenFile, validateCredentialsFile, hasCredentials, isConfigured, installCredentials } = require('./gmail-auth');
7
8
  const { logDeletions, getRecentDeletions, getLogPath, readLog, removeLogEntries, getStats: getDeletionStats, analyzePatterns } = require('./deletion-log');
8
9
  const { getSkillStatus, checkForUpdate, installSkill, SKILL_DEST_DIR, SOURCE_MARKER } = require('./skill-installer');
9
10
  const { logSentEmail, getSentLogPath, getSentStats } = require('./sent-log');
11
+ const { getPreferencesPath, preferencesExist, readPreferences, writePreferences, validatePreferences, getTemplatePath } = require('./preferences');
10
12
  const readline = require('readline');
11
13
  const path = require('path');
12
14
  const os = require('os');
15
+ const { spawnSync } = require('child_process');
13
16
  const pkg = require('../package.json');
14
17
 
15
18
  /**
@@ -1823,6 +1826,129 @@ async function main() {
1823
1826
  }
1824
1827
  });
1825
1828
 
1829
+ program
1830
+ .command('preferences')
1831
+ .description('View and manage inbox preferences used by the AI assistant')
1832
+ .option('--init', 'Create preferences file from template if missing')
1833
+ .option('--edit', 'Open preferences file in $EDITOR (creates if missing)')
1834
+ .option('--validate', 'Validate preferences format and line count')
1835
+ .option('--json', 'Output preferences and validation as JSON')
1836
+ .action(async (options) => {
1837
+ try {
1838
+ const prefPath = getPreferencesPath();
1839
+ const templatePath = getTemplatePath();
1840
+ const templateContent = fs.existsSync(templatePath)
1841
+ ? fs.readFileSync(templatePath, 'utf8')
1842
+ : '# Inbox Preferences\n';
1843
+ let createdDuringRun = false;
1844
+
1845
+ const ensurePreferencesFile = () => {
1846
+ if (!preferencesExist()) {
1847
+ writePreferences(templateContent);
1848
+ createdDuringRun = true;
1849
+ }
1850
+ };
1851
+
1852
+ // Handle init (create if missing)
1853
+ if (options.init) {
1854
+ ensurePreferencesFile();
1855
+ if (!options.json) {
1856
+ if (createdDuringRun) {
1857
+ console.log(chalk.green('\n✓ Preferences file created from template.'));
1858
+ console.log(chalk.gray(` Path: ${prefPath}\n`));
1859
+ } else {
1860
+ console.log(chalk.yellow('\nPreferences file already exists.'));
1861
+ console.log(chalk.gray(` Path: ${prefPath}\n`));
1862
+ }
1863
+ }
1864
+ }
1865
+
1866
+ // Handle edit (open editor)
1867
+ if (options.edit) {
1868
+ ensurePreferencesFile();
1869
+ const editor = process.env.EDITOR || (process.platform === 'win32' ? 'notepad' : 'nano');
1870
+ if (!options.json) {
1871
+ console.log(chalk.gray(`Opening ${prefPath} with ${editor}...\n`));
1872
+ }
1873
+ const result = spawnSync(editor, [prefPath], { stdio: 'inherit' });
1874
+ if (result.error) {
1875
+ console.log(chalk.red(`Failed to open editor "${editor}": ${result.error.message}`));
1876
+ }
1877
+ return;
1878
+ }
1879
+
1880
+ const exists = preferencesExist();
1881
+ const content = exists ? readPreferences() : null;
1882
+ const validation = validatePreferences(content);
1883
+
1884
+ if (options.json) {
1885
+ console.log(JSON.stringify({
1886
+ path: prefPath,
1887
+ exists,
1888
+ created: createdDuringRun,
1889
+ lineCount: validation.lineCount,
1890
+ valid: validation.valid,
1891
+ errors: validation.errors,
1892
+ warnings: validation.warnings,
1893
+ content: content || '',
1894
+ }, null, 2));
1895
+ return;
1896
+ }
1897
+
1898
+ if (options.validate) {
1899
+ if (!exists) {
1900
+ console.log(chalk.red('\nPreferences file not found.'));
1901
+ console.log(chalk.gray('Create one with: inboxd preferences --init\n'));
1902
+ return;
1903
+ }
1904
+
1905
+ if (validation.valid) {
1906
+ console.log(chalk.green('\n✓ Preferences file is valid.'));
1907
+ } else {
1908
+ console.log(chalk.red('\nPreferences file has issues:'));
1909
+ }
1910
+ console.log(chalk.gray(` Path: ${prefPath}`));
1911
+ console.log(chalk.gray(` Lines: ${validation.lineCount}`));
1912
+
1913
+ if (validation.errors.length > 0) {
1914
+ console.log(chalk.red('\nErrors:'));
1915
+ validation.errors.forEach(err => console.log(chalk.red(` - ${err}`)));
1916
+ }
1917
+ if (validation.warnings.length > 0) {
1918
+ console.log(chalk.yellow('\nWarnings:'));
1919
+ validation.warnings.forEach(warn => console.log(chalk.yellow(` - ${warn}`)));
1920
+ }
1921
+ console.log('');
1922
+ return;
1923
+ }
1924
+
1925
+ // Default: display preferences or hint to init
1926
+ if (!exists) {
1927
+ console.log(chalk.yellow('\nNo preferences file found.'));
1928
+ console.log(chalk.gray('Create one with: inboxd preferences --init'));
1929
+ console.log(chalk.gray('Then edit with: inboxd preferences --edit\n'));
1930
+ return;
1931
+ }
1932
+
1933
+ if (validation.lineCount > 500) {
1934
+ console.log(chalk.red(`\nPreferences file is too long (${validation.lineCount} lines). Please shorten below 500 lines.\n`));
1935
+ return;
1936
+ }
1937
+
1938
+ console.log(chalk.bold('\nInbox Preferences\n'));
1939
+ console.log(content);
1940
+ console.log(chalk.gray(`\nPath: ${prefPath}`));
1941
+ console.log(chalk.gray(`Lines: ${validation.lineCount}\n`));
1942
+ } catch (error) {
1943
+ if (options.json) {
1944
+ console.log(JSON.stringify({ error: error.message }, null, 2));
1945
+ } else {
1946
+ console.error(chalk.red('Error managing preferences:'), error.message);
1947
+ }
1948
+ process.exit(1);
1949
+ }
1950
+ });
1951
+
1826
1952
  program
1827
1953
  .command('install-skill')
1828
1954
  .description('Install Claude Code skill for AI-powered inbox management')
@@ -0,0 +1,202 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { atomicWriteSync } = require('./utils');
4
+ const { TOKEN_DIR } = require('./gmail-auth');
5
+
6
+ const TEMPLATE_PATH = path.join(__dirname, 'templates', 'user-preferences-template.md');
7
+
8
+ /**
9
+ * Returns the path to the user preferences file
10
+ * @returns {string}
11
+ */
12
+ function getPreferencesPath() {
13
+ return path.join(TOKEN_DIR, 'user-preferences.md');
14
+ }
15
+
16
+ /**
17
+ * Ensures the config directory exists
18
+ */
19
+ function ensureConfigDir() {
20
+ fs.mkdirSync(TOKEN_DIR, { recursive: true });
21
+ }
22
+
23
+ /**
24
+ * Checks if the preferences file exists
25
+ * @returns {boolean}
26
+ */
27
+ function preferencesExist() {
28
+ return fs.existsSync(getPreferencesPath());
29
+ }
30
+
31
+ /**
32
+ * Reads preferences file contents
33
+ * @returns {string|null} File content or null if missing
34
+ */
35
+ function readPreferences() {
36
+ if (!preferencesExist()) {
37
+ return null;
38
+ }
39
+ try {
40
+ return fs.readFileSync(getPreferencesPath(), 'utf8');
41
+ } catch (_err) {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Returns the line count of the current preferences file (or provided content)
48
+ * @param {string|null} content - Optional content to count lines from
49
+ * @returns {number}
50
+ */
51
+ function getLineCount(content = null) {
52
+ const text = content !== null ? content : readPreferences();
53
+ if (!text) return 0;
54
+ return text.split(/\r?\n/).length;
55
+ }
56
+
57
+ /**
58
+ * Writes preferences to disk, creating a backup if the file already exists
59
+ * @param {string} content - Content to write
60
+ * @returns {{ path: string, backupPath: string|null }}
61
+ */
62
+ function writePreferences(content) {
63
+ ensureConfigDir();
64
+ const prefPath = getPreferencesPath();
65
+ let backupPath = null;
66
+
67
+ if (fs.existsSync(prefPath)) {
68
+ backupPath = `${prefPath}.backup`;
69
+ try {
70
+ fs.copyFileSync(prefPath, backupPath);
71
+ } catch (_err) {
72
+ // If backup fails, better to abort than risk data loss
73
+ throw new Error(`Failed to create backup at ${backupPath}`);
74
+ }
75
+ }
76
+
77
+ atomicWriteSync(prefPath, content);
78
+ return { path: prefPath, backupPath };
79
+ }
80
+
81
+ /**
82
+ * Validates preferences content for basic safety rules
83
+ * @param {string|null} content - Content to validate (reads current file if null)
84
+ * @returns {{ valid: boolean, errors: string[], warnings: string[], lineCount: number }}
85
+ */
86
+ function validatePreferences(content = null) {
87
+ const text = content !== null ? content : readPreferences();
88
+
89
+ if (!text) {
90
+ return {
91
+ valid: false,
92
+ errors: ['Preferences file not found or empty'],
93
+ warnings: [],
94
+ lineCount: 0,
95
+ };
96
+ }
97
+
98
+ const errors = [];
99
+ const warnings = [];
100
+ const lineCount = getLineCount(text);
101
+
102
+ if (lineCount > 500) {
103
+ errors.push(`Preferences exceed 500 lines (currently ${lineCount}). Consider consolidating rules.`);
104
+ }
105
+
106
+ const requiredSections = [
107
+ '## About Me',
108
+ '## Important People',
109
+ '## Sender Behaviors',
110
+ '## Category Rules',
111
+ '## Behavioral Preferences',
112
+ ];
113
+ const missingSections = requiredSections.filter(section => !text.toLowerCase().includes(section.toLowerCase()));
114
+ if (missingSections.length > 0) {
115
+ warnings.push(`Missing sections: ${missingSections.join(', ')}`);
116
+ }
117
+
118
+ if (!text.trimStart().toLowerCase().startsWith('# inbox preferences')) {
119
+ warnings.push('Missing top-level heading "# Inbox Preferences"');
120
+ }
121
+
122
+ return {
123
+ valid: errors.length === 0,
124
+ errors,
125
+ warnings,
126
+ lineCount,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Appends content to a specific section, creating it if missing
132
+ * @param {string} sectionTitle - Section heading without hashes (e.g., "Important People (Never Auto-Delete)")
133
+ * @param {string} entry - Entry to append (bullet text)
134
+ * @returns {{ path: string, backupPath: string|null, createdSection: boolean }}
135
+ */
136
+ function appendToSection(sectionTitle, entry) {
137
+ const prefPath = getPreferencesPath();
138
+ ensureConfigDir();
139
+
140
+ let content = readPreferences();
141
+ if (!content) {
142
+ content = fs.existsSync(TEMPLATE_PATH)
143
+ ? fs.readFileSync(TEMPLATE_PATH, 'utf8')
144
+ : `# Inbox Preferences\n\n## ${sectionTitle}\n`;
145
+ }
146
+
147
+ const lines = content.split(/\r?\n/);
148
+ const normalized = sectionTitle.trim().toLowerCase();
149
+ const sectionHeader = `## ${sectionTitle}`;
150
+
151
+ let sectionIndex = lines.findIndex(line => line.trim().toLowerCase() === `## ${normalized}`);
152
+ let createdSection = false;
153
+
154
+ if (sectionIndex === -1) {
155
+ // Append new section at end
156
+ if (lines[lines.length - 1].trim() !== '') {
157
+ lines.push('');
158
+ }
159
+ lines.push(sectionHeader, `- ${entry}`);
160
+ createdSection = true;
161
+ } else {
162
+ // Find insertion point (before next header or end of file)
163
+ let insertIndex = lines.length;
164
+ for (let i = sectionIndex + 1; i < lines.length; i++) {
165
+ if (lines[i].startsWith('#')) {
166
+ insertIndex = i;
167
+ break;
168
+ }
169
+ }
170
+
171
+ // Ensure a blank line before the append if needed
172
+ if (lines[insertIndex - 1] && lines[insertIndex - 1].trim() !== '') {
173
+ lines.splice(insertIndex, 0, '');
174
+ insertIndex++;
175
+ }
176
+
177
+ const bullet = entry.trim().startsWith('-') ? entry.trim() : `- ${entry.trim()}`;
178
+ lines.splice(insertIndex, 0, bullet);
179
+ }
180
+
181
+ const { backupPath } = writePreferences(lines.join('\n'));
182
+ return { path: prefPath, backupPath, createdSection };
183
+ }
184
+
185
+ /**
186
+ * Returns the template path (useful for CLI to bootstrap)
187
+ * @returns {string}
188
+ */
189
+ function getTemplatePath() {
190
+ return TEMPLATE_PATH;
191
+ }
192
+
193
+ module.exports = {
194
+ getPreferencesPath,
195
+ preferencesExist,
196
+ readPreferences,
197
+ writePreferences,
198
+ validatePreferences,
199
+ appendToSection,
200
+ getLineCount,
201
+ getTemplatePath,
202
+ };
@@ -6,6 +6,7 @@
6
6
  * - Source marker: Only manages skills with `source: inboxd` in front matter
7
7
  * - Content hash: Detects changes without version numbers
8
8
  * - Backup: Creates .backup before replacing modified files
9
+ * - Never touches user config files (tokens, preferences) under ~/.config/inboxd
9
10
  */
10
11
 
11
12
  const fs = require('fs');
@@ -0,0 +1,40 @@
1
+ # Inbox Preferences
2
+
3
+ <!--
4
+ This file stores your inbox preferences. The AI assistant reads this
5
+ at the start of each session to personalize how it manages your email.
6
+ Keep it under 500 lines. Edit with: inboxd preferences --edit
7
+ -->
8
+
9
+ ## About Me
10
+ <!-- Describe your context: job role, interests, what emails matter to you -->
11
+ <!-- Example entries (delete these and add your own): -->
12
+ - Software engineer interested in AI/ML opportunities
13
+ - Based in London, prefer remote-friendly roles
14
+ - Active on GitHub, receive many notifications
15
+
16
+ ## Important People (Never Auto-Delete)
17
+ <!-- Senders whose emails should NEVER be suggested for deletion -->
18
+ <!-- Example: -->
19
+ - partner@gmail.com - spouse, always important
20
+ - hr@company.com - work-related
21
+
22
+ ## Sender Behaviors
23
+ <!-- Rules for specific senders or domains -->
24
+ <!-- Examples: -->
25
+ - **LinkedIn job alerts** - When there are multiple, summarize the best matches for my profile and suggest deleting the rest
26
+ - **GitHub notifications** - Keep PRs I'm tagged on, archive others after reading
27
+
28
+ ## Category Rules
29
+ <!-- Rules for types of emails -->
30
+ <!-- Examples: -->
31
+ - Bank/Financial emails - Archive after viewing, never delete
32
+ - Promotional emails older than 14 days - Can be auto-suggested for cleanup
33
+ - Newsletters I haven't read in 30 days - Suggest unsubscribing
34
+
35
+ ## Behavioral Preferences
36
+ <!-- How you want the AI to behave -->
37
+ <!-- Examples: -->
38
+ - I prefer brief summaries (2-3 sentences)
39
+ - Always ask before deleting more than 10 emails at once
40
+ - On busy days (>50 unread), prioritize action-required emails
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ describe('preferences module', () => {
7
+ const tempDir = path.join(os.tmpdir(), 'inboxd-preferences-test');
8
+ const originalTokenDir = process.env.INBOXD_TOKEN_DIR;
9
+ let getPreferencesPath;
10
+ let preferencesExist;
11
+ let readPreferences;
12
+ let writePreferences;
13
+ let validatePreferences;
14
+ let appendToSection;
15
+
16
+ beforeEach(async () => {
17
+ fs.rmSync(tempDir, { recursive: true, force: true });
18
+ fs.mkdirSync(tempDir, { recursive: true });
19
+
20
+ vi.resetModules();
21
+ process.env.INBOXD_TOKEN_DIR = tempDir;
22
+
23
+ const module = await import('../src/preferences');
24
+ getPreferencesPath = module.getPreferencesPath;
25
+ preferencesExist = module.preferencesExist;
26
+ readPreferences = module.readPreferences;
27
+ writePreferences = module.writePreferences;
28
+ validatePreferences = module.validatePreferences;
29
+ appendToSection = module.appendToSection;
30
+ });
31
+
32
+ afterAll(() => {
33
+ fs.rmSync(tempDir, { recursive: true, force: true });
34
+ if (originalTokenDir === undefined) {
35
+ delete process.env.INBOXD_TOKEN_DIR;
36
+ } else {
37
+ process.env.INBOXD_TOKEN_DIR = originalTokenDir;
38
+ }
39
+ });
40
+
41
+ it('uses INBOXD_TOKEN_DIR for preference path', () => {
42
+ expect(getPreferencesPath()).toBe(path.join(tempDir, 'user-preferences.md'));
43
+ });
44
+
45
+ it('writes preferences and creates a backup on overwrite', () => {
46
+ const initial = '# Inbox Preferences\nInitial content\n';
47
+ writePreferences(initial);
48
+ expect(preferencesExist()).toBe(true);
49
+
50
+ const updated = '# Inbox Preferences\nUpdated content\n';
51
+ writePreferences(updated);
52
+
53
+ const backupPath = `${getPreferencesPath()}.backup`;
54
+ expect(fs.existsSync(backupPath)).toBe(true);
55
+ const backupContent = fs.readFileSync(backupPath, 'utf8');
56
+ expect(backupContent).toContain('Initial content');
57
+
58
+ const current = readPreferences();
59
+ expect(current).toContain('Updated content');
60
+ });
61
+
62
+ it('flags files over 500 lines', () => {
63
+ const oversized = '# Inbox Preferences\n' + 'line\n'.repeat(501);
64
+ const result = validatePreferences(oversized);
65
+
66
+ expect(result.valid).toBe(false);
67
+ expect(result.errors.some(e => e.includes('500'))).toBe(true);
68
+ });
69
+
70
+ it('appends to existing sections using the template when missing', () => {
71
+ const result = appendToSection('Important People (Never Auto-Delete)', 'test@example.com - never delete');
72
+ const content = readPreferences();
73
+
74
+ expect(result.createdSection).toBe(false);
75
+ expect(content).toContain('## Important People');
76
+ expect(content).toContain('test@example.com');
77
+ });
78
+ });