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.
- 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/src/skill-installer.js +7 -2
- 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
|
@@ -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 &', () => {
|
|
53
|
+
expect(decodeHtmlEntities('a&b')).toBe('a&b');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should decode < and >', () => {
|
|
57
|
+
expect(decodeHtmlEntities('<tag>')).toBe('<tag>');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should decode "', () => {
|
|
61
|
+
expect(decodeHtmlEntities('"hello"')).toBe('"hello"');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should decode '', () => {
|
|
65
|
+
expect(decodeHtmlEntities('it'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&b<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&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
|
+
});
|