inboxd 1.0.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.
- package/com.danielparedes.inboxd.plist +40 -0
- package/credentials.json +1 -0
- package/package.json +43 -0
- package/src/cli.js +396 -0
- package/src/deletion-log.js +87 -0
- package/src/gmail-auth.js +203 -0
- package/src/gmail-monitor.js +165 -0
- package/src/notifier.js +48 -0
- package/src/state.js +106 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key>
|
|
6
|
+
<string>com.danielparedes.inboxd</string>
|
|
7
|
+
|
|
8
|
+
<key>ProgramArguments</key>
|
|
9
|
+
<array>
|
|
10
|
+
<string>/usr/local/bin/node</string>
|
|
11
|
+
<string>/Users/danielparedes/Documents/Github/inboxd/src/cli.js</string>
|
|
12
|
+
<string>check</string>
|
|
13
|
+
<string>--quiet</string>
|
|
14
|
+
</array>
|
|
15
|
+
|
|
16
|
+
<key>WorkingDirectory</key>
|
|
17
|
+
<string>/Users/danielparedes/Documents/Github/inboxd</string>
|
|
18
|
+
|
|
19
|
+
<!-- Run every 5 minutes (300 seconds) -->
|
|
20
|
+
<key>StartInterval</key>
|
|
21
|
+
<integer>300</integer>
|
|
22
|
+
|
|
23
|
+
<!-- Run immediately when loaded -->
|
|
24
|
+
<key>RunAtLoad</key>
|
|
25
|
+
<true/>
|
|
26
|
+
|
|
27
|
+
<!-- Logging -->
|
|
28
|
+
<key>StandardOutPath</key>
|
|
29
|
+
<string>/tmp/inboxd.log</string>
|
|
30
|
+
<key>StandardErrorPath</key>
|
|
31
|
+
<string>/tmp/inboxd.error.log</string>
|
|
32
|
+
|
|
33
|
+
<!-- Environment variables -->
|
|
34
|
+
<key>EnvironmentVariables</key>
|
|
35
|
+
<dict>
|
|
36
|
+
<key>PATH</key>
|
|
37
|
+
<string>/usr/local/bin:/usr/bin:/bin</string>
|
|
38
|
+
</dict>
|
|
39
|
+
</dict>
|
|
40
|
+
</plist>
|
package/credentials.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"installed":{"client_id":"670632438932-arhht2o3bp94r0ug45o2accmu084eqfu.apps.googleusercontent.com","project_id":"daily-assistant-483211","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "inboxd",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI assistant for Gmail monitoring with multi-account support and AI-ready JSON output",
|
|
5
|
+
"main": "src/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"inbox": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/dparedesi/inboxd.git"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest",
|
|
16
|
+
"inbox": "node src/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"gmail",
|
|
20
|
+
"cli",
|
|
21
|
+
"email",
|
|
22
|
+
"monitor",
|
|
23
|
+
"assistant",
|
|
24
|
+
"ai",
|
|
25
|
+
"agent",
|
|
26
|
+
"inbox",
|
|
27
|
+
"tool"
|
|
28
|
+
],
|
|
29
|
+
"author": "Daniel Paredes",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"type": "commonjs",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@google-cloud/local-auth": "^3.0.1",
|
|
34
|
+
"boxen": "^8.0.1",
|
|
35
|
+
"chalk": "^5.6.2",
|
|
36
|
+
"commander": "^14.0.2",
|
|
37
|
+
"googleapis": "^169.0.0",
|
|
38
|
+
"node-notifier": "^10.0.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"vitest": "^4.0.16"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const { getUnreadEmails, getEmailCount, trashEmails, getEmailById } = require('./gmail-monitor');
|
|
5
|
+
const { getState, updateLastCheck, markEmailsSeen, getNewEmailIds, clearOldSeenEmails } = require('./state');
|
|
6
|
+
const { notifyNewEmails } = require('./notifier');
|
|
7
|
+
const { authorize, addAccount, getAccounts, getAccountEmail, getDefaultAccount, removeAccount, removeAllAccounts, renameTokenFile } = require('./gmail-auth');
|
|
8
|
+
const { logDeletions, getRecentDeletions, getLogPath } = require('./deletion-log');
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const chalk = (await import('chalk')).default;
|
|
12
|
+
const boxen = (await import('boxen')).default;
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.name('inbox')
|
|
16
|
+
.description('Gmail monitoring CLI for Daily Assistant')
|
|
17
|
+
.version('1.0.0');
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.command('auth')
|
|
21
|
+
.description('Authenticate a Gmail account')
|
|
22
|
+
.option('-a, --account <name>', 'Account name (e.g., personal, work)')
|
|
23
|
+
.action(async (options) => {
|
|
24
|
+
try {
|
|
25
|
+
// If account name is provided, use it. Otherwise start with a temporary name
|
|
26
|
+
// that we'll swap for the email address later
|
|
27
|
+
const isExplicitAccount = !!options.account;
|
|
28
|
+
let accountName = options.account || `temp_${Date.now()}`;
|
|
29
|
+
|
|
30
|
+
console.log(chalk.cyan(`Authenticating...`));
|
|
31
|
+
console.log(chalk.gray('A browser window will open for you to authorize access.\n'));
|
|
32
|
+
|
|
33
|
+
await authorize(accountName);
|
|
34
|
+
const email = await getAccountEmail(accountName);
|
|
35
|
+
|
|
36
|
+
if (!email) {
|
|
37
|
+
throw new Error('Could not retrieve email address from the authenticated account.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// If user didn't specify an account name, we use the email
|
|
41
|
+
if (!isExplicitAccount) {
|
|
42
|
+
// Check if this email is already registered
|
|
43
|
+
const accounts = getAccounts();
|
|
44
|
+
const existing = accounts.find(a => a.email === email);
|
|
45
|
+
|
|
46
|
+
if (existing) {
|
|
47
|
+
console.log(chalk.yellow(`Account already registered as "${existing.name}" (${email})`));
|
|
48
|
+
// Clean up the temporary token
|
|
49
|
+
removeAccount(accountName);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Rename the token file from temp to email
|
|
54
|
+
renameTokenFile(accountName, email);
|
|
55
|
+
accountName = email;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
addAccount(accountName, email);
|
|
59
|
+
console.log(chalk.green(`Authentication successful!`));
|
|
60
|
+
console.log(chalk.white(`Account "${accountName}" linked to ${email}`));
|
|
61
|
+
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(chalk.red('Authentication failed:'), error.message);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
program
|
|
69
|
+
.command('accounts')
|
|
70
|
+
.description('List all configured accounts')
|
|
71
|
+
.action(async () => {
|
|
72
|
+
const accounts = getAccounts();
|
|
73
|
+
if (accounts.length === 0) {
|
|
74
|
+
console.log(chalk.gray('No accounts configured. Run: inbox auth --account <name>'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(chalk.bold('\nConfigured Accounts:\n'));
|
|
79
|
+
for (const acc of accounts) {
|
|
80
|
+
console.log(` ${chalk.cyan(acc.name)} - ${acc.email || 'unknown email'}`);
|
|
81
|
+
}
|
|
82
|
+
console.log('');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
program
|
|
86
|
+
.command('logout')
|
|
87
|
+
.description('Remove an account or all accounts')
|
|
88
|
+
.option('-a, --account <name>', 'Account to remove (or "all" to remove all)')
|
|
89
|
+
.option('--all', 'Remove all accounts')
|
|
90
|
+
.action(async (options) => {
|
|
91
|
+
if (options.all || options.account === 'all') {
|
|
92
|
+
const accounts = getAccounts();
|
|
93
|
+
if (accounts.length === 0) {
|
|
94
|
+
console.log(chalk.gray('No accounts to remove.'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
removeAllAccounts();
|
|
98
|
+
console.log(chalk.green(`Removed ${accounts.length} account(s) and cleared all tokens.`));
|
|
99
|
+
} else if (options.account) {
|
|
100
|
+
const accounts = getAccounts();
|
|
101
|
+
const exists = accounts.find(a => a.name === options.account);
|
|
102
|
+
if (!exists) {
|
|
103
|
+
console.log(chalk.yellow(`Account "${options.account}" not found.`));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
removeAccount(options.account);
|
|
107
|
+
console.log(chalk.green(`Removed account "${options.account}"`));
|
|
108
|
+
} else {
|
|
109
|
+
console.log(chalk.gray('Usage: inbox logout --account <name> or inbox logout --all'));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
program
|
|
114
|
+
.command('check')
|
|
115
|
+
.description('Check for new emails and send notifications')
|
|
116
|
+
.option('-a, --account <name>', 'Check specific account (or "all")', 'all')
|
|
117
|
+
.option('-q, --quiet', 'Suppress output, only send notifications')
|
|
118
|
+
.action(async (options) => {
|
|
119
|
+
try {
|
|
120
|
+
const accounts = options.account === 'all'
|
|
121
|
+
? getAccounts().map(a => a.name)
|
|
122
|
+
: [options.account];
|
|
123
|
+
|
|
124
|
+
if (accounts.length === 0) {
|
|
125
|
+
accounts.push('default');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let totalNew = 0;
|
|
129
|
+
const allNewEmails = [];
|
|
130
|
+
|
|
131
|
+
for (const account of accounts) {
|
|
132
|
+
clearOldSeenEmails(7, account);
|
|
133
|
+
|
|
134
|
+
const emails = await getUnreadEmails(account, 20);
|
|
135
|
+
const newEmailIds = getNewEmailIds(emails.map((e) => e.id), account);
|
|
136
|
+
const newEmails = emails.filter((e) => newEmailIds.includes(e.id));
|
|
137
|
+
|
|
138
|
+
if (newEmails.length > 0) {
|
|
139
|
+
markEmailsSeen(newEmailIds, account);
|
|
140
|
+
allNewEmails.push(...newEmails.map(e => ({ ...e, account })));
|
|
141
|
+
totalNew += newEmails.length;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
updateLastCheck(account);
|
|
145
|
+
|
|
146
|
+
if (!options.quiet && newEmails.length > 0) {
|
|
147
|
+
console.log(chalk.green(`[${account}] ${newEmails.length} new email(s)`));
|
|
148
|
+
newEmails.forEach((e) => {
|
|
149
|
+
console.log(chalk.white(` - ${e.subject}`));
|
|
150
|
+
console.log(chalk.gray(` From: ${e.from}`));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (allNewEmails.length > 0) {
|
|
156
|
+
notifyNewEmails(allNewEmails);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!options.quiet && totalNew === 0) {
|
|
160
|
+
console.log(chalk.gray('No new emails since last check.'));
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error(chalk.red('Error checking emails:'), error.message);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
program
|
|
169
|
+
.command('summary')
|
|
170
|
+
.description('Show summary of unread emails')
|
|
171
|
+
.option('-a, --account <name>', 'Show specific account (or "all")', 'all')
|
|
172
|
+
.option('-n, --count <number>', 'Number of emails per account', '5')
|
|
173
|
+
.action(async (options) => {
|
|
174
|
+
try {
|
|
175
|
+
const accounts = options.account === 'all'
|
|
176
|
+
? getAccounts().map(a => a.name)
|
|
177
|
+
: [options.account];
|
|
178
|
+
|
|
179
|
+
if (accounts.length === 0) {
|
|
180
|
+
accounts.push('default');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const maxPerAccount = parseInt(options.count, 10);
|
|
184
|
+
const sections = [];
|
|
185
|
+
|
|
186
|
+
for (const account of accounts) {
|
|
187
|
+
const count = await getEmailCount(account);
|
|
188
|
+
const emails = await getUnreadEmails(account, maxPerAccount);
|
|
189
|
+
const state = getState(account);
|
|
190
|
+
const lastCheckStr = state.lastCheck
|
|
191
|
+
? new Date(state.lastCheck).toLocaleString()
|
|
192
|
+
: 'Never';
|
|
193
|
+
|
|
194
|
+
const accountInfo = getAccounts().find(a => a.name === account);
|
|
195
|
+
const label = accountInfo?.email || account;
|
|
196
|
+
|
|
197
|
+
let content = `${chalk.bold.cyan(label)} - ${count} unread\n`;
|
|
198
|
+
content += chalk.gray(`Last check: ${lastCheckStr}\n\n`);
|
|
199
|
+
|
|
200
|
+
if (emails.length > 0) {
|
|
201
|
+
content += emails.map((e) => {
|
|
202
|
+
const from = e.from.length > 35 ? e.from.substring(0, 32) + '...' : e.from;
|
|
203
|
+
const subject = e.subject.length > 50 ? e.subject.substring(0, 47) + '...' : e.subject;
|
|
204
|
+
return `${chalk.white(from)}\n ${chalk.gray(subject)}`;
|
|
205
|
+
}).join('\n\n');
|
|
206
|
+
} else {
|
|
207
|
+
content += chalk.gray('No unread emails');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
sections.push(content);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const output = boxen(sections.join('\n\n' + chalk.gray('─'.repeat(50)) + '\n\n'), {
|
|
214
|
+
padding: 1,
|
|
215
|
+
margin: 1,
|
|
216
|
+
borderStyle: 'round',
|
|
217
|
+
borderColor: 'cyan',
|
|
218
|
+
title: 'Inbox Summary',
|
|
219
|
+
titleAlignment: 'center',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
console.log(output);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
console.error(chalk.red('Error fetching summary:'), error.message);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
program
|
|
230
|
+
.command('analyze')
|
|
231
|
+
.description('Output structured email data for AI analysis (unread only by default)')
|
|
232
|
+
.option('-a, --account <name>', 'Account to analyze (or "all")', 'all')
|
|
233
|
+
.option('-n, --count <number>', 'Number of emails to analyze per account', '20')
|
|
234
|
+
.option('--all', 'Include read and unread emails (default: unread only)')
|
|
235
|
+
.action(async (options) => {
|
|
236
|
+
try {
|
|
237
|
+
const accounts = options.account === 'all'
|
|
238
|
+
? getAccounts().map(a => a.name)
|
|
239
|
+
: [options.account];
|
|
240
|
+
|
|
241
|
+
if (accounts.length === 0) {
|
|
242
|
+
accounts.push('default');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const maxPerAccount = parseInt(options.count, 10);
|
|
246
|
+
const includeRead = !!options.all;
|
|
247
|
+
const allEmails = [];
|
|
248
|
+
|
|
249
|
+
for (const account of accounts) {
|
|
250
|
+
const emails = await getUnreadEmails(account, maxPerAccount, includeRead);
|
|
251
|
+
allEmails.push(...emails);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Output pure JSON for AI consumption
|
|
255
|
+
console.log(JSON.stringify(allEmails, null, 2));
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.error(JSON.stringify({ error: error.message }));
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
program
|
|
263
|
+
.command('delete')
|
|
264
|
+
.description('Move emails to trash')
|
|
265
|
+
.requiredOption('--ids <ids>', 'Comma-separated message IDs to delete')
|
|
266
|
+
.option('-a, --account <name>', 'Account name (required for single-account delete)')
|
|
267
|
+
.option('--confirm', 'Skip confirmation prompt')
|
|
268
|
+
.action(async (options) => {
|
|
269
|
+
try {
|
|
270
|
+
const ids = options.ids.split(',').map(id => id.trim()).filter(Boolean);
|
|
271
|
+
|
|
272
|
+
if (ids.length === 0) {
|
|
273
|
+
console.log(chalk.yellow('No message IDs provided.'));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Get account - if not specified, try to find from configured accounts
|
|
278
|
+
let account = options.account;
|
|
279
|
+
if (!account) {
|
|
280
|
+
const accounts = getAccounts();
|
|
281
|
+
if (accounts.length === 1) {
|
|
282
|
+
account = accounts[0].name;
|
|
283
|
+
} else if (accounts.length > 1) {
|
|
284
|
+
console.log(chalk.yellow('Multiple accounts configured. Please specify --account <name>'));
|
|
285
|
+
console.log(chalk.gray('Available accounts:'));
|
|
286
|
+
accounts.forEach(a => console.log(chalk.gray(` - ${a.name}`)));
|
|
287
|
+
return;
|
|
288
|
+
} else {
|
|
289
|
+
account = 'default';
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Fetch email details for logging before deletion
|
|
294
|
+
console.log(chalk.cyan(`Fetching ${ids.length} email(s) for deletion...`));
|
|
295
|
+
const emailsToDelete = [];
|
|
296
|
+
|
|
297
|
+
for (const id of ids) {
|
|
298
|
+
const email = await getEmailById(account, id);
|
|
299
|
+
if (email) {
|
|
300
|
+
emailsToDelete.push(email);
|
|
301
|
+
} else {
|
|
302
|
+
console.log(chalk.yellow(`Could not find email with ID: ${id}`));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (emailsToDelete.length === 0) {
|
|
307
|
+
console.log(chalk.yellow('No valid emails found to delete.'));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Show what will be deleted
|
|
312
|
+
if (!options.confirm) {
|
|
313
|
+
console.log(chalk.bold('\nEmails to be moved to trash:\n'));
|
|
314
|
+
emailsToDelete.forEach(e => {
|
|
315
|
+
const from = e.from.length > 40 ? e.from.substring(0, 37) + '...' : e.from;
|
|
316
|
+
const subject = e.subject.length > 50 ? e.subject.substring(0, 47) + '...' : e.subject;
|
|
317
|
+
console.log(chalk.white(` ${from}`));
|
|
318
|
+
console.log(chalk.gray(` ${subject}\n`));
|
|
319
|
+
});
|
|
320
|
+
console.log(chalk.yellow(`\nThis will move ${emailsToDelete.length} email(s) to trash.`));
|
|
321
|
+
console.log(chalk.gray('Use --confirm to skip this prompt.\n'));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Log deletions BEFORE actually deleting
|
|
325
|
+
logDeletions(emailsToDelete);
|
|
326
|
+
console.log(chalk.gray(`Logged to: ${getLogPath()}`));
|
|
327
|
+
|
|
328
|
+
// Perform the deletion
|
|
329
|
+
const results = await trashEmails(account, emailsToDelete.map(e => e.id));
|
|
330
|
+
|
|
331
|
+
const succeeded = results.filter(r => r.success).length;
|
|
332
|
+
const failed = results.filter(r => !r.success).length;
|
|
333
|
+
|
|
334
|
+
if (succeeded > 0) {
|
|
335
|
+
console.log(chalk.green(`\nMoved ${succeeded} email(s) to trash.`));
|
|
336
|
+
}
|
|
337
|
+
if (failed > 0) {
|
|
338
|
+
console.log(chalk.red(`Failed to delete ${failed} email(s).`));
|
|
339
|
+
results.filter(r => !r.success).forEach(r => {
|
|
340
|
+
console.log(chalk.red(` - ${r.id}: ${r.error}`));
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (error.message.includes('403') || error.code === 403) {
|
|
346
|
+
console.error(chalk.red('Permission denied. You may need to re-authenticate with updated scopes.'));
|
|
347
|
+
console.error(chalk.yellow('Run: node src/cli.js auth'));
|
|
348
|
+
} else {
|
|
349
|
+
console.error(chalk.red('Error deleting emails:'), error.message);
|
|
350
|
+
}
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
program
|
|
356
|
+
.command('deletion-log')
|
|
357
|
+
.description('View recent email deletions')
|
|
358
|
+
.option('-n, --days <number>', 'Show deletions from last N days', '30')
|
|
359
|
+
.action(async (options) => {
|
|
360
|
+
const days = parseInt(options.days, 10);
|
|
361
|
+
const deletions = getRecentDeletions(days);
|
|
362
|
+
|
|
363
|
+
if (deletions.length === 0) {
|
|
364
|
+
console.log(chalk.gray(`No deletions in the last ${days} days.`));
|
|
365
|
+
console.log(chalk.gray(`Log file: ${getLogPath()}`));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
console.log(chalk.bold(`\nDeletion Log (last ${days} days):\n`));
|
|
370
|
+
|
|
371
|
+
// Group by date
|
|
372
|
+
const byDate = {};
|
|
373
|
+
deletions.forEach(d => {
|
|
374
|
+
const date = new Date(d.deletedAt).toLocaleDateString();
|
|
375
|
+
if (!byDate[date]) byDate[date] = [];
|
|
376
|
+
byDate[date].push(d);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
for (const [date, items] of Object.entries(byDate)) {
|
|
380
|
+
console.log(chalk.cyan(`${date} (${items.length} deleted)`));
|
|
381
|
+
items.forEach(d => {
|
|
382
|
+
const from = d.from.length > 35 ? d.from.substring(0, 32) + '...' : d.from;
|
|
383
|
+
const subject = d.subject.length > 45 ? d.subject.substring(0, 42) + '...' : d.subject;
|
|
384
|
+
console.log(chalk.white(` ${from}`));
|
|
385
|
+
console.log(chalk.gray(` ${subject}`));
|
|
386
|
+
console.log(chalk.gray(` ID: ${d.id} | Account: ${d.account}\n`));
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
console.log(chalk.gray(`\nLog file: ${getLogPath()}`));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
program.parse();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const LOG_DIR = path.join(os.homedir(), '.config', 'inboxd');
|
|
6
|
+
const LOG_FILE = path.join(LOG_DIR, 'deletion-log.json');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensures the log directory exists
|
|
10
|
+
*/
|
|
11
|
+
function ensureLogDir() {
|
|
12
|
+
if (!fs.existsSync(LOG_DIR)) {
|
|
13
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reads the current deletion log
|
|
19
|
+
* @returns {Array} Array of deletion entries
|
|
20
|
+
*/
|
|
21
|
+
function readLog() {
|
|
22
|
+
ensureLogDir();
|
|
23
|
+
if (!fs.existsSync(LOG_FILE)) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const content = fs.readFileSync(LOG_FILE, 'utf8');
|
|
28
|
+
return JSON.parse(content);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Logs deleted emails to the deletion log
|
|
36
|
+
* @param {Array} emails - Array of email objects with id, threadId, account, from, subject, snippet
|
|
37
|
+
*/
|
|
38
|
+
function logDeletions(emails) {
|
|
39
|
+
ensureLogDir();
|
|
40
|
+
const log = readLog();
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
|
|
43
|
+
for (const email of emails) {
|
|
44
|
+
log.push({
|
|
45
|
+
deletedAt: timestamp,
|
|
46
|
+
account: email.account,
|
|
47
|
+
id: email.id,
|
|
48
|
+
threadId: email.threadId,
|
|
49
|
+
from: email.from,
|
|
50
|
+
subject: email.subject,
|
|
51
|
+
snippet: email.snippet,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fs.writeFileSync(LOG_FILE, JSON.stringify(log, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Gets recent deletions from the log
|
|
60
|
+
* @param {number} days - Number of days to look back (default: 30)
|
|
61
|
+
* @returns {Array} Array of deletion entries within the time range
|
|
62
|
+
*/
|
|
63
|
+
function getRecentDeletions(days = 30) {
|
|
64
|
+
const log = readLog();
|
|
65
|
+
const cutoff = new Date();
|
|
66
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
67
|
+
|
|
68
|
+
return log.filter((entry) => {
|
|
69
|
+
const deletedAt = new Date(entry.deletedAt);
|
|
70
|
+
return deletedAt >= cutoff;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Gets the path to the log file (for display purposes)
|
|
76
|
+
* @returns {string} The log file path
|
|
77
|
+
*/
|
|
78
|
+
function getLogPath() {
|
|
79
|
+
return LOG_FILE;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
logDeletions,
|
|
84
|
+
getRecentDeletions,
|
|
85
|
+
getLogPath,
|
|
86
|
+
readLog,
|
|
87
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const fsSync = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { authenticate } = require('@google-cloud/local-auth');
|
|
6
|
+
const { google } = require('googleapis');
|
|
7
|
+
|
|
8
|
+
const SCOPES = ['https://www.googleapis.com/auth/gmail.modify'];
|
|
9
|
+
|
|
10
|
+
const TOKEN_DIR = process.env.INBOXD_TOKEN_DIR || path.join(os.homedir(), '.config', 'inboxd');
|
|
11
|
+
const ACCOUNTS_FILE = path.join(TOKEN_DIR, 'accounts.json');
|
|
12
|
+
|
|
13
|
+
function getCredentialsPath() {
|
|
14
|
+
return process.env.GMAIL_CREDENTIALS_PATH || path.join(process.cwd(), 'credentials.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getTokenPath(account = 'default') {
|
|
18
|
+
return path.join(TOKEN_DIR, `token-${account}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadAccounts() {
|
|
22
|
+
try {
|
|
23
|
+
if (fsSync.existsSync(ACCOUNTS_FILE)) {
|
|
24
|
+
return JSON.parse(fsSync.readFileSync(ACCOUNTS_FILE, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {}
|
|
27
|
+
return { accounts: [], defaultAccount: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveAccounts(data) {
|
|
31
|
+
fsSync.mkdirSync(TOKEN_DIR, { recursive: true });
|
|
32
|
+
fsSync.writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getAccounts() {
|
|
36
|
+
return loadAccounts().accounts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function addAccount(name, email) {
|
|
40
|
+
const data = loadAccounts();
|
|
41
|
+
const existing = data.accounts.find(a => a.name === name);
|
|
42
|
+
if (existing) {
|
|
43
|
+
existing.email = email;
|
|
44
|
+
} else {
|
|
45
|
+
data.accounts.push({ name, email });
|
|
46
|
+
}
|
|
47
|
+
if (!data.defaultAccount) {
|
|
48
|
+
data.defaultAccount = name;
|
|
49
|
+
}
|
|
50
|
+
saveAccounts(data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getDefaultAccount() {
|
|
54
|
+
const data = loadAccounts();
|
|
55
|
+
return data.defaultAccount || (data.accounts[0]?.name) || 'default';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function removeAccount(name) {
|
|
59
|
+
const data = loadAccounts();
|
|
60
|
+
data.accounts = data.accounts.filter(a => a.name !== name);
|
|
61
|
+
if (data.defaultAccount === name) {
|
|
62
|
+
data.defaultAccount = data.accounts[0]?.name || null;
|
|
63
|
+
}
|
|
64
|
+
saveAccounts(data);
|
|
65
|
+
|
|
66
|
+
// Remove token file
|
|
67
|
+
const tokenPath = getTokenPath(name);
|
|
68
|
+
if (fsSync.existsSync(tokenPath)) {
|
|
69
|
+
fsSync.unlinkSync(tokenPath);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Remove state file
|
|
73
|
+
const statePath = path.join(TOKEN_DIR, `state-${name}.json`);
|
|
74
|
+
if (fsSync.existsSync(statePath)) {
|
|
75
|
+
fsSync.unlinkSync(statePath);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function removeAllAccounts() {
|
|
80
|
+
const data = loadAccounts();
|
|
81
|
+
const accountNames = data.accounts.map(a => a.name);
|
|
82
|
+
|
|
83
|
+
for (const name of accountNames) {
|
|
84
|
+
const tokenPath = getTokenPath(name);
|
|
85
|
+
if (fsSync.existsSync(tokenPath)) {
|
|
86
|
+
fsSync.unlinkSync(tokenPath);
|
|
87
|
+
}
|
|
88
|
+
const statePath = path.join(TOKEN_DIR, `state-${name}.json`);
|
|
89
|
+
if (fsSync.existsSync(statePath)) {
|
|
90
|
+
fsSync.unlinkSync(statePath);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Also clean up any legacy files
|
|
95
|
+
const legacyToken = path.join(TOKEN_DIR, 'token.json');
|
|
96
|
+
if (fsSync.existsSync(legacyToken)) {
|
|
97
|
+
fsSync.unlinkSync(legacyToken);
|
|
98
|
+
}
|
|
99
|
+
const legacyState = path.join(TOKEN_DIR, 'state.json');
|
|
100
|
+
if (fsSync.existsSync(legacyState)) {
|
|
101
|
+
fsSync.unlinkSync(legacyState);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
saveAccounts({ accounts: [], defaultAccount: null });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renameTokenFile(oldName, newName) {
|
|
108
|
+
const oldPath = getTokenPath(oldName);
|
|
109
|
+
const newPath = getTokenPath(newName);
|
|
110
|
+
if (fsSync.existsSync(oldPath)) {
|
|
111
|
+
fsSync.renameSync(oldPath, newPath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function loadSavedCredentialsIfExist(account = 'default') {
|
|
116
|
+
try {
|
|
117
|
+
const tokenPath = getTokenPath(account);
|
|
118
|
+
const content = await fs.readFile(tokenPath, 'utf8');
|
|
119
|
+
const credentials = JSON.parse(content);
|
|
120
|
+
return google.auth.fromJSON(credentials);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function saveCredentials(client, account = 'default') {
|
|
127
|
+
const credentialsPath = getCredentialsPath();
|
|
128
|
+
const content = await fs.readFile(credentialsPath, 'utf8');
|
|
129
|
+
const keys = JSON.parse(content);
|
|
130
|
+
const key = keys.installed || keys.web;
|
|
131
|
+
const payload = JSON.stringify({
|
|
132
|
+
type: 'authorized_user',
|
|
133
|
+
client_id: key.client_id,
|
|
134
|
+
client_secret: key.client_secret,
|
|
135
|
+
refresh_token: client.credentials.refresh_token,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await fs.mkdir(TOKEN_DIR, { recursive: true });
|
|
139
|
+
await fs.writeFile(getTokenPath(account), payload);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function authorize(account = 'default') {
|
|
143
|
+
let client = await loadSavedCredentialsIfExist(account);
|
|
144
|
+
if (client) {
|
|
145
|
+
return client;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const credentialsPath = getCredentialsPath();
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await fs.access(credentialsPath);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`credentials.json not found at ${credentialsPath}\n\n` +
|
|
155
|
+
`To set up Gmail API access:\n` +
|
|
156
|
+
`1. Go to https://console.cloud.google.com/\n` +
|
|
157
|
+
`2. Create a new project\n` +
|
|
158
|
+
`3. Enable the Gmail API\n` +
|
|
159
|
+
`4. Create OAuth 2.0 credentials (Desktop app)\n` +
|
|
160
|
+
`5. Download and save as credentials.json in the project root`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
client = await authenticate({
|
|
165
|
+
scopes: SCOPES,
|
|
166
|
+
keyfilePath: credentialsPath,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (client.credentials) {
|
|
170
|
+
await saveCredentials(client, account);
|
|
171
|
+
}
|
|
172
|
+
return client;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function getGmailClient(account = 'default') {
|
|
176
|
+
const auth = await authorize(account);
|
|
177
|
+
return google.gmail({ version: 'v1', auth });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function getAccountEmail(account = 'default') {
|
|
181
|
+
try {
|
|
182
|
+
const gmail = await getGmailClient(account);
|
|
183
|
+
const profile = await gmail.users.getProfile({ userId: 'me' });
|
|
184
|
+
return profile.data.emailAddress;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
getGmailClient,
|
|
192
|
+
authorize,
|
|
193
|
+
getTokenPath,
|
|
194
|
+
getCredentialsPath,
|
|
195
|
+
getAccounts,
|
|
196
|
+
addAccount,
|
|
197
|
+
removeAccount,
|
|
198
|
+
removeAllAccounts,
|
|
199
|
+
getDefaultAccount,
|
|
200
|
+
getAccountEmail,
|
|
201
|
+
renameTokenFile,
|
|
202
|
+
TOKEN_DIR,
|
|
203
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const { getGmailClient } = require('./gmail-auth');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Executes an async operation with a single retry on network errors.
|
|
5
|
+
* Max 2 attempts total (initial + 1 retry).
|
|
6
|
+
* Delays 1 second before retry.
|
|
7
|
+
*/
|
|
8
|
+
async function withRetry(operation) {
|
|
9
|
+
try {
|
|
10
|
+
return await operation();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
// Do not retry on auth errors
|
|
13
|
+
if (error.code === 401 || error.code === 403 || error.response?.status === 401 || error.response?.status === 403) {
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const networkErrors = ['ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'];
|
|
18
|
+
const isNetworkError = networkErrors.includes(error.code);
|
|
19
|
+
const isServerError = error.response?.status >= 500;
|
|
20
|
+
|
|
21
|
+
if (isNetworkError || isServerError) {
|
|
22
|
+
// console.log('Retrying operation due to error:', error.message);
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
24
|
+
return await operation();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function getUnreadEmails(account = 'default', maxResults = 20, includeRead = false) {
|
|
32
|
+
try {
|
|
33
|
+
const gmail = await getGmailClient(account);
|
|
34
|
+
|
|
35
|
+
const res = await withRetry(() => gmail.users.messages.list({
|
|
36
|
+
userId: 'me',
|
|
37
|
+
q: includeRead ? '' : 'is:unread',
|
|
38
|
+
maxResults,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const messages = res.data.messages;
|
|
42
|
+
if (!messages || messages.length === 0) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const emailPromises = messages.map(async (msg) => {
|
|
47
|
+
try {
|
|
48
|
+
const detail = await withRetry(() => gmail.users.messages.get({
|
|
49
|
+
userId: 'me',
|
|
50
|
+
id: msg.id,
|
|
51
|
+
format: 'metadata',
|
|
52
|
+
metadataHeaders: ['From', 'Subject', 'Date'],
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const headers = detail.data.payload.headers;
|
|
56
|
+
const getHeader = (name) => {
|
|
57
|
+
const header = headers.find((h) => h.name === name);
|
|
58
|
+
return header ? header.value : '';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
id: msg.id,
|
|
63
|
+
threadId: detail.data.threadId,
|
|
64
|
+
labelIds: detail.data.labelIds || [],
|
|
65
|
+
account,
|
|
66
|
+
from: getHeader('From'),
|
|
67
|
+
subject: getHeader('Subject'),
|
|
68
|
+
snippet: detail.data.snippet,
|
|
69
|
+
date: getHeader('Date'),
|
|
70
|
+
};
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const results = await Promise.all(emailPromises);
|
|
77
|
+
return results.filter((email) => email !== null);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error(`Error in getUnreadEmails for ${account}:`, error.message);
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function getEmailCount(account = 'default') {
|
|
85
|
+
try {
|
|
86
|
+
const gmail = await getGmailClient(account);
|
|
87
|
+
const res = await withRetry(() => gmail.users.labels.get({
|
|
88
|
+
userId: 'me',
|
|
89
|
+
id: 'INBOX',
|
|
90
|
+
}));
|
|
91
|
+
return res.data.messagesUnread || 0;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(`Error in getEmailCount for ${account}:`, error.message);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Moves emails to trash
|
|
100
|
+
* @param {string} account - Account name
|
|
101
|
+
* @param {Array<string>} messageIds - Array of message IDs to trash
|
|
102
|
+
* @returns {Array<{id: string, success: boolean, error?: string}>} Results for each message
|
|
103
|
+
*/
|
|
104
|
+
async function trashEmails(account, messageIds) {
|
|
105
|
+
const gmail = await getGmailClient(account);
|
|
106
|
+
const results = [];
|
|
107
|
+
|
|
108
|
+
for (const id of messageIds) {
|
|
109
|
+
try {
|
|
110
|
+
await withRetry(() => gmail.users.messages.trash({
|
|
111
|
+
userId: 'me',
|
|
112
|
+
id: id,
|
|
113
|
+
}));
|
|
114
|
+
results.push({ id, success: true });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
results.push({ id, success: false, error: err.message });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return results;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Gets email details by ID (for logging before deletion)
|
|
125
|
+
* @param {string} account - Account name
|
|
126
|
+
* @param {string} messageId - Message ID
|
|
127
|
+
* @returns {Object|null} Email object or null if not found
|
|
128
|
+
*/
|
|
129
|
+
async function getEmailById(account, messageId) {
|
|
130
|
+
try {
|
|
131
|
+
const gmail = await getGmailClient(account);
|
|
132
|
+
const detail = await withRetry(() => gmail.users.messages.get({
|
|
133
|
+
userId: 'me',
|
|
134
|
+
id: messageId,
|
|
135
|
+
format: 'metadata',
|
|
136
|
+
metadataHeaders: ['From', 'Subject', 'Date'],
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const headers = detail.data.payload.headers;
|
|
140
|
+
const getHeader = (name) => {
|
|
141
|
+
const header = headers.find((h) => h.name === name);
|
|
142
|
+
return header ? header.value : '';
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
id: messageId,
|
|
147
|
+
threadId: detail.data.threadId,
|
|
148
|
+
labelIds: detail.data.labelIds || [],
|
|
149
|
+
account,
|
|
150
|
+
from: getHeader('From'),
|
|
151
|
+
subject: getHeader('Subject'),
|
|
152
|
+
snippet: detail.data.snippet,
|
|
153
|
+
date: getHeader('Date'),
|
|
154
|
+
};
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
getUnreadEmails,
|
|
162
|
+
getEmailCount,
|
|
163
|
+
trashEmails,
|
|
164
|
+
getEmailById,
|
|
165
|
+
};
|
package/src/notifier.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const notifier = require('node-notifier');
|
|
2
|
+
|
|
3
|
+
function notify({ title, message, subtitle }) {
|
|
4
|
+
notifier.notify({
|
|
5
|
+
title: title || 'Inboxd',
|
|
6
|
+
message: message,
|
|
7
|
+
subtitle: subtitle || 'Inboxd',
|
|
8
|
+
sound: true,
|
|
9
|
+
wait: false
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function extractSenderName(from) {
|
|
14
|
+
if (!from) return 'Unknown';
|
|
15
|
+
const match = from.match(/^"?(.*?)"?\s*<.*>$/);
|
|
16
|
+
return match ? match[1] : from.split('@')[0];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function notifyNewEmails(emails) {
|
|
20
|
+
if (!Array.isArray(emails) || emails.length === 0) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const count = emails.length;
|
|
25
|
+
const senders = emails.map(email => extractSenderName(email.from));
|
|
26
|
+
|
|
27
|
+
// Get first 3 unique senders for the preview
|
|
28
|
+
const uniqueSenders = [...new Set(senders)];
|
|
29
|
+
const previewSenders = uniqueSenders.slice(0, 3);
|
|
30
|
+
|
|
31
|
+
let message = `From: ${previewSenders.join(', ')}`;
|
|
32
|
+
|
|
33
|
+
if (uniqueSenders.length > 3) {
|
|
34
|
+
message += `, and ${uniqueSenders.length - 3} others`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
notify({
|
|
38
|
+
title: `${count} New Email${count === 1 ? '' : 's'}`,
|
|
39
|
+
message: message,
|
|
40
|
+
subtitle: 'Inboxd'
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
notify,
|
|
46
|
+
notifyNewEmails,
|
|
47
|
+
extractSenderName
|
|
48
|
+
};
|
package/src/state.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const STATE_DIR = path.join(os.homedir(), '.config', 'inboxd');
|
|
6
|
+
|
|
7
|
+
function getStatePath(account = 'default') {
|
|
8
|
+
return path.join(STATE_DIR, `state-${account}.json`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function ensureDir() {
|
|
12
|
+
if (!fs.existsSync(STATE_DIR)) {
|
|
13
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadState(account = 'default') {
|
|
18
|
+
try {
|
|
19
|
+
const statePath = getStatePath(account);
|
|
20
|
+
if (fs.existsSync(statePath)) {
|
|
21
|
+
const content = fs.readFileSync(statePath, 'utf8');
|
|
22
|
+
return JSON.parse(content);
|
|
23
|
+
}
|
|
24
|
+
} catch (error) {}
|
|
25
|
+
return {
|
|
26
|
+
lastCheck: null,
|
|
27
|
+
seenEmailIds: [],
|
|
28
|
+
lastNotifiedAt: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function saveState(state, account = 'default') {
|
|
33
|
+
ensureDir();
|
|
34
|
+
fs.writeFileSync(getStatePath(account), JSON.stringify(state, null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getState(account = 'default') {
|
|
38
|
+
return loadState(account);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function updateLastCheck(account = 'default') {
|
|
42
|
+
const state = loadState(account);
|
|
43
|
+
state.lastCheck = Date.now();
|
|
44
|
+
saveState(state, account);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function markEmailsSeen(ids, account = 'default') {
|
|
48
|
+
if (!Array.isArray(ids) || ids.length === 0) return;
|
|
49
|
+
|
|
50
|
+
const state = loadState(account);
|
|
51
|
+
const seen = state.seenEmailIds || [];
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
|
|
54
|
+
const existingIds = new Set(seen.map((item) => (typeof item === 'string' ? item : item.id)));
|
|
55
|
+
|
|
56
|
+
const newEntries = ids
|
|
57
|
+
.filter((id) => !existingIds.has(id))
|
|
58
|
+
.map((id) => ({ id, timestamp: now }));
|
|
59
|
+
|
|
60
|
+
if (newEntries.length > 0) {
|
|
61
|
+
state.seenEmailIds = [...seen, ...newEntries];
|
|
62
|
+
saveState(state, account);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isEmailSeen(id, account = 'default') {
|
|
67
|
+
const state = loadState(account);
|
|
68
|
+
const seen = state.seenEmailIds || [];
|
|
69
|
+
return seen.some((item) => (typeof item === 'string' ? item === id : item.id === id));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function clearOldSeenEmails(olderThanDays = 7, account = 'default') {
|
|
73
|
+
const state = loadState(account);
|
|
74
|
+
const seen = state.seenEmailIds || [];
|
|
75
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
76
|
+
|
|
77
|
+
const filtered = seen.filter((item) => {
|
|
78
|
+
if (typeof item === 'string') return true;
|
|
79
|
+
return item.timestamp > cutoff;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (filtered.length !== seen.length) {
|
|
83
|
+
state.seenEmailIds = filtered;
|
|
84
|
+
saveState(state, account);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getNewEmailIds(emailIds, account = 'default') {
|
|
89
|
+
return emailIds.filter((id) => !isEmailSeen(id, account));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function updateLastNotifiedAt(account = 'default') {
|
|
93
|
+
const state = loadState(account);
|
|
94
|
+
state.lastNotifiedAt = Date.now();
|
|
95
|
+
saveState(state, account);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
getState,
|
|
100
|
+
updateLastCheck,
|
|
101
|
+
markEmailsSeen,
|
|
102
|
+
isEmailSeen,
|
|
103
|
+
clearOldSeenEmails,
|
|
104
|
+
getNewEmailIds,
|
|
105
|
+
updateLastNotifiedAt,
|
|
106
|
+
};
|