inboxd 1.0.11 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/inbox-assistant/SKILL.md +239 -4
- package/CLAUDE.md +35 -4
- package/package.json +1 -1
- package/src/cli.js +422 -3
- package/src/gmail-monitor.js +393 -0
- package/src/sent-log.js +87 -0
- package/tests/gmail-monitor-patterns.test.js +232 -0
- package/tests/gmail-monitor.test.js +293 -0
- package/tests/link-extraction.test.js +249 -0
- package/tests/older-than.test.js +127 -0
- package/tests/sent-log.test.js +142 -0
package/src/gmail-monitor.js
CHANGED
|
@@ -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(/&/g, '&')
|
|
445
|
+
.replace(/</g, '<')
|
|
446
|
+
.replace(/>/g, '>')
|
|
447
|
+
.replace(/"/g, '"')
|
|
448
|
+
.replace(/'/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
|
};
|
package/src/sent-log.js
ADDED
|
@@ -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
|
+
};
|