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.
@@ -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
+ };
@@ -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
+ });