inboxd 1.0.10 → 1.0.12

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.
@@ -286,6 +286,359 @@ function groupEmailsBySender(emails) {
286
286
  return { groups: groupArray, totalCount: emails.length };
287
287
  }
288
288
 
289
+ /**
290
+ * Decodes base64url encoded content
291
+ * @param {string} str - Base64url encoded string
292
+ * @returns {string} Decoded UTF-8 string
293
+ */
294
+ function decodeBase64Url(str) {
295
+ if (!str) return '';
296
+ return Buffer.from(str, 'base64url').toString('utf8');
297
+ }
298
+
299
+ /**
300
+ * Extracts body content from a Gmail message payload
301
+ * Handles multipart messages recursively
302
+ * @param {Object} payload - Gmail message payload
303
+ * @param {Object} options - { preferHtml: boolean } - prefer HTML for link extraction
304
+ * @returns {{type: string, content: string}} Body content with mime type
305
+ */
306
+ function extractBody(payload, options = {}) {
307
+ const { preferHtml = false } = options;
308
+
309
+ // Simple case: body data directly in payload
310
+ if (payload.body && payload.body.data) {
311
+ return {
312
+ type: payload.mimeType,
313
+ content: decodeBase64Url(payload.body.data)
314
+ };
315
+ }
316
+
317
+ if (!payload.parts) {
318
+ return { type: 'text/plain', content: '' };
319
+ }
320
+
321
+ // Determine preference order based on options
322
+ const mimeOrder = preferHtml
323
+ ? ['text/html', 'text/plain']
324
+ : ['text/plain', 'text/html'];
325
+
326
+ for (const mimeType of mimeOrder) {
327
+ const part = payload.parts.find(p => p.mimeType === mimeType);
328
+ if (part && part.body && part.body.data) {
329
+ return {
330
+ type: mimeType,
331
+ content: decodeBase64Url(part.body.data)
332
+ };
333
+ }
334
+ }
335
+
336
+ // Recursive check for nested multipart (e.g., multipart/mixed containing multipart/alternative)
337
+ for (const part of payload.parts) {
338
+ if (part.parts) {
339
+ const found = extractBody(part, options);
340
+ if (found.content) {
341
+ return found;
342
+ }
343
+ }
344
+ }
345
+
346
+ return { type: 'text/plain', content: '' };
347
+ }
348
+
349
+ /**
350
+ * Validates URL scheme - filters out non-http(s) schemes
351
+ * @param {string} url - URL to validate
352
+ * @returns {boolean} True if URL should be included
353
+ */
354
+ function isValidUrl(url) {
355
+ if (!url) return false;
356
+ const lowerUrl = url.toLowerCase().trim();
357
+ // Only allow http and https
358
+ return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
359
+ }
360
+
361
+ /**
362
+ * Extracts links from email body content
363
+ * @param {string} body - Email body content
364
+ * @param {string} mimeType - 'text/html' or 'text/plain'
365
+ * @returns {Array<{url: string, text: string|null}>} Extracted links
366
+ */
367
+ function extractLinks(body, mimeType) {
368
+ if (!body) return [];
369
+
370
+ const links = [];
371
+ const seenUrls = new Set();
372
+
373
+ // For HTML, extract from anchor tags first (captures link text)
374
+ if (mimeType === 'text/html') {
375
+ // Match <a href="URL">Text</a> - handles attributes in any order
376
+ const hrefRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi;
377
+ let match;
378
+ while ((match = hrefRegex.exec(body)) !== null) {
379
+ const url = decodeHtmlEntities(match[1].trim());
380
+ const text = match[2].trim() || null;
381
+ if (!seenUrls.has(url) && isValidUrl(url)) {
382
+ seenUrls.add(url);
383
+ links.push({ url, text });
384
+ }
385
+ }
386
+ }
387
+
388
+ // Also extract plain URLs (works for both HTML and plain text)
389
+ // This catches URLs not in anchor tags
390
+ const urlRegex = /https?:\/\/[^\s<>"']+/gi;
391
+ let urlMatch;
392
+ while ((urlMatch = urlRegex.exec(body)) !== null) {
393
+ // Clean trailing punctuation that's likely not part of the URL
394
+ let url = urlMatch[0].replace(/[.,;:!?)>\]]+$/, '');
395
+ // Also handle HTML entity at end
396
+ url = url.replace(/&[a-z]+;?$/i, '');
397
+ // Decode HTML entities for consistency (important for HTML content)
398
+ url = decodeHtmlEntities(url);
399
+ if (!seenUrls.has(url) && isValidUrl(url)) {
400
+ seenUrls.add(url);
401
+ links.push({ url, text: null });
402
+ }
403
+ }
404
+
405
+ return links;
406
+ }
407
+
408
+ /**
409
+ * Decodes common HTML entities in URLs
410
+ * @param {string} str - String potentially containing HTML entities
411
+ * @returns {string} Decoded string
412
+ */
413
+ function decodeHtmlEntities(str) {
414
+ if (!str) return '';
415
+ return str
416
+ .replace(/&amp;/g, '&')
417
+ .replace(/&lt;/g, '<')
418
+ .replace(/&gt;/g, '>')
419
+ .replace(/&quot;/g, '"')
420
+ .replace(/&#39;/g, "'");
421
+ }
422
+
423
+ /**
424
+ * Gets full email content by ID
425
+ * @param {string} account - Account name
426
+ * @param {string} messageId - Message ID
427
+ * @param {Object} options - { preferHtml: boolean } - prefer HTML for link extraction
428
+ * @returns {Object|null} Email object with body or null if not found
429
+ */
430
+ async function getEmailContent(account, messageId, options = {}) {
431
+ try {
432
+ const gmail = await getGmailClient(account);
433
+ const detail = await withRetry(() => gmail.users.messages.get({
434
+ userId: 'me',
435
+ id: messageId,
436
+ format: 'full',
437
+ }));
438
+
439
+ const headers = detail.data.payload.headers;
440
+ const getHeader = (name) => {
441
+ const header = headers.find((h) => h.name.toLowerCase() === name.toLowerCase());
442
+ return header ? header.value : '';
443
+ };
444
+
445
+ const bodyData = extractBody(detail.data.payload, options);
446
+
447
+ return {
448
+ id: messageId,
449
+ threadId: detail.data.threadId,
450
+ labelIds: detail.data.labelIds || [],
451
+ account,
452
+ from: getHeader('From'),
453
+ to: getHeader('To'),
454
+ subject: getHeader('Subject'),
455
+ date: getHeader('Date'),
456
+ snippet: detail.data.snippet,
457
+ body: bodyData.content,
458
+ mimeType: bodyData.type
459
+ };
460
+ } catch (error) {
461
+ console.error(`Error fetching email content ${messageId}:`, error.message);
462
+ return null;
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Searches for emails using Gmail query syntax
468
+ * @param {string} account - Account name
469
+ * @param {string} query - Gmail search query (e.g. "is:unread from:google")
470
+ * @param {number} maxResults - Max results to return
471
+ * @returns {Array} List of email metadata objects
472
+ */
473
+ async function searchEmails(account, query, maxResults = 20) {
474
+ try {
475
+ const gmail = await getGmailClient(account);
476
+ const res = await withRetry(() => gmail.users.messages.list({
477
+ userId: 'me',
478
+ q: query,
479
+ maxResults,
480
+ }));
481
+
482
+ const messages = res.data.messages;
483
+ if (!messages || messages.length === 0) {
484
+ return [];
485
+ }
486
+
487
+ const emailPromises = messages.map(async (msg) => {
488
+ try {
489
+ const detail = await withRetry(() => gmail.users.messages.get({
490
+ userId: 'me',
491
+ id: msg.id,
492
+ format: 'metadata',
493
+ metadataHeaders: ['From', 'Subject', 'Date'],
494
+ }));
495
+
496
+ const headers = detail.data.payload.headers;
497
+ const getHeader = (name) => {
498
+ const header = headers.find((h) => h.name.toLowerCase() === name.toLowerCase());
499
+ return header ? header.value : '';
500
+ };
501
+
502
+ return {
503
+ id: msg.id,
504
+ threadId: detail.data.threadId,
505
+ labelIds: detail.data.labelIds || [],
506
+ account,
507
+ from: getHeader('From'),
508
+ subject: getHeader('Subject'),
509
+ snippet: detail.data.snippet,
510
+ date: getHeader('Date'),
511
+ };
512
+ } catch (_err) {
513
+ return null;
514
+ }
515
+ });
516
+
517
+ const results = await Promise.all(emailPromises);
518
+ return results.filter((email) => email !== null);
519
+ } catch (error) {
520
+ console.error(`Error searching emails for ${account}:`, error.message);
521
+ return [];
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Composes a raw RFC 2822 email message
527
+ * @param {Object} options - { to, subject, body, inReplyTo?, references? }
528
+ * @returns {string} Base64url encoded message
529
+ */
530
+ function composeMessage({ to, subject, body, inReplyTo, references }) {
531
+ const messageParts = [
532
+ `To: ${to}`,
533
+ `Subject: ${subject}`,
534
+ ];
535
+
536
+ if (inReplyTo) {
537
+ messageParts.push(`In-Reply-To: ${inReplyTo}`);
538
+ }
539
+ if (references) {
540
+ messageParts.push(`References: ${references}`);
541
+ }
542
+
543
+ messageParts.push(
544
+ 'Content-Type: text/plain; charset="UTF-8"',
545
+ 'MIME-Version: 1.0',
546
+ '',
547
+ body
548
+ );
549
+
550
+ return Buffer.from(messageParts.join('\n')).toString('base64url');
551
+ }
552
+
553
+ /**
554
+ * Sends an email
555
+ * @param {string} account - Account name
556
+ * @param {Object} options - { to, subject, body }
557
+ * @returns {Object} Result object with success, id, threadId, or error
558
+ */
559
+ async function sendEmail(account, { to, subject, body }) {
560
+ try {
561
+ const gmail = await getGmailClient(account);
562
+ const encodedMessage = composeMessage({ to, subject, body });
563
+
564
+ const res = await withRetry(() => gmail.users.messages.send({
565
+ userId: 'me',
566
+ requestBody: {
567
+ raw: encodedMessage
568
+ }
569
+ }));
570
+
571
+ return { success: true, id: res.data.id, threadId: res.data.threadId };
572
+ } catch (error) {
573
+ return { success: false, error: error.message };
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Reply to an email
579
+ * @param {string} account - Account name
580
+ * @param {string} messageId - ID of the message to reply to
581
+ * @param {string} body - Reply content
582
+ * @returns {Object} Result object with success, id, threadId, or error
583
+ */
584
+ async function replyToEmail(account, messageId, body) {
585
+ try {
586
+ const gmail = await getGmailClient(account);
587
+
588
+ // Get original message headers
589
+ const original = await withRetry(() => gmail.users.messages.get({
590
+ userId: 'me',
591
+ id: messageId,
592
+ format: 'metadata',
593
+ metadataHeaders: ['Subject', 'Message-ID', 'References', 'Reply-To', 'From']
594
+ }));
595
+
596
+ const headers = original.data.payload.headers;
597
+ const getHeader = (name) => {
598
+ const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
599
+ return header ? header.value : '';
600
+ };
601
+
602
+ const originalSubject = getHeader('Subject');
603
+ const originalMessageId = getHeader('Message-ID');
604
+ const originalReferences = getHeader('References');
605
+ // Prefer Reply-To header, fallback to From
606
+ const replyTo = getHeader('Reply-To');
607
+ const originalFrom = getHeader('From');
608
+ const to = replyTo || originalFrom;
609
+
610
+ // Add Re: prefix if not present
611
+ const subject = originalSubject.toLowerCase().startsWith('re:')
612
+ ? originalSubject
613
+ : `Re: ${originalSubject}`;
614
+
615
+ // Build references chain
616
+ const references = originalReferences
617
+ ? `${originalReferences} ${originalMessageId}`
618
+ : originalMessageId;
619
+
620
+ const encodedMessage = composeMessage({
621
+ to,
622
+ subject,
623
+ body,
624
+ inReplyTo: originalMessageId,
625
+ references
626
+ });
627
+
628
+ const res = await withRetry(() => gmail.users.messages.send({
629
+ userId: 'me',
630
+ requestBody: {
631
+ raw: encodedMessage,
632
+ threadId: original.data.threadId
633
+ }
634
+ }));
635
+
636
+ return { success: true, id: res.data.id, threadId: res.data.threadId };
637
+ } catch (error) {
638
+ return { success: false, error: error.message };
639
+ }
640
+ }
641
+
289
642
  module.exports = {
290
643
  getUnreadEmails,
291
644
  getEmailCount,
@@ -296,4 +649,15 @@ module.exports = {
296
649
  archiveEmails,
297
650
  extractSenderDomain,
298
651
  groupEmailsBySender,
652
+ getEmailContent,
653
+ searchEmails,
654
+ sendEmail,
655
+ replyToEmail,
656
+ extractLinks,
657
+ // Exposed for testing
658
+ extractBody,
659
+ decodeBase64Url,
660
+ composeMessage,
661
+ isValidUrl,
662
+ decodeHtmlEntities,
299
663
  };
@@ -0,0 +1,87 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { TOKEN_DIR } = require('./gmail-auth');
4
+ const { atomicWriteJsonSync } = require('./utils');
5
+
6
+ const LOG_DIR = TOKEN_DIR;
7
+ const LOG_FILE = path.join(LOG_DIR, 'sent-log.json');
8
+
9
+ /**
10
+ * Ensures the log directory exists
11
+ */
12
+ function ensureLogDir() {
13
+ if (!fs.existsSync(LOG_DIR)) {
14
+ fs.mkdirSync(LOG_DIR, { recursive: true });
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Reads the current sent log
20
+ * @returns {Array} Array of sent email entries
21
+ */
22
+ function readSentLog() {
23
+ ensureLogDir();
24
+ if (!fs.existsSync(LOG_FILE)) {
25
+ return [];
26
+ }
27
+ try {
28
+ const content = fs.readFileSync(LOG_FILE, 'utf8');
29
+ return JSON.parse(content);
30
+ } catch (_err) {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Logs a sent email to the sent log
37
+ * @param {Object} entry - { account, to, subject, body, id, threadId, replyToId? }
38
+ */
39
+ function logSentEmail(entry) {
40
+ ensureLogDir();
41
+ const log = readSentLog();
42
+ const timestamp = new Date().toISOString();
43
+
44
+ log.push({
45
+ sentAt: timestamp,
46
+ account: entry.account,
47
+ id: entry.id,
48
+ threadId: entry.threadId,
49
+ to: entry.to,
50
+ subject: entry.subject,
51
+ bodyPreview: entry.body.substring(0, 200),
52
+ replyToId: entry.replyToId || null,
53
+ });
54
+
55
+ atomicWriteJsonSync(LOG_FILE, log);
56
+ }
57
+
58
+ /**
59
+ * Gets recent sent emails from the log
60
+ * @param {number} days - Number of days to look back (default: 30)
61
+ * @returns {Array} Array of sent email entries within the time range
62
+ */
63
+ function getRecentSent(days = 30) {
64
+ const log = readSentLog();
65
+ const cutoff = new Date();
66
+ cutoff.setDate(cutoff.getDate() - days);
67
+
68
+ return log.filter((entry) => {
69
+ const sentAt = new Date(entry.sentAt);
70
+ return sentAt >= 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 getSentLogPath() {
79
+ return LOG_FILE;
80
+ }
81
+
82
+ module.exports = {
83
+ logSentEmail,
84
+ getRecentSent,
85
+ getSentLogPath,
86
+ readSentLog,
87
+ };
@@ -203,11 +203,14 @@ function installSkill(options = {}) {
203
203
  const skillsDir = path.dirname(SKILL_DEST_DIR);
204
204
  fs.mkdirSync(skillsDir, { recursive: true });
205
205
 
206
- // Backup if replacing existing (user may have modified)
206
+ // Backup if replacing existing AND user modified it
207
+ // (Don't backup if it matches source - nothing worth preserving)
207
208
  let backedUp = false;
208
209
  let backupPath = null;
209
210
  let backupResult = null;
210
- if (status.installed) {
211
+ if (status.installed && updateInfo.hashMismatch) {
212
+ // Only backup if the installed version differs from our source
213
+ // This prevents overwriting a previous backup with an unmodified version
211
214
  backupResult = createBackup(SKILL_DEST_DIR);
212
215
 
213
216
  if (!backupResult.success) {
@@ -219,7 +222,9 @@ function installSkill(options = {}) {
219
222
  path: SKILL_DEST_DIR
220
223
  };
221
224
  }
225
+ }
222
226
 
227
+ if (status.installed) {
223
228
  // Remove existing for clean update
224
229
  fs.rmSync(SKILL_DEST_DIR, { recursive: true, force: true });
225
230
  }