inboxd 1.0.11 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });
@@ -132,4 +132,297 @@ describe('Gmail Monitor Logic', () => {
132
132
  }
133
133
  });
134
134
  });
135
+
136
+ describe('Mark As Read Logic', () => {
137
+ it('should mark email as read by removing UNREAD label', async () => {
138
+ const mockGmail = {
139
+ users: {
140
+ messages: {
141
+ modify: vi.fn().mockResolvedValue({ data: { id: 'msg123', labelIds: ['INBOX'] } })
142
+ }
143
+ }
144
+ };
145
+
146
+ const messageIds = ['msg123'];
147
+ const results = [];
148
+
149
+ for (const id of messageIds) {
150
+ try {
151
+ await mockGmail.users.messages.modify({
152
+ userId: 'me',
153
+ id: id,
154
+ requestBody: { removeLabelIds: ['UNREAD'] }
155
+ });
156
+ results.push({ id, success: true });
157
+ } catch (err) {
158
+ results.push({ id, success: false, error: err.message });
159
+ }
160
+ }
161
+
162
+ expect(results).toHaveLength(1);
163
+ expect(results[0].success).toBe(true);
164
+ expect(mockGmail.users.messages.modify).toHaveBeenCalledWith({
165
+ userId: 'me',
166
+ id: 'msg123',
167
+ requestBody: { removeLabelIds: ['UNREAD'] }
168
+ });
169
+ });
170
+
171
+ it('should handle errors during mark as read', async () => {
172
+ const mockGmail = {
173
+ users: {
174
+ messages: {
175
+ modify: vi.fn().mockRejectedValue(new Error('API Error'))
176
+ }
177
+ }
178
+ };
179
+
180
+ const messageIds = ['msg123'];
181
+ const results = [];
182
+
183
+ for (const id of messageIds) {
184
+ try {
185
+ await mockGmail.users.messages.modify({
186
+ userId: 'me',
187
+ id: id,
188
+ requestBody: { removeLabelIds: ['UNREAD'] }
189
+ });
190
+ results.push({ id, success: true });
191
+ } catch (err) {
192
+ results.push({ id, success: false, error: err.message });
193
+ }
194
+ }
195
+
196
+ expect(results).toHaveLength(1);
197
+ expect(results[0].success).toBe(false);
198
+ expect(results[0].error).toBe('API Error');
199
+ });
200
+
201
+ it('should handle multiple message IDs for mark as read', async () => {
202
+ const mockModify = vi.fn()
203
+ .mockResolvedValueOnce({ data: { id: 'msg1' } })
204
+ .mockResolvedValueOnce({ data: { id: 'msg2' } })
205
+ .mockRejectedValueOnce(new Error('Not found'));
206
+
207
+ const mockGmail = {
208
+ users: { messages: { modify: mockModify } }
209
+ };
210
+
211
+ const messageIds = ['msg1', 'msg2', 'msg3'];
212
+ const results = [];
213
+
214
+ for (const id of messageIds) {
215
+ try {
216
+ await mockGmail.users.messages.modify({
217
+ userId: 'me',
218
+ id: id,
219
+ requestBody: { removeLabelIds: ['UNREAD'] }
220
+ });
221
+ results.push({ id, success: true });
222
+ } catch (err) {
223
+ results.push({ id, success: false, error: err.message });
224
+ }
225
+ }
226
+
227
+ expect(results).toHaveLength(3);
228
+ expect(results[0]).toEqual({ id: 'msg1', success: true });
229
+ expect(results[1]).toEqual({ id: 'msg2', success: true });
230
+ expect(results[2]).toEqual({ id: 'msg3', success: false, error: 'Not found' });
231
+ });
232
+ });
233
+
234
+ describe('Mark As Unread Logic', () => {
235
+ it('should mark email as unread by adding UNREAD label', async () => {
236
+ const mockGmail = {
237
+ users: {
238
+ messages: {
239
+ modify: vi.fn().mockResolvedValue({ data: { id: 'msg123', labelIds: ['INBOX', 'UNREAD'] } })
240
+ }
241
+ }
242
+ };
243
+
244
+ const messageIds = ['msg123'];
245
+ const results = [];
246
+
247
+ for (const id of messageIds) {
248
+ try {
249
+ await mockGmail.users.messages.modify({
250
+ userId: 'me',
251
+ id: id,
252
+ requestBody: { addLabelIds: ['UNREAD'] }
253
+ });
254
+ results.push({ id, success: true });
255
+ } catch (err) {
256
+ results.push({ id, success: false, error: err.message });
257
+ }
258
+ }
259
+
260
+ expect(results).toHaveLength(1);
261
+ expect(results[0].success).toBe(true);
262
+ expect(mockGmail.users.messages.modify).toHaveBeenCalledWith({
263
+ userId: 'me',
264
+ id: 'msg123',
265
+ requestBody: { addLabelIds: ['UNREAD'] }
266
+ });
267
+ });
268
+
269
+ it('should handle errors during mark as unread', async () => {
270
+ const mockGmail = {
271
+ users: {
272
+ messages: {
273
+ modify: vi.fn().mockRejectedValue(new Error('Permission denied'))
274
+ }
275
+ }
276
+ };
277
+
278
+ const messageIds = ['msg123'];
279
+ const results = [];
280
+
281
+ for (const id of messageIds) {
282
+ try {
283
+ await mockGmail.users.messages.modify({
284
+ userId: 'me',
285
+ id: id,
286
+ requestBody: { addLabelIds: ['UNREAD'] }
287
+ });
288
+ results.push({ id, success: true });
289
+ } catch (err) {
290
+ results.push({ id, success: false, error: err.message });
291
+ }
292
+ }
293
+
294
+ expect(results).toHaveLength(1);
295
+ expect(results[0].success).toBe(false);
296
+ expect(results[0].error).toBe('Permission denied');
297
+ });
298
+
299
+ it('should handle multiple message IDs for mark as unread', async () => {
300
+ const mockModify = vi.fn()
301
+ .mockResolvedValueOnce({ data: { id: 'msg1' } })
302
+ .mockRejectedValueOnce(new Error('Not found'))
303
+ .mockResolvedValueOnce({ data: { id: 'msg3' } });
304
+
305
+ const mockGmail = {
306
+ users: { messages: { modify: mockModify } }
307
+ };
308
+
309
+ const messageIds = ['msg1', 'msg2', 'msg3'];
310
+ const results = [];
311
+
312
+ for (const id of messageIds) {
313
+ try {
314
+ await mockGmail.users.messages.modify({
315
+ userId: 'me',
316
+ id: id,
317
+ requestBody: { addLabelIds: ['UNREAD'] }
318
+ });
319
+ results.push({ id, success: true });
320
+ } catch (err) {
321
+ results.push({ id, success: false, error: err.message });
322
+ }
323
+ }
324
+
325
+ expect(results).toHaveLength(3);
326
+ expect(results[0]).toEqual({ id: 'msg1', success: true });
327
+ expect(results[1]).toEqual({ id: 'msg2', success: false, error: 'Not found' });
328
+ expect(results[2]).toEqual({ id: 'msg3', success: true });
329
+ });
330
+ });
331
+
332
+ describe('Archive Emails Logic', () => {
333
+ it('should archive email by removing INBOX label', async () => {
334
+ const mockGmail = {
335
+ users: {
336
+ messages: {
337
+ modify: vi.fn().mockResolvedValue({ data: { id: 'msg123', labelIds: ['CATEGORY_UPDATES'] } })
338
+ }
339
+ }
340
+ };
341
+
342
+ const messageIds = ['msg123'];
343
+ const results = [];
344
+
345
+ for (const id of messageIds) {
346
+ try {
347
+ await mockGmail.users.messages.modify({
348
+ userId: 'me',
349
+ id: id,
350
+ requestBody: { removeLabelIds: ['INBOX'] }
351
+ });
352
+ results.push({ id, success: true });
353
+ } catch (err) {
354
+ results.push({ id, success: false, error: err.message });
355
+ }
356
+ }
357
+
358
+ expect(results).toHaveLength(1);
359
+ expect(results[0].success).toBe(true);
360
+ expect(mockGmail.users.messages.modify).toHaveBeenCalledWith({
361
+ userId: 'me',
362
+ id: 'msg123',
363
+ requestBody: { removeLabelIds: ['INBOX'] }
364
+ });
365
+ });
366
+
367
+ it('should handle errors during archive', async () => {
368
+ const mockGmail = {
369
+ users: {
370
+ messages: {
371
+ modify: vi.fn().mockRejectedValue(new Error('Rate limit exceeded'))
372
+ }
373
+ }
374
+ };
375
+
376
+ const messageIds = ['msg123'];
377
+ const results = [];
378
+
379
+ for (const id of messageIds) {
380
+ try {
381
+ await mockGmail.users.messages.modify({
382
+ userId: 'me',
383
+ id: id,
384
+ requestBody: { removeLabelIds: ['INBOX'] }
385
+ });
386
+ results.push({ id, success: true });
387
+ } catch (err) {
388
+ results.push({ id, success: false, error: err.message });
389
+ }
390
+ }
391
+
392
+ expect(results).toHaveLength(1);
393
+ expect(results[0].success).toBe(false);
394
+ expect(results[0].error).toBe('Rate limit exceeded');
395
+ });
396
+
397
+ it('should handle multiple message IDs for archive', async () => {
398
+ const mockModify = vi.fn()
399
+ .mockResolvedValueOnce({ data: { id: 'msg1' } })
400
+ .mockResolvedValueOnce({ data: { id: 'msg2' } });
401
+
402
+ const mockGmail = {
403
+ users: { messages: { modify: mockModify } }
404
+ };
405
+
406
+ const messageIds = ['msg1', 'msg2'];
407
+ const results = [];
408
+
409
+ for (const id of messageIds) {
410
+ try {
411
+ await mockGmail.users.messages.modify({
412
+ userId: 'me',
413
+ id: id,
414
+ requestBody: { removeLabelIds: ['INBOX'] }
415
+ });
416
+ results.push({ id, success: true });
417
+ } catch (err) {
418
+ results.push({ id, success: false, error: err.message });
419
+ }
420
+ }
421
+
422
+ expect(results).toHaveLength(2);
423
+ expect(results[0]).toEqual({ id: 'msg1', success: true });
424
+ expect(results[1]).toEqual({ id: 'msg2', success: true });
425
+ expect(mockModify).toHaveBeenCalledTimes(2);
426
+ });
427
+ });
135
428
  });