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.
@@ -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
+ });
@@ -0,0 +1,249 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ const { extractLinks, isValidUrl, decodeHtmlEntities, extractBody } = require('../src/gmail-monitor');
4
+
5
+ describe('Link Extraction', () => {
6
+
7
+ describe('isValidUrl', () => {
8
+ it('should accept http URLs', () => {
9
+ expect(isValidUrl('http://example.com')).toBe(true);
10
+ });
11
+
12
+ it('should accept https URLs', () => {
13
+ expect(isValidUrl('https://example.com')).toBe(true);
14
+ expect(isValidUrl('https://example.com/path?query=1')).toBe(true);
15
+ });
16
+
17
+ it('should reject javascript: URLs', () => {
18
+ expect(isValidUrl('javascript:void(0)')).toBe(false);
19
+ expect(isValidUrl('javascript:alert("hi")')).toBe(false);
20
+ });
21
+
22
+ it('should reject data: URLs', () => {
23
+ expect(isValidUrl('data:text/html,<h1>Hello</h1>')).toBe(false);
24
+ });
25
+
26
+ it('should reject mailto: URLs', () => {
27
+ expect(isValidUrl('mailto:test@example.com')).toBe(false);
28
+ });
29
+
30
+ it('should reject tel: URLs', () => {
31
+ expect(isValidUrl('tel:+1234567890')).toBe(false);
32
+ });
33
+
34
+ it('should reject file: URLs', () => {
35
+ expect(isValidUrl('file:///etc/passwd')).toBe(false);
36
+ });
37
+
38
+ it('should handle null/undefined/empty', () => {
39
+ expect(isValidUrl(null)).toBe(false);
40
+ expect(isValidUrl(undefined)).toBe(false);
41
+ expect(isValidUrl('')).toBe(false);
42
+ });
43
+
44
+ it('should be case insensitive', () => {
45
+ expect(isValidUrl('HTTP://EXAMPLE.COM')).toBe(true);
46
+ expect(isValidUrl('HTTPS://EXAMPLE.COM')).toBe(true);
47
+ expect(isValidUrl('JAVASCRIPT:void(0)')).toBe(false);
48
+ });
49
+ });
50
+
51
+ describe('decodeHtmlEntities', () => {
52
+ it('should decode &amp;', () => {
53
+ expect(decodeHtmlEntities('a&amp;b')).toBe('a&b');
54
+ });
55
+
56
+ it('should decode &lt; and &gt;', () => {
57
+ expect(decodeHtmlEntities('&lt;tag&gt;')).toBe('<tag>');
58
+ });
59
+
60
+ it('should decode &quot;', () => {
61
+ expect(decodeHtmlEntities('&quot;hello&quot;')).toBe('"hello"');
62
+ });
63
+
64
+ it('should decode &#39;', () => {
65
+ expect(decodeHtmlEntities('it&#39;s')).toBe("it's");
66
+ });
67
+
68
+ it('should handle empty/null', () => {
69
+ expect(decodeHtmlEntities('')).toBe('');
70
+ expect(decodeHtmlEntities(null)).toBe('');
71
+ expect(decodeHtmlEntities(undefined)).toBe('');
72
+ });
73
+
74
+ it('should decode multiple entities', () => {
75
+ expect(decodeHtmlEntities('a&amp;b&lt;c')).toBe('a&b<c');
76
+ });
77
+ });
78
+
79
+ describe('extractLinks from HTML', () => {
80
+ it('should extract href from anchor tags', () => {
81
+ const html = '<a href="https://example.com">Click here</a>';
82
+ const links = extractLinks(html, 'text/html');
83
+ expect(links).toHaveLength(1);
84
+ expect(links[0].url).toBe('https://example.com');
85
+ expect(links[0].text).toBe('Click here');
86
+ });
87
+
88
+ it('should extract multiple links', () => {
89
+ const html = `
90
+ <a href="https://example.com/1">Link 1</a>
91
+ <a href="https://example.com/2">Link 2</a>
92
+ `;
93
+ const links = extractLinks(html, 'text/html');
94
+ expect(links).toHaveLength(2);
95
+ expect(links[0].url).toBe('https://example.com/1');
96
+ expect(links[1].url).toBe('https://example.com/2');
97
+ });
98
+
99
+ it('should deduplicate URLs', () => {
100
+ const html = `
101
+ <a href="https://example.com">First</a>
102
+ <a href="https://example.com">Second</a>
103
+ `;
104
+ const links = extractLinks(html, 'text/html');
105
+ expect(links).toHaveLength(1);
106
+ expect(links[0].text).toBe('First'); // First occurrence wins
107
+ });
108
+
109
+ it('should filter out javascript: URLs', () => {
110
+ const html = `
111
+ <a href="javascript:alert('hi')">JS</a>
112
+ <a href="https://real.com">Real</a>
113
+ `;
114
+ const links = extractLinks(html, 'text/html');
115
+ expect(links).toHaveLength(1);
116
+ expect(links[0].url).toBe('https://real.com');
117
+ });
118
+
119
+ it('should filter out mailto: URLs', () => {
120
+ const html = `
121
+ <a href="mailto:test@example.com">Email</a>
122
+ <a href="https://real.com">Real</a>
123
+ `;
124
+ const links = extractLinks(html, 'text/html');
125
+ expect(links).toHaveLength(1);
126
+ expect(links[0].url).toBe('https://real.com');
127
+ });
128
+
129
+ it('should decode HTML entities in URLs', () => {
130
+ const html = '<a href="https://example.com?a=1&amp;b=2">Link</a>';
131
+ const links = extractLinks(html, 'text/html');
132
+ expect(links).toHaveLength(1);
133
+ expect(links[0].url).toBe('https://example.com?a=1&b=2');
134
+ });
135
+
136
+ it('should handle links with extra attributes', () => {
137
+ const html = '<a class="btn" href="https://example.com" target="_blank">Link</a>';
138
+ const links = extractLinks(html, 'text/html');
139
+ expect(links).toHaveLength(1);
140
+ expect(links[0].url).toBe('https://example.com');
141
+ });
142
+
143
+ it('should also extract plain URLs not in anchor tags', () => {
144
+ const html = '<p>Visit https://plain-url.com for more info</p>';
145
+ const links = extractLinks(html, 'text/html');
146
+ expect(links).toHaveLength(1);
147
+ expect(links[0].url).toBe('https://plain-url.com');
148
+ expect(links[0].text).toBe(null);
149
+ });
150
+ });
151
+
152
+ describe('extractLinks from plain text', () => {
153
+ it('should extract URLs from plain text', () => {
154
+ const text = 'Check out https://example.com for more info';
155
+ const links = extractLinks(text, 'text/plain');
156
+ expect(links).toHaveLength(1);
157
+ expect(links[0].url).toBe('https://example.com');
158
+ expect(links[0].text).toBe(null);
159
+ });
160
+
161
+ it('should handle URLs with trailing punctuation', () => {
162
+ const text = 'Visit https://example.com, or https://other.com.';
163
+ const links = extractLinks(text, 'text/plain');
164
+ expect(links).toHaveLength(2);
165
+ expect(links[0].url).toBe('https://example.com');
166
+ expect(links[1].url).toBe('https://other.com');
167
+ });
168
+
169
+ it('should handle URLs with query strings', () => {
170
+ const text = 'Link: https://example.com/path?foo=bar&baz=qux';
171
+ const links = extractLinks(text, 'text/plain');
172
+ expect(links).toHaveLength(1);
173
+ expect(links[0].url).toBe('https://example.com/path?foo=bar&baz=qux');
174
+ });
175
+
176
+ it('should handle multiple URLs on same line', () => {
177
+ const text = 'See https://one.com and https://two.com';
178
+ const links = extractLinks(text, 'text/plain');
179
+ expect(links).toHaveLength(2);
180
+ });
181
+
182
+ it('should return empty array for no links', () => {
183
+ const text = 'No links here, just plain text';
184
+ const links = extractLinks(text, 'text/plain');
185
+ expect(links).toHaveLength(0);
186
+ });
187
+
188
+ it('should handle empty body', () => {
189
+ expect(extractLinks('', 'text/plain')).toHaveLength(0);
190
+ expect(extractLinks(null, 'text/plain')).toHaveLength(0);
191
+ expect(extractLinks(undefined, 'text/plain')).toHaveLength(0);
192
+ });
193
+ });
194
+
195
+ describe('extractBody with preferHtml option', () => {
196
+ it('should prefer text/plain by default', () => {
197
+ const payload = {
198
+ mimeType: 'multipart/alternative',
199
+ parts: [
200
+ {
201
+ mimeType: 'text/html',
202
+ body: { data: Buffer.from('<b>HTML</b>').toString('base64url') }
203
+ },
204
+ {
205
+ mimeType: 'text/plain',
206
+ body: { data: Buffer.from('Plain text').toString('base64url') }
207
+ }
208
+ ]
209
+ };
210
+ const result = extractBody(payload);
211
+ expect(result.content).toBe('Plain text');
212
+ expect(result.type).toBe('text/plain');
213
+ });
214
+
215
+ it('should prefer text/html when preferHtml is true', () => {
216
+ const payload = {
217
+ mimeType: 'multipart/alternative',
218
+ parts: [
219
+ {
220
+ mimeType: 'text/html',
221
+ body: { data: Buffer.from('<b>HTML</b>').toString('base64url') }
222
+ },
223
+ {
224
+ mimeType: 'text/plain',
225
+ body: { data: Buffer.from('Plain text').toString('base64url') }
226
+ }
227
+ ]
228
+ };
229
+ const result = extractBody(payload, { preferHtml: true });
230
+ expect(result.content).toBe('<b>HTML</b>');
231
+ expect(result.type).toBe('text/html');
232
+ });
233
+
234
+ it('should fallback to text/plain if no HTML available', () => {
235
+ const payload = {
236
+ mimeType: 'multipart/alternative',
237
+ parts: [
238
+ {
239
+ mimeType: 'text/plain',
240
+ body: { data: Buffer.from('Plain text').toString('base64url') }
241
+ }
242
+ ]
243
+ };
244
+ const result = extractBody(payload, { preferHtml: true });
245
+ expect(result.content).toBe('Plain text');
246
+ expect(result.type).toBe('text/plain');
247
+ });
248
+ });
249
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ // Test the older-than parsing logic used in inbox analyze command
4
+ // We test the logic patterns directly since the function is not exported
5
+
6
+ describe('Older Than Duration Parsing', () => {
7
+ /**
8
+ * Parsing function matching the implementation in cli.js
9
+ * Gmail only supports days (d) for older_than, so we convert weeks/months to days
10
+ */
11
+ function parseOlderThanDuration(duration) {
12
+ const match = duration.match(/^(\d+)([dwm])$/i);
13
+ if (!match) {
14
+ return null;
15
+ }
16
+
17
+ const value = parseInt(match[1], 10);
18
+ const unit = match[2].toLowerCase();
19
+
20
+ switch (unit) {
21
+ case 'd': // days
22
+ return `${value}d`;
23
+ case 'w': // weeks -> days
24
+ return `${value * 7}d`;
25
+ case 'm': // months (approximate as 30 days)
26
+ return `${value * 30}d`;
27
+ default:
28
+ return null;
29
+ }
30
+ }
31
+
32
+ describe('Days parsing', () => {
33
+ it('should parse days correctly', () => {
34
+ expect(parseOlderThanDuration('30d')).toBe('30d');
35
+ expect(parseOlderThanDuration('7d')).toBe('7d');
36
+ expect(parseOlderThanDuration('1d')).toBe('1d');
37
+ expect(parseOlderThanDuration('90d')).toBe('90d');
38
+ });
39
+ });
40
+
41
+ describe('Weeks conversion', () => {
42
+ it('should convert weeks to days', () => {
43
+ expect(parseOlderThanDuration('2w')).toBe('14d');
44
+ expect(parseOlderThanDuration('1w')).toBe('7d');
45
+ expect(parseOlderThanDuration('4w')).toBe('28d');
46
+ });
47
+ });
48
+
49
+ describe('Months conversion', () => {
50
+ it('should convert months to days (approx 30)', () => {
51
+ expect(parseOlderThanDuration('1m')).toBe('30d');
52
+ expect(parseOlderThanDuration('3m')).toBe('90d');
53
+ expect(parseOlderThanDuration('6m')).toBe('180d');
54
+ });
55
+ });
56
+
57
+ describe('Case insensitivity', () => {
58
+ it('should handle uppercase units', () => {
59
+ expect(parseOlderThanDuration('30D')).toBe('30d');
60
+ expect(parseOlderThanDuration('2W')).toBe('14d');
61
+ expect(parseOlderThanDuration('1M')).toBe('30d');
62
+ });
63
+ });
64
+
65
+ describe('Invalid formats', () => {
66
+ it('should return null for number without unit', () => {
67
+ expect(parseOlderThanDuration('30')).toBe(null);
68
+ });
69
+
70
+ it('should return null for unit without number', () => {
71
+ expect(parseOlderThanDuration('d')).toBe(null);
72
+ expect(parseOlderThanDuration('days')).toBe(null);
73
+ });
74
+
75
+ it('should return null for unsupported units', () => {
76
+ expect(parseOlderThanDuration('30h')).toBe(null); // hours not supported
77
+ expect(parseOlderThanDuration('30s')).toBe(null); // seconds not supported
78
+ expect(parseOlderThanDuration('30y')).toBe(null); // years not supported
79
+ });
80
+
81
+ it('should return null for empty/invalid strings', () => {
82
+ expect(parseOlderThanDuration('')).toBe(null);
83
+ expect(parseOlderThanDuration('abc')).toBe(null);
84
+ expect(parseOlderThanDuration('30 d')).toBe(null); // space not allowed
85
+ });
86
+ });
87
+ });
88
+
89
+ describe('Gmail Query Building for Older Than', () => {
90
+ /**
91
+ * Helper to simulate query building logic from cli.js analyze command
92
+ */
93
+ function buildQuery(includeRead, olderThanDays) {
94
+ const baseQuery = includeRead ? '' : 'is:unread';
95
+ if (!olderThanDays) {
96
+ return baseQuery;
97
+ }
98
+ const olderThanQuery = `older_than:${olderThanDays}`;
99
+ return baseQuery ? `${baseQuery} ${olderThanQuery}` : olderThanQuery;
100
+ }
101
+
102
+ it('should build correct query for unread + older_than', () => {
103
+ const query = buildQuery(false, '30d');
104
+ expect(query).toBe('is:unread older_than:30d');
105
+ });
106
+
107
+ it('should build correct query for all + older_than', () => {
108
+ const query = buildQuery(true, '30d');
109
+ expect(query).toBe('older_than:30d');
110
+ });
111
+
112
+ it('should build correct query for unread only (no older_than)', () => {
113
+ const query = buildQuery(false, null);
114
+ expect(query).toBe('is:unread');
115
+ });
116
+
117
+ it('should build correct query for all (no older_than)', () => {
118
+ const query = buildQuery(true, null);
119
+ expect(query).toBe('');
120
+ });
121
+
122
+ it('should work with week/month converted to days', () => {
123
+ // 2 weeks = 14 days
124
+ const query = buildQuery(false, '14d');
125
+ expect(query).toBe('is:unread older_than:14d');
126
+ });
127
+ });