inboxd 1.0.11 → 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.
- package/.claude/skills/inbox-assistant/SKILL.md +99 -0
- package/CLAUDE.md +15 -0
- package/package.json +1 -1
- package/src/cli.js +364 -3
- package/src/gmail-monitor.js +364 -0
- package/src/sent-log.js +87 -0
- package/tests/gmail-monitor-patterns.test.js +232 -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
|
@@ -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(/&/g, '&')
|
|
417
|
+
.replace(/</g, '<')
|
|
418
|
+
.replace(/>/g, '>')
|
|
419
|
+
.replace(/"/g, '"')
|
|
420
|
+
.replace(/'/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
|
};
|
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
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Import the actual exported helper functions for testing
|
|
4
|
+
// These are pure functions that don't require mocking
|
|
5
|
+
const { extractBody, decodeBase64Url, composeMessage } = require('../src/gmail-monitor');
|
|
6
|
+
|
|
7
|
+
describe('Gmail Monitor New Features', () => {
|
|
8
|
+
|
|
9
|
+
describe('decodeBase64Url', () => {
|
|
10
|
+
it('should decode base64url encoded string', () => {
|
|
11
|
+
const encoded = Buffer.from('Hello world').toString('base64url');
|
|
12
|
+
expect(decodeBase64Url(encoded)).toBe('Hello world');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should return empty string for empty input', () => {
|
|
16
|
+
expect(decodeBase64Url('')).toBe('');
|
|
17
|
+
expect(decodeBase64Url(null)).toBe('');
|
|
18
|
+
expect(decodeBase64Url(undefined)).toBe('');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should handle unicode content', () => {
|
|
22
|
+
const encoded = Buffer.from('Hello 世界 🌍').toString('base64url');
|
|
23
|
+
expect(decodeBase64Url(encoded)).toBe('Hello 世界 🌍');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('extractBody', () => {
|
|
28
|
+
it('should extract body from simple text/plain payload', () => {
|
|
29
|
+
const payload = {
|
|
30
|
+
mimeType: 'text/plain',
|
|
31
|
+
body: {
|
|
32
|
+
data: Buffer.from('Hello world').toString('base64url')
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const result = extractBody(payload);
|
|
36
|
+
expect(result.content).toBe('Hello world');
|
|
37
|
+
expect(result.type).toBe('text/plain');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should prefer text/plain in multipart/alternative', () => {
|
|
41
|
+
const payload = {
|
|
42
|
+
mimeType: 'multipart/alternative',
|
|
43
|
+
parts: [
|
|
44
|
+
{
|
|
45
|
+
mimeType: 'text/html',
|
|
46
|
+
body: { data: Buffer.from('<b>HTML</b>').toString('base64url') }
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
mimeType: 'text/plain',
|
|
50
|
+
body: { data: Buffer.from('Plain text').toString('base64url') }
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
const result = extractBody(payload);
|
|
55
|
+
expect(result.content).toBe('Plain text');
|
|
56
|
+
expect(result.type).toBe('text/plain');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should fallback to text/html if no plain text', () => {
|
|
60
|
+
const payload = {
|
|
61
|
+
mimeType: 'multipart/alternative',
|
|
62
|
+
parts: [
|
|
63
|
+
{
|
|
64
|
+
mimeType: 'text/html',
|
|
65
|
+
body: { data: Buffer.from('<b>HTML only</b>').toString('base64url') }
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
const result = extractBody(payload);
|
|
70
|
+
expect(result.content).toBe('<b>HTML only</b>');
|
|
71
|
+
expect(result.type).toBe('text/html');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should handle nested multipart (multipart/mixed with multipart/alternative)', () => {
|
|
75
|
+
const payload = {
|
|
76
|
+
mimeType: 'multipart/mixed',
|
|
77
|
+
parts: [
|
|
78
|
+
{
|
|
79
|
+
mimeType: 'multipart/alternative',
|
|
80
|
+
parts: [
|
|
81
|
+
{
|
|
82
|
+
mimeType: 'text/plain',
|
|
83
|
+
body: { data: Buffer.from('Nested plain').toString('base64url') }
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
mimeType: 'application/pdf',
|
|
89
|
+
filename: 'attachment.pdf',
|
|
90
|
+
body: { attachmentId: 'xyz' }
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
};
|
|
94
|
+
const result = extractBody(payload);
|
|
95
|
+
expect(result.content).toBe('Nested plain');
|
|
96
|
+
expect(result.type).toBe('text/plain');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should return empty content for payload with no body data', () => {
|
|
100
|
+
const payload = {
|
|
101
|
+
mimeType: 'text/plain'
|
|
102
|
+
// no body
|
|
103
|
+
};
|
|
104
|
+
const result = extractBody(payload);
|
|
105
|
+
expect(result.content).toBe('');
|
|
106
|
+
expect(result.type).toBe('text/plain');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should handle parts without body data', () => {
|
|
110
|
+
const payload = {
|
|
111
|
+
mimeType: 'multipart/mixed',
|
|
112
|
+
parts: [
|
|
113
|
+
{
|
|
114
|
+
mimeType: 'text/plain'
|
|
115
|
+
// no body data
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
};
|
|
119
|
+
const result = extractBody(payload);
|
|
120
|
+
expect(result.content).toBe('');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('composeMessage', () => {
|
|
125
|
+
it('should compose a simple email message', () => {
|
|
126
|
+
const encoded = composeMessage({
|
|
127
|
+
to: 'test@example.com',
|
|
128
|
+
subject: 'Test Subject',
|
|
129
|
+
body: 'Hello Body'
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const decoded = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
133
|
+
|
|
134
|
+
expect(decoded).toContain('To: test@example.com');
|
|
135
|
+
expect(decoded).toContain('Subject: Test Subject');
|
|
136
|
+
expect(decoded).toContain('Content-Type: text/plain; charset="UTF-8"');
|
|
137
|
+
expect(decoded).toContain('MIME-Version: 1.0');
|
|
138
|
+
expect(decoded).toContain('Hello Body');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should include In-Reply-To and References headers for replies', () => {
|
|
142
|
+
const encoded = composeMessage({
|
|
143
|
+
to: 'sender@example.com',
|
|
144
|
+
subject: 'Re: Original Subject',
|
|
145
|
+
body: 'My reply',
|
|
146
|
+
inReplyTo: '<msg123@example.com>',
|
|
147
|
+
references: '<ref1@example.com> <msg123@example.com>'
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const decoded = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
151
|
+
|
|
152
|
+
expect(decoded).toContain('In-Reply-To: <msg123@example.com>');
|
|
153
|
+
expect(decoded).toContain('References: <ref1@example.com> <msg123@example.com>');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should not include reply headers when not provided', () => {
|
|
157
|
+
const encoded = composeMessage({
|
|
158
|
+
to: 'test@example.com',
|
|
159
|
+
subject: 'New Email',
|
|
160
|
+
body: 'Body'
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const decoded = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
164
|
+
|
|
165
|
+
expect(decoded).not.toContain('In-Reply-To:');
|
|
166
|
+
expect(decoded).not.toContain('References:');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should handle special characters in subject and body', () => {
|
|
170
|
+
const encoded = composeMessage({
|
|
171
|
+
to: 'test@example.com',
|
|
172
|
+
subject: 'Test: Special chars & symbols!',
|
|
173
|
+
body: 'Line 1\nLine 2\n\nParagraph with émojis 🎉'
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const decoded = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
177
|
+
|
|
178
|
+
expect(decoded).toContain('Subject: Test: Special chars & symbols!');
|
|
179
|
+
expect(decoded).toContain('émojis 🎉');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('Reply Subject Logic', () => {
|
|
184
|
+
// Test the Re: prefix logic used in replyToEmail
|
|
185
|
+
function buildReplySubject(originalSubject) {
|
|
186
|
+
return originalSubject.toLowerCase().startsWith('re:')
|
|
187
|
+
? originalSubject
|
|
188
|
+
: `Re: ${originalSubject}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it('should add Re: prefix to new subject', () => {
|
|
192
|
+
expect(buildReplySubject('Hello')).toBe('Re: Hello');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should not double Re: prefix', () => {
|
|
196
|
+
expect(buildReplySubject('Re: Hello')).toBe('Re: Hello');
|
|
197
|
+
expect(buildReplySubject('RE: Hello')).toBe('RE: Hello');
|
|
198
|
+
expect(buildReplySubject('re: Hello')).toBe('re: Hello');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should handle edge cases', () => {
|
|
202
|
+
expect(buildReplySubject('')).toBe('Re: ');
|
|
203
|
+
expect(buildReplySubject('Re:')).toBe('Re:');
|
|
204
|
+
expect(buildReplySubject('Re: Re: Multiple')).toBe('Re: Re: Multiple');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('References Chain Logic', () => {
|
|
209
|
+
// Test the references building logic used in replyToEmail
|
|
210
|
+
function buildReferences(originalReferences, originalMessageId) {
|
|
211
|
+
return originalReferences
|
|
212
|
+
? `${originalReferences} ${originalMessageId}`
|
|
213
|
+
: originalMessageId;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it('should use message ID when no existing references', () => {
|
|
217
|
+
expect(buildReferences('', '<msg1@example.com>')).toBe('<msg1@example.com>');
|
|
218
|
+
expect(buildReferences(null, '<msg1@example.com>')).toBe('<msg1@example.com>');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should append message ID to existing references', () => {
|
|
222
|
+
expect(buildReferences('<ref1@example.com>', '<msg1@example.com>'))
|
|
223
|
+
.toBe('<ref1@example.com> <msg1@example.com>');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should build long reference chains', () => {
|
|
227
|
+
const refs = '<ref1@ex.com> <ref2@ex.com>';
|
|
228
|
+
expect(buildReferences(refs, '<msg1@ex.com>'))
|
|
229
|
+
.toBe('<ref1@ex.com> <ref2@ex.com> <msg1@ex.com>');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|