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.
@@ -209,6 +209,34 @@ async function markAsRead(account, messageIds) {
209
209
  return results;
210
210
  }
211
211
 
212
+ /**
213
+ * Marks emails as unread by adding the UNREAD label
214
+ * @param {string} account - Account name
215
+ * @param {Array<string>} messageIds - Array of message IDs to mark as unread
216
+ * @returns {Array<{id: string, success: boolean, error?: string}>} Results for each message
217
+ */
218
+ async function markAsUnread(account, messageIds) {
219
+ const gmail = await getGmailClient(account);
220
+ const results = [];
221
+
222
+ for (const id of messageIds) {
223
+ try {
224
+ await withRetry(() => gmail.users.messages.modify({
225
+ userId: 'me',
226
+ id: id,
227
+ requestBody: {
228
+ addLabelIds: ['UNREAD'],
229
+ },
230
+ }));
231
+ results.push({ id, success: true });
232
+ } catch (err) {
233
+ results.push({ id, success: false, error: err.message });
234
+ }
235
+ }
236
+
237
+ return results;
238
+ }
239
+
212
240
  /**
213
241
  * Archives emails by removing the INBOX label
214
242
  * @param {string} account - Account name
@@ -286,6 +314,359 @@ function groupEmailsBySender(emails) {
286
314
  return { groups: groupArray, totalCount: emails.length };
287
315
  }
288
316
 
317
+ /**
318
+ * Decodes base64url encoded content
319
+ * @param {string} str - Base64url encoded string
320
+ * @returns {string} Decoded UTF-8 string
321
+ */
322
+ function decodeBase64Url(str) {
323
+ if (!str) return '';
324
+ return Buffer.from(str, 'base64url').toString('utf8');
325
+ }
326
+
327
+ /**
328
+ * Extracts body content from a Gmail message payload
329
+ * Handles multipart messages recursively
330
+ * @param {Object} payload - Gmail message payload
331
+ * @param {Object} options - { preferHtml: boolean } - prefer HTML for link extraction
332
+ * @returns {{type: string, content: string}} Body content with mime type
333
+ */
334
+ function extractBody(payload, options = {}) {
335
+ const { preferHtml = false } = options;
336
+
337
+ // Simple case: body data directly in payload
338
+ if (payload.body && payload.body.data) {
339
+ return {
340
+ type: payload.mimeType,
341
+ content: decodeBase64Url(payload.body.data)
342
+ };
343
+ }
344
+
345
+ if (!payload.parts) {
346
+ return { type: 'text/plain', content: '' };
347
+ }
348
+
349
+ // Determine preference order based on options
350
+ const mimeOrder = preferHtml
351
+ ? ['text/html', 'text/plain']
352
+ : ['text/plain', 'text/html'];
353
+
354
+ for (const mimeType of mimeOrder) {
355
+ const part = payload.parts.find(p => p.mimeType === mimeType);
356
+ if (part && part.body && part.body.data) {
357
+ return {
358
+ type: mimeType,
359
+ content: decodeBase64Url(part.body.data)
360
+ };
361
+ }
362
+ }
363
+
364
+ // Recursive check for nested multipart (e.g., multipart/mixed containing multipart/alternative)
365
+ for (const part of payload.parts) {
366
+ if (part.parts) {
367
+ const found = extractBody(part, options);
368
+ if (found.content) {
369
+ return found;
370
+ }
371
+ }
372
+ }
373
+
374
+ return { type: 'text/plain', content: '' };
375
+ }
376
+
377
+ /**
378
+ * Validates URL scheme - filters out non-http(s) schemes
379
+ * @param {string} url - URL to validate
380
+ * @returns {boolean} True if URL should be included
381
+ */
382
+ function isValidUrl(url) {
383
+ if (!url) return false;
384
+ const lowerUrl = url.toLowerCase().trim();
385
+ // Only allow http and https
386
+ return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
387
+ }
388
+
389
+ /**
390
+ * Extracts links from email body content
391
+ * @param {string} body - Email body content
392
+ * @param {string} mimeType - 'text/html' or 'text/plain'
393
+ * @returns {Array<{url: string, text: string|null}>} Extracted links
394
+ */
395
+ function extractLinks(body, mimeType) {
396
+ if (!body) return [];
397
+
398
+ const links = [];
399
+ const seenUrls = new Set();
400
+
401
+ // For HTML, extract from anchor tags first (captures link text)
402
+ if (mimeType === 'text/html') {
403
+ // Match <a href="URL">Text</a> - handles attributes in any order
404
+ const hrefRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi;
405
+ let match;
406
+ while ((match = hrefRegex.exec(body)) !== null) {
407
+ const url = decodeHtmlEntities(match[1].trim());
408
+ const text = match[2].trim() || null;
409
+ if (!seenUrls.has(url) && isValidUrl(url)) {
410
+ seenUrls.add(url);
411
+ links.push({ url, text });
412
+ }
413
+ }
414
+ }
415
+
416
+ // Also extract plain URLs (works for both HTML and plain text)
417
+ // This catches URLs not in anchor tags
418
+ const urlRegex = /https?:\/\/[^\s<>"']+/gi;
419
+ let urlMatch;
420
+ while ((urlMatch = urlRegex.exec(body)) !== null) {
421
+ // Clean trailing punctuation that's likely not part of the URL
422
+ let url = urlMatch[0].replace(/[.,;:!?)>\]]+$/, '');
423
+ // Also handle HTML entity at end
424
+ url = url.replace(/&[a-z]+;?$/i, '');
425
+ // Decode HTML entities for consistency (important for HTML content)
426
+ url = decodeHtmlEntities(url);
427
+ if (!seenUrls.has(url) && isValidUrl(url)) {
428
+ seenUrls.add(url);
429
+ links.push({ url, text: null });
430
+ }
431
+ }
432
+
433
+ return links;
434
+ }
435
+
436
+ /**
437
+ * Decodes common HTML entities in URLs
438
+ * @param {string} str - String potentially containing HTML entities
439
+ * @returns {string} Decoded string
440
+ */
441
+ function decodeHtmlEntities(str) {
442
+ if (!str) return '';
443
+ return str
444
+ .replace(/&amp;/g, '&')
445
+ .replace(/&lt;/g, '<')
446
+ .replace(/&gt;/g, '>')
447
+ .replace(/&quot;/g, '"')
448
+ .replace(/&#39;/g, "'");
449
+ }
450
+
451
+ /**
452
+ * Gets full email content by ID
453
+ * @param {string} account - Account name
454
+ * @param {string} messageId - Message ID
455
+ * @param {Object} options - { preferHtml: boolean } - prefer HTML for link extraction
456
+ * @returns {Object|null} Email object with body or null if not found
457
+ */
458
+ async function getEmailContent(account, messageId, options = {}) {
459
+ try {
460
+ const gmail = await getGmailClient(account);
461
+ const detail = await withRetry(() => gmail.users.messages.get({
462
+ userId: 'me',
463
+ id: messageId,
464
+ format: 'full',
465
+ }));
466
+
467
+ const headers = detail.data.payload.headers;
468
+ const getHeader = (name) => {
469
+ const header = headers.find((h) => h.name.toLowerCase() === name.toLowerCase());
470
+ return header ? header.value : '';
471
+ };
472
+
473
+ const bodyData = extractBody(detail.data.payload, options);
474
+
475
+ return {
476
+ id: messageId,
477
+ threadId: detail.data.threadId,
478
+ labelIds: detail.data.labelIds || [],
479
+ account,
480
+ from: getHeader('From'),
481
+ to: getHeader('To'),
482
+ subject: getHeader('Subject'),
483
+ date: getHeader('Date'),
484
+ snippet: detail.data.snippet,
485
+ body: bodyData.content,
486
+ mimeType: bodyData.type
487
+ };
488
+ } catch (error) {
489
+ console.error(`Error fetching email content ${messageId}:`, error.message);
490
+ return null;
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Searches for emails using Gmail query syntax
496
+ * @param {string} account - Account name
497
+ * @param {string} query - Gmail search query (e.g. "is:unread from:google")
498
+ * @param {number} maxResults - Max results to return
499
+ * @returns {Array} List of email metadata objects
500
+ */
501
+ async function searchEmails(account, query, maxResults = 20) {
502
+ try {
503
+ const gmail = await getGmailClient(account);
504
+ const res = await withRetry(() => gmail.users.messages.list({
505
+ userId: 'me',
506
+ q: query,
507
+ maxResults,
508
+ }));
509
+
510
+ const messages = res.data.messages;
511
+ if (!messages || messages.length === 0) {
512
+ return [];
513
+ }
514
+
515
+ const emailPromises = messages.map(async (msg) => {
516
+ try {
517
+ const detail = await withRetry(() => gmail.users.messages.get({
518
+ userId: 'me',
519
+ id: msg.id,
520
+ format: 'metadata',
521
+ metadataHeaders: ['From', 'Subject', 'Date'],
522
+ }));
523
+
524
+ const headers = detail.data.payload.headers;
525
+ const getHeader = (name) => {
526
+ const header = headers.find((h) => h.name.toLowerCase() === name.toLowerCase());
527
+ return header ? header.value : '';
528
+ };
529
+
530
+ return {
531
+ id: msg.id,
532
+ threadId: detail.data.threadId,
533
+ labelIds: detail.data.labelIds || [],
534
+ account,
535
+ from: getHeader('From'),
536
+ subject: getHeader('Subject'),
537
+ snippet: detail.data.snippet,
538
+ date: getHeader('Date'),
539
+ };
540
+ } catch (_err) {
541
+ return null;
542
+ }
543
+ });
544
+
545
+ const results = await Promise.all(emailPromises);
546
+ return results.filter((email) => email !== null);
547
+ } catch (error) {
548
+ console.error(`Error searching emails for ${account}:`, error.message);
549
+ return [];
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Composes a raw RFC 2822 email message
555
+ * @param {Object} options - { to, subject, body, inReplyTo?, references? }
556
+ * @returns {string} Base64url encoded message
557
+ */
558
+ function composeMessage({ to, subject, body, inReplyTo, references }) {
559
+ const messageParts = [
560
+ `To: ${to}`,
561
+ `Subject: ${subject}`,
562
+ ];
563
+
564
+ if (inReplyTo) {
565
+ messageParts.push(`In-Reply-To: ${inReplyTo}`);
566
+ }
567
+ if (references) {
568
+ messageParts.push(`References: ${references}`);
569
+ }
570
+
571
+ messageParts.push(
572
+ 'Content-Type: text/plain; charset="UTF-8"',
573
+ 'MIME-Version: 1.0',
574
+ '',
575
+ body
576
+ );
577
+
578
+ return Buffer.from(messageParts.join('\n')).toString('base64url');
579
+ }
580
+
581
+ /**
582
+ * Sends an email
583
+ * @param {string} account - Account name
584
+ * @param {Object} options - { to, subject, body }
585
+ * @returns {Object} Result object with success, id, threadId, or error
586
+ */
587
+ async function sendEmail(account, { to, subject, body }) {
588
+ try {
589
+ const gmail = await getGmailClient(account);
590
+ const encodedMessage = composeMessage({ to, subject, body });
591
+
592
+ const res = await withRetry(() => gmail.users.messages.send({
593
+ userId: 'me',
594
+ requestBody: {
595
+ raw: encodedMessage
596
+ }
597
+ }));
598
+
599
+ return { success: true, id: res.data.id, threadId: res.data.threadId };
600
+ } catch (error) {
601
+ return { success: false, error: error.message };
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Reply to an email
607
+ * @param {string} account - Account name
608
+ * @param {string} messageId - ID of the message to reply to
609
+ * @param {string} body - Reply content
610
+ * @returns {Object} Result object with success, id, threadId, or error
611
+ */
612
+ async function replyToEmail(account, messageId, body) {
613
+ try {
614
+ const gmail = await getGmailClient(account);
615
+
616
+ // Get original message headers
617
+ const original = await withRetry(() => gmail.users.messages.get({
618
+ userId: 'me',
619
+ id: messageId,
620
+ format: 'metadata',
621
+ metadataHeaders: ['Subject', 'Message-ID', 'References', 'Reply-To', 'From']
622
+ }));
623
+
624
+ const headers = original.data.payload.headers;
625
+ const getHeader = (name) => {
626
+ const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
627
+ return header ? header.value : '';
628
+ };
629
+
630
+ const originalSubject = getHeader('Subject');
631
+ const originalMessageId = getHeader('Message-ID');
632
+ const originalReferences = getHeader('References');
633
+ // Prefer Reply-To header, fallback to From
634
+ const replyTo = getHeader('Reply-To');
635
+ const originalFrom = getHeader('From');
636
+ const to = replyTo || originalFrom;
637
+
638
+ // Add Re: prefix if not present
639
+ const subject = originalSubject.toLowerCase().startsWith('re:')
640
+ ? originalSubject
641
+ : `Re: ${originalSubject}`;
642
+
643
+ // Build references chain
644
+ const references = originalReferences
645
+ ? `${originalReferences} ${originalMessageId}`
646
+ : originalMessageId;
647
+
648
+ const encodedMessage = composeMessage({
649
+ to,
650
+ subject,
651
+ body,
652
+ inReplyTo: originalMessageId,
653
+ references
654
+ });
655
+
656
+ const res = await withRetry(() => gmail.users.messages.send({
657
+ userId: 'me',
658
+ requestBody: {
659
+ raw: encodedMessage,
660
+ threadId: original.data.threadId
661
+ }
662
+ }));
663
+
664
+ return { success: true, id: res.data.id, threadId: res.data.threadId };
665
+ } catch (error) {
666
+ return { success: false, error: error.message };
667
+ }
668
+ }
669
+
289
670
  module.exports = {
290
671
  getUnreadEmails,
291
672
  getEmailCount,
@@ -293,7 +674,19 @@ module.exports = {
293
674
  getEmailById,
294
675
  untrashEmails,
295
676
  markAsRead,
677
+ markAsUnread,
296
678
  archiveEmails,
297
679
  extractSenderDomain,
298
680
  groupEmailsBySender,
681
+ getEmailContent,
682
+ searchEmails,
683
+ sendEmail,
684
+ replyToEmail,
685
+ extractLinks,
686
+ // Exposed for testing
687
+ extractBody,
688
+ decodeBase64Url,
689
+ composeMessage,
690
+ isValidUrl,
691
+ decodeHtmlEntities,
299
692
  };
@@ -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
+ };