@cli4ai/gmail 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -1,65 +1,94 @@
1
- # Gmail
1
+ # @cli4ai/gmail
2
2
 
3
- Full-featured Gmail CLI via Google API. Fast, powerful search, send/reply/forward.
3
+ > Official @cli4ai package https://cli4ai.com Install c4ai: `npm i -g c4ai`
4
+
5
+ Full-featured Gmail CLI via Google API. Fast search, send/reply/forward, drafts, attachments, and labels.
6
+
7
+ ## Setup
8
+
9
+ ```bash
10
+ npm i -g c4ai
11
+ c4ai add -g gmail
12
+ ```
13
+
14
+ ### 1) Google Cloud OAuth
15
+
16
+ 1. Go to https://console.cloud.google.com
17
+ 2. Create a project (or use an existing one)
18
+ 3. Enable the Gmail API
19
+ 4. Create OAuth 2.0 credentials (Desktop app recommended)
20
+ 5. Download the OAuth client JSON
21
+
22
+ ### 2) Point the tool at your credentials
23
+
24
+ Set `GMAIL_CREDENTIALS_PATH` to the downloaded JSON (optional: `GMAIL_TOKEN_PATH`).
25
+
26
+ Example:
27
+
28
+ ```bash
29
+ GMAIL_CREDENTIALS_PATH="$HOME/Downloads/credentials.json" c4ai run gmail inbox
30
+ ```
31
+
32
+ On first run, the tool prints an authorization URL and prompts for the code, then stores a token at `GMAIL_TOKEN_PATH` (default: `token.json` inside the installed package directory).
4
33
 
5
34
  ## Commands
6
35
 
7
36
  ### Reading
8
37
  ```bash
9
- ./run.js inbox [limit] # Recent inbox messages (default: 20)
10
- ./run.js unread [limit] # Unread messages only
11
- ./run.js search <query> [limit] # Search with Gmail syntax
12
- ./run.js read <id> # Read full message
13
- ./run.js thread <id> # Full conversation thread
14
- ./run.js threads [limit] [query] # List recent threads
38
+ c4ai run gmail inbox [limit] # Recent inbox messages (default: 20)
39
+ c4ai run gmail unread [limit] # Unread messages only
40
+ c4ai run gmail search <query> [limit] # Search with Gmail syntax
41
+ c4ai run gmail read <id> # Read full message
42
+ c4ai run gmail thread <id> # Full conversation thread
43
+ c4ai run gmail threads [limit] [query] # List recent threads
15
44
  ```
16
45
 
17
46
  ### Actions
18
47
  ```bash
19
- ./run.js archive <id> # Archive (remove from inbox)
20
- ./run.js trash <id> # Move to trash
21
- ./run.js untrash <id> # Restore from trash
22
- ./run.js star <id> # Star message
23
- ./run.js unstar <id> # Unstar message
24
- ./run.js markread <id> # Mark as read
25
- ./run.js markunread <id> # Mark as unread
48
+ c4ai run gmail archive <id> # Archive (remove from inbox)
49
+ c4ai run gmail trash <id> # Move to trash
50
+ c4ai run gmail untrash <id> # Restore from trash
51
+ c4ai run gmail star <id> # Star message
52
+ c4ai run gmail unstar <id> # Unstar message
53
+ c4ai run gmail markread <id> # Mark as read
54
+ c4ai run gmail markunread <id> # Mark as unread
26
55
  ```
27
56
 
28
57
  ### Sending
29
58
  ```bash
30
- ./run.js send <to> <subject> <body> # Send new email
31
- ./run.js reply <id> <body> # Reply to message
32
- ./run.js replyall <id> <body> # Reply to all
33
- ./run.js forward <id> <to> [body] # Forward message
34
- ./run.js draft <to> <subject> <body> # Create draft (legacy)
59
+ c4ai run gmail send <to> <subject> <body> # Send new email
60
+ c4ai run gmail reply <id> <body> # Reply to message
61
+ c4ai run gmail replyall <id> <body> # Reply to all
62
+ c4ai run gmail forward <id> <to> [body] # Forward message
63
+ c4ai run gmail draft <to> <subject> <body> # Create draft (legacy)
35
64
  ```
36
65
 
37
66
  ### Drafts
38
67
  ```bash
39
- ./run.js drafts # List all drafts
40
- ./run.js draft-get <id> # Get draft content
41
- ./run.js draft-create <to> <subj> <body> # Create new draft
42
- ./run.js draft-reply <msg-id> <body> # Create draft reply to message
43
- ./run.js draft-update <id> <to> <s> <b> # Update existing draft
44
- ./run.js draft-delete <id> # Delete draft
45
- ./run.js draft-send <id> # Send draft
68
+ c4ai run gmail drafts # List all drafts
69
+ c4ai run gmail draft-get <id> # Get draft content
70
+ c4ai run gmail draft-create <to> <subj> <body> # Create new draft
71
+ c4ai run gmail draft-reply <msg-id> <body> # Create draft reply to message
72
+ c4ai run gmail draft-update <id> <to> <s> <b> # Update existing draft
73
+ c4ai run gmail draft-delete <id> # Delete draft
74
+ c4ai run gmail draft-send <id> # Send draft
46
75
  ```
47
76
 
48
77
  ### Attachments
49
78
  ```bash
50
- ./run.js attachments <id> # List attachments in message
51
- ./run.js download <id> [filename] [out] # Download specific attachment
52
- ./run.js download-all <id> [dir] # Download all attachments
79
+ c4ai run gmail attachments <id> # List attachments in message
80
+ c4ai run gmail download <id> [filename] [out] # Download specific attachment
81
+ c4ai run gmail download-all <id> [dir] # Download all attachments
53
82
  ```
54
83
 
55
84
  ### Labels
56
85
  ```bash
57
- ./run.js labels # List all labels
58
- ./run.js label <id> <label> # Add label to message
59
- ./run.js unlabel <id> <label> # Remove label
60
- ./run.js label-info <label> # Get label with counts
61
- ./run.js label-create <name> # Create new label
62
- ./run.js label-delete <name> # Delete label
86
+ c4ai run gmail labels # List all labels
87
+ c4ai run gmail label <id> <label> # Add label to message
88
+ c4ai run gmail unlabel <id> <label> # Remove label
89
+ c4ai run gmail label-info <label> # Get label with counts
90
+ c4ai run gmail label-create <name> # Create new label
91
+ c4ai run gmail label-delete <name> # Delete label
63
92
  ```
64
93
 
65
94
  ## Output Flags
@@ -81,130 +110,100 @@ The search command uses Gmail's powerful search syntax:
81
110
 
82
111
  ```bash
83
112
  # By sender/recipient
84
- ./run.js search "from:boss@company.com"
85
- ./run.js search "to:me"
86
- ./run.js search "cc:team@company.com"
113
+ c4ai run gmail search "from:boss@company.com"
114
+ c4ai run gmail search "to:me"
115
+ c4ai run gmail search "cc:team@company.com"
87
116
 
88
117
  # By status
89
- ./run.js search "is:unread"
90
- ./run.js search "is:starred"
91
- ./run.js search "is:important"
118
+ c4ai run gmail search "is:unread"
119
+ c4ai run gmail search "is:starred"
120
+ c4ai run gmail search "is:important"
92
121
 
93
122
  # By content
94
- ./run.js search "subject:meeting"
95
- ./run.js search "has:attachment"
96
- ./run.js search "filename:pdf"
123
+ c4ai run gmail search "subject:meeting"
124
+ c4ai run gmail search "has:attachment"
125
+ c4ai run gmail search "filename:pdf"
97
126
 
98
127
  # By date
99
- ./run.js search "after:2024/12/01"
100
- ./run.js search "before:2024/12/31"
101
- ./run.js search "newer_than:7d"
102
- ./run.js search "older_than:1m"
128
+ c4ai run gmail search "after:2024/12/01"
129
+ c4ai run gmail search "before:2024/12/31"
130
+ c4ai run gmail search "newer_than:7d"
131
+ c4ai run gmail search "older_than:1m"
103
132
 
104
133
  # By label/location
105
- ./run.js search "label:work"
106
- ./run.js search "in:inbox"
107
- ./run.js search "in:sent"
134
+ c4ai run gmail search "label:work"
135
+ c4ai run gmail search "in:inbox"
136
+ c4ai run gmail search "in:sent"
108
137
 
109
138
  # Combine queries
110
- ./run.js search "from:boss@company.com is:unread newer_than:7d"
111
- ./run.js search "has:attachment larger:5M"
139
+ c4ai run gmail search "from:boss@company.com is:unread newer_than:7d"
140
+ c4ai run gmail search "has:attachment larger:5M"
112
141
  ```
113
142
 
114
143
  ## Examples
115
144
 
116
145
  ```bash
117
146
  # Quick inbox scan
118
- ./run.js inbox 10 --compact
147
+ c4ai run gmail inbox 10 --compact
119
148
 
120
149
  # Find unread from specific sender
121
- ./run.js search "from:important@client.com is:unread"
150
+ c4ai run gmail search "from:important@client.com is:unread"
122
151
 
123
152
  # Read a specific email
124
- ./run.js read 19b176a2f49b681f
153
+ c4ai run gmail read 19b176a2f49b681f
125
154
 
126
155
  # View full conversation thread
127
- ./run.js thread 19b176a2f49b681f
156
+ c4ai run gmail thread 19b176a2f49b681f
128
157
 
129
158
  # Send an email
130
- ./run.js send "john@example.com" "Meeting tomorrow" "Hi John,\n\nAre we still on for tomorrow?\n\nThanks"
159
+ c4ai run gmail send "john@example.com" "Meeting tomorrow" "Hi John,\n\nAre we still on for tomorrow?\n\nThanks"
131
160
 
132
161
  # Reply to an email
133
- ./run.js reply 19b176a2f49b681f "Thanks for the update, looks good!"
162
+ c4ai run gmail reply 19b176a2f49b681f "Thanks for the update, looks good!"
134
163
 
135
164
  # Forward an email
136
- ./run.js forward 19b176a2f49b681f "colleague@example.com" "FYI - see below"
165
+ c4ai run gmail forward 19b176a2f49b681f "colleague@example.com" "FYI - see below"
137
166
 
138
167
  # Archive old emails
139
- ./run.js archive 19b176a2f49b681f
168
+ c4ai run gmail archive 19b176a2f49b681f
140
169
 
141
170
  # Label management
142
- ./run.js label 19b176a2f49b681f "Work"
143
- ./run.js label-info "INBOX"
171
+ c4ai run gmail label 19b176a2f49b681f "Work"
172
+ c4ai run gmail label-info "INBOX"
144
173
 
145
174
  # Send with attachment
146
- ./run.js send "john@example.com" "Report" "See attached" --attach=./report.pdf
175
+ c4ai run gmail send "john@example.com" "Report" "See attached" --attach=./report.pdf
147
176
 
148
177
  # Multiple attachments
149
- ./run.js send "john@example.com" "Files" "Here are the files" --attach=./a.pdf --attach=./b.pdf
178
+ c4ai run gmail send "john@example.com" "Files" "Here are the files" --attach=./a.pdf --attach=./b.pdf
150
179
 
151
180
  # List and download attachments
152
- ./run.js attachments 19b176a2f49b681f
153
- ./run.js download 19b176a2f49b681f "report.pdf" ./downloads/report.pdf
154
- ./run.js download-all 19b176a2f49b681f ./downloads/
181
+ c4ai run gmail attachments 19b176a2f49b681f
182
+ c4ai run gmail download 19b176a2f49b681f "report.pdf" ./downloads/report.pdf
183
+ c4ai run gmail download-all 19b176a2f49b681f ./downloads/
155
184
 
156
185
  # Draft management
157
- ./run.js drafts
158
- ./run.js draft-create "john@example.com" "Draft" "Will finish later"
159
- ./run.js draft-reply 19b176a2f49b681f "Thanks, need to think about this"
160
- ./run.js draft-send r123456789
161
- ```
162
-
163
- ## Setup
164
-
165
- ### 1. Google Cloud Project
166
-
167
- 1. Go to [Google Cloud Console](https://console.cloud.google.com)
168
- 2. Create a project (or use existing)
169
- 3. Enable Gmail API
170
- 4. Create OAuth 2.0 credentials (Desktop app type)
171
- 5. Download `credentials.json`
172
-
173
- ### 2. Place Credentials
174
-
175
- Put `credentials.json` in the `gmail/` directory, or set path in `.env`:
176
-
177
- ```bash
178
- GMAIL_CREDENTIALS_PATH=/path/to/credentials.json
179
- GMAIL_TOKEN_PATH=/path/to/token.json
186
+ c4ai run gmail drafts
187
+ c4ai run gmail draft-create "john@example.com" "Draft" "Will finish later"
188
+ c4ai run gmail draft-reply 19b176a2f49b681f "Thanks, need to think about this"
189
+ c4ai run gmail draft-send r123456789
180
190
  ```
181
191
 
182
- ### 3. First Run
183
-
184
- On first run, you'll be prompted to authorize:
185
-
186
- 1. Visit the OAuth URL shown
187
- 2. Sign in with your Google account
188
- 3. Copy the authorization code
189
- 4. Paste it in the terminal
190
-
191
- Token is saved and auto-refreshes.
192
-
193
192
  ## Project Structure
194
193
 
195
194
  ```
196
195
  gmail/
197
- ├── run.js # CLI entry point
196
+ ├── run.ts # CLI entry point
198
197
  ├── lib/
199
- │ ├── api.js # Gmail API client + OAuth
200
- │ ├── messages.js # inbox, unread, search, read, archive...
201
- │ ├── threads.js # Thread operations
202
- │ ├── send.js # send, reply, forward (with attachment support)
203
- │ ├── drafts.js # Draft management, reply drafts
204
- │ ├── attachments.js # List, download attachments
205
- │ └── labels.js # Label management
206
- ├── credentials.json # OAuth credentials (gitignored)
207
- └── token.json # Auth token (gitignored)
198
+ │ ├── api.ts # Gmail API client + OAuth
199
+ │ ├── messages.ts # inbox, unread, search, read, archive...
200
+ │ ├── threads.ts # Thread operations
201
+ │ ├── send.ts # send, reply, forward (with attachment support)
202
+ │ ├── drafts.ts # Draft management, reply drafts
203
+ │ ├── attachments.ts # List, download attachments
204
+ │ └── labels.ts # Label management
205
+ ├── credentials.json # OAuth credentials (gitignored)
206
+ └── token.json # Auth token (gitignored)
208
207
  ```
209
208
 
210
209
  ## Required Scopes
package/c4ai.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gmail",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "Gmail CLI tool for messages, threads, and drafts",
5
5
  "author": "cliforai",
6
6
  "license": "MIT",
@@ -0,0 +1,287 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
2
+ import {
3
+ formatDate,
4
+ decodeBase64Url,
5
+ encodeBase64Url,
6
+ extractHeaders,
7
+ extractBody,
8
+ cleanBody,
9
+ output,
10
+ type MessagePayload,
11
+ type EmailHeaders
12
+ } from './api';
13
+
14
+ describe('gmail/lib/api', () => {
15
+ describe('formatDate', () => {
16
+ test('should format timestamp to readable date', () => {
17
+ // 1704067200000 = 2024-01-01 00:00:00 UTC
18
+ const result = formatDate('1704067200000');
19
+ expect(result).toBe('2024-01-01 00:00');
20
+ });
21
+
22
+ test('should handle timestamp as number', () => {
23
+ const result = formatDate(1704067200000);
24
+ expect(result).toBe('2024-01-01 00:00');
25
+ });
26
+
27
+ test('should return null for undefined', () => {
28
+ expect(formatDate(undefined)).toBeNull();
29
+ });
30
+
31
+ test('should return null for null', () => {
32
+ expect(formatDate(null)).toBeNull();
33
+ });
34
+ });
35
+
36
+ describe('decodeBase64Url', () => {
37
+ test('should decode base64url encoded string', () => {
38
+ // 'Hello World' in base64url
39
+ const encoded = 'SGVsbG8gV29ybGQ';
40
+ const result = decodeBase64Url(encoded);
41
+ expect(result).toBe('Hello World');
42
+ });
43
+
44
+ test('should handle URL-safe characters', () => {
45
+ // Contains - and _ instead of + and /
46
+ const encoded = 'PDw_Pz4-'; // <<??>>
47
+ const result = decodeBase64Url(encoded);
48
+ expect(result).toBe('<<??>>');
49
+ });
50
+
51
+ test('should handle missing padding', () => {
52
+ // Without = padding
53
+ const encoded = 'SGVsbG8';
54
+ const result = decodeBase64Url(encoded);
55
+ expect(result).toBe('Hello');
56
+ });
57
+
58
+ test('should return empty string for undefined', () => {
59
+ expect(decodeBase64Url(undefined)).toBe('');
60
+ });
61
+
62
+ test('should return empty string for null', () => {
63
+ expect(decodeBase64Url(null)).toBe('');
64
+ });
65
+
66
+ test('should return empty string for empty string', () => {
67
+ expect(decodeBase64Url('')).toBe('');
68
+ });
69
+ });
70
+
71
+ describe('encodeBase64Url', () => {
72
+ test('should encode string to base64url', () => {
73
+ const result = encodeBase64Url('Hello World');
74
+ expect(result).toBe('SGVsbG8gV29ybGQ');
75
+ });
76
+
77
+ test('should replace + with -', () => {
78
+ // A string that would produce + in standard base64
79
+ const result = encodeBase64Url('<<??>>');
80
+ expect(result).not.toContain('+');
81
+ expect(result).toContain('-');
82
+ });
83
+
84
+ test('should replace / with _', () => {
85
+ // A string that would produce / in standard base64
86
+ const result = encodeBase64Url('<<??>>');
87
+ expect(result).not.toContain('/');
88
+ });
89
+
90
+ test('should remove padding', () => {
91
+ const result = encodeBase64Url('Hello');
92
+ expect(result).not.toContain('=');
93
+ });
94
+
95
+ test('should be reversible', () => {
96
+ const original = 'Test message with special chars: éà';
97
+ const encoded = encodeBase64Url(original);
98
+ const decoded = decodeBase64Url(encoded);
99
+ expect(decoded).toBe(original);
100
+ });
101
+ });
102
+
103
+ describe('extractHeaders', () => {
104
+ test('should extract email headers', () => {
105
+ const headers = [
106
+ { name: 'From', value: 'sender@example.com' },
107
+ { name: 'To', value: 'recipient@example.com' },
108
+ { name: 'Subject', value: 'Test Subject' },
109
+ { name: 'Date', value: '2024-01-01' }
110
+ ];
111
+ const result = extractHeaders(headers);
112
+ expect(result.from).toBe('sender@example.com');
113
+ expect(result.to).toBe('recipient@example.com');
114
+ expect(result.subject).toBe('Test Subject');
115
+ expect(result.date).toBe('2024-01-01');
116
+ });
117
+
118
+ test('should handle Message-ID header', () => {
119
+ const headers = [
120
+ { name: 'Message-ID', value: '<123@example.com>' }
121
+ ];
122
+ const result = extractHeaders(headers);
123
+ expect(result.message_id).toBe('<123@example.com>');
124
+ });
125
+
126
+ test('should handle Reply-To header', () => {
127
+ const headers = [
128
+ { name: 'Reply-To', value: 'reply@example.com' }
129
+ ];
130
+ const result = extractHeaders(headers);
131
+ expect(result.reply_to).toBe('reply@example.com');
132
+ });
133
+
134
+ test('should ignore unknown headers', () => {
135
+ const headers = [
136
+ { name: 'X-Custom-Header', value: 'custom value' },
137
+ { name: 'From', value: 'sender@example.com' }
138
+ ];
139
+ const result = extractHeaders(headers);
140
+ expect(result.from).toBe('sender@example.com');
141
+ expect(Object.keys(result)).not.toContain('x_custom_header');
142
+ });
143
+
144
+ test('should return empty object for undefined headers', () => {
145
+ const result = extractHeaders(undefined);
146
+ expect(result).toEqual({});
147
+ });
148
+
149
+ test('should return empty object for empty array', () => {
150
+ const result = extractHeaders([]);
151
+ expect(result).toEqual({});
152
+ });
153
+ });
154
+
155
+ describe('extractBody', () => {
156
+ test('should extract body from simple message', () => {
157
+ const payload: MessagePayload = {
158
+ body: { data: 'SGVsbG8gV29ybGQ' } // 'Hello World'
159
+ };
160
+ const result = extractBody(payload);
161
+ expect(result).toBe('Hello World');
162
+ });
163
+
164
+ test('should prefer text/plain from multipart', () => {
165
+ const payload: MessagePayload = {
166
+ parts: [
167
+ { mimeType: 'text/html', body: { data: 'PGh0bWw-SGVsbG88L2h0bWw-' } },
168
+ { mimeType: 'text/plain', body: { data: 'SGVsbG8' } }
169
+ ]
170
+ };
171
+ const result = extractBody(payload);
172
+ expect(result).toBe('Hello');
173
+ });
174
+
175
+ test('should fall back to text/html if no text/plain', () => {
176
+ const payload: MessagePayload = {
177
+ parts: [
178
+ { mimeType: 'text/html', body: { data: 'PGI-SGVsbG88L2I-' } } // <b>Hello</b>
179
+ ]
180
+ };
181
+ const result = extractBody(payload);
182
+ // HTML tags should be stripped
183
+ expect(result).toContain('Hello');
184
+ });
185
+
186
+ test('should handle nested parts', () => {
187
+ const payload: MessagePayload = {
188
+ parts: [
189
+ {
190
+ mimeType: 'multipart/alternative',
191
+ parts: [
192
+ { mimeType: 'text/plain', body: { data: 'SGVsbG8' } }
193
+ ]
194
+ }
195
+ ]
196
+ };
197
+ const result = extractBody(payload);
198
+ expect(result).toBe('Hello');
199
+ });
200
+
201
+ test('should return empty string for undefined payload', () => {
202
+ expect(extractBody(undefined)).toBe('');
203
+ });
204
+
205
+ test('should return empty string for empty payload', () => {
206
+ expect(extractBody({})).toBe('');
207
+ });
208
+ });
209
+
210
+ describe('cleanBody', () => {
211
+ test('should remove excessive whitespace', () => {
212
+ const body = 'Hello World';
213
+ const result = cleanBody(body);
214
+ expect(result).toBe('Hello World');
215
+ });
216
+
217
+ test('should normalize line breaks', () => {
218
+ const body = 'Hello\r\nWorld';
219
+ const result = cleanBody(body);
220
+ expect(result).toBe('Hello\nWorld');
221
+ });
222
+
223
+ test('should collapse multiple newlines', () => {
224
+ const body = 'Hello\n\n\n\nWorld';
225
+ const result = cleanBody(body);
226
+ expect(result).toBe('Hello\n\nWorld');
227
+ });
228
+
229
+ test('should trim whitespace', () => {
230
+ const body = ' Hello World ';
231
+ const result = cleanBody(body);
232
+ expect(result).toBe('Hello World');
233
+ });
234
+
235
+ test('should truncate to maxLength', () => {
236
+ const body = 'Hello World This is a long message';
237
+ const result = cleanBody(body, 10);
238
+ expect(result).toBe('Hello Worl...');
239
+ });
240
+
241
+ test('should not truncate when maxLength is null', () => {
242
+ const body = 'Hello World';
243
+ const result = cleanBody(body, null);
244
+ expect(result).toBe('Hello World');
245
+ });
246
+
247
+ test('should return empty string for empty input', () => {
248
+ expect(cleanBody('')).toBe('');
249
+ });
250
+ });
251
+
252
+ describe('output', () => {
253
+ let consoleSpy: ReturnType<typeof spyOn>;
254
+ let logs: string[];
255
+
256
+ beforeEach(() => {
257
+ logs = [];
258
+ consoleSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
259
+ logs.push(msg);
260
+ });
261
+ });
262
+
263
+ afterEach(() => {
264
+ consoleSpy.mockRestore();
265
+ });
266
+
267
+ test('should output JSON by default', () => {
268
+ output({ test: 'data' });
269
+ expect(logs[0]).toContain('"test": "data"');
270
+ });
271
+
272
+ test('should output raw JSON when raw option is true', () => {
273
+ output({ test: 'data' }, { raw: true });
274
+ expect(logs[0]).toContain('"test": "data"');
275
+ });
276
+
277
+ test('should output compact format for message arrays', () => {
278
+ const messages = [
279
+ { date: '2024-01-01', from: 'sender@test.com', subject: 'Test Subject' }
280
+ ];
281
+ output(messages, { compact: true });
282
+ expect(logs[0]).toContain('[2024-01-01]');
283
+ expect(logs[0]).toContain('sender@test.com');
284
+ expect(logs[0]).toContain('Test Subject');
285
+ });
286
+ });
287
+ });