@cli4ai/gmail 1.0.5 → 1.0.7
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/cli4ai.json +11 -1
- package/lib/api.test.ts +287 -0
- package/lib/api.ts +299 -0
- package/lib/attachments.ts +199 -0
- package/lib/drafts.ts +434 -0
- package/lib/labels.ts +198 -0
- package/lib/messages.ts +310 -0
- package/lib/send.ts +331 -0
- package/lib/threads.ts +164 -0
- package/package.json +2 -1
package/cli4ai.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gmail",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Gmail CLI tool for messages, threads, and drafts",
|
|
5
5
|
"author": "cliforai",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,16 @@
|
|
|
39
39
|
"google-auth-library": "^9.0.0",
|
|
40
40
|
"commander": "^14.0.0"
|
|
41
41
|
},
|
|
42
|
+
"env": {
|
|
43
|
+
"GMAIL_CREDENTIALS_PATH": {
|
|
44
|
+
"required": true,
|
|
45
|
+
"description": "Path to Google OAuth credentials.json file (download from Google Cloud Console)"
|
|
46
|
+
},
|
|
47
|
+
"GMAIL_TOKEN_PATH": {
|
|
48
|
+
"required": false,
|
|
49
|
+
"description": "Path to store OAuth token (defaults to package directory)"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
42
52
|
"mcp": {
|
|
43
53
|
"enabled": true,
|
|
44
54
|
"transport": "stdio"
|
package/lib/api.test.ts
ADDED
|
@@ -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
|
+
});
|
package/lib/api.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { google, gmail_v1 } from 'googleapis';
|
|
4
|
+
import readline from 'readline';
|
|
5
|
+
import type { OAuth2Client } from 'google-auth-library';
|
|
6
|
+
|
|
7
|
+
// Load env from root .env
|
|
8
|
+
const SCRIPT_DIR = path.dirname(import.meta.dir);
|
|
9
|
+
const ENV_FILE = path.join(SCRIPT_DIR, '../.env');
|
|
10
|
+
if (fs.existsSync(ENV_FILE)) {
|
|
11
|
+
const envContent = fs.readFileSync(ENV_FILE, 'utf-8');
|
|
12
|
+
envContent.split('\n').forEach(line => {
|
|
13
|
+
const [key, ...vals] = line.split('=');
|
|
14
|
+
if (key && !key.startsWith('#')) {
|
|
15
|
+
process.env[key.trim()] = vals.join('=').trim();
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Paths for credentials and token
|
|
21
|
+
const CREDENTIALS_PATH = process.env.GMAIL_CREDENTIALS_PATH || path.join(SCRIPT_DIR, 'credentials.json');
|
|
22
|
+
const TOKEN_PATH = process.env.GMAIL_TOKEN_PATH || path.join(SCRIPT_DIR, 'token.json');
|
|
23
|
+
|
|
24
|
+
// Scopes for Gmail access
|
|
25
|
+
const SCOPES = [
|
|
26
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
27
|
+
'https://www.googleapis.com/auth/gmail.compose',
|
|
28
|
+
'https://www.googleapis.com/auth/gmail.send'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
let gmailClient: gmail_v1.Gmail | null = null;
|
|
32
|
+
let authClient: OAuth2Client | null = null;
|
|
33
|
+
|
|
34
|
+
/** Output options */
|
|
35
|
+
export interface OutputOptions {
|
|
36
|
+
raw?: boolean;
|
|
37
|
+
compact?: boolean;
|
|
38
|
+
body?: boolean;
|
|
39
|
+
fullBody?: boolean;
|
|
40
|
+
attach?: string | string[];
|
|
41
|
+
cc?: string;
|
|
42
|
+
bcc?: string;
|
|
43
|
+
limit?: number;
|
|
44
|
+
query?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Message output data for compact display */
|
|
48
|
+
export interface MessageOutputData {
|
|
49
|
+
date?: string | null;
|
|
50
|
+
from?: string;
|
|
51
|
+
subject?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Email headers */
|
|
55
|
+
export interface EmailHeaders {
|
|
56
|
+
from?: string;
|
|
57
|
+
to?: string;
|
|
58
|
+
cc?: string;
|
|
59
|
+
bcc?: string;
|
|
60
|
+
subject?: string;
|
|
61
|
+
date?: string;
|
|
62
|
+
reply_to?: string;
|
|
63
|
+
message_id?: string;
|
|
64
|
+
references?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Message payload */
|
|
68
|
+
export interface MessagePayload {
|
|
69
|
+
headers?: Array<{ name: string; value: string }>;
|
|
70
|
+
body?: { data?: string; size?: number; attachmentId?: string };
|
|
71
|
+
parts?: MessagePayload[];
|
|
72
|
+
mimeType?: string;
|
|
73
|
+
filename?: string;
|
|
74
|
+
partId?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get new token via OAuth flow
|
|
79
|
+
*/
|
|
80
|
+
async function getNewToken(oAuth2Client: OAuth2Client): Promise<void> {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const authUrl = oAuth2Client.generateAuthUrl({
|
|
83
|
+
access_type: 'offline',
|
|
84
|
+
scope: SCOPES,
|
|
85
|
+
});
|
|
86
|
+
console.error('Authorize this app by visiting this url:');
|
|
87
|
+
console.error(authUrl);
|
|
88
|
+
|
|
89
|
+
const rl = readline.createInterface({
|
|
90
|
+
input: process.stdin,
|
|
91
|
+
output: process.stderr,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
rl.question('Enter the code from that page here: ', (code: string) => {
|
|
95
|
+
rl.close();
|
|
96
|
+
oAuth2Client.getToken(code, (err, token) => {
|
|
97
|
+
if (err) {
|
|
98
|
+
reject(new Error(`Error retrieving access token: ${err.message}`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
oAuth2Client.setCredentials(token!);
|
|
102
|
+
fs.writeFileSync(TOKEN_PATH, JSON.stringify(token));
|
|
103
|
+
console.error('Token stored to', TOKEN_PATH);
|
|
104
|
+
resolve();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Authorize and get OAuth2 client
|
|
112
|
+
*/
|
|
113
|
+
export async function authorize(): Promise<OAuth2Client> {
|
|
114
|
+
if (authClient) return authClient;
|
|
115
|
+
|
|
116
|
+
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
|
117
|
+
throw new Error(`Credentials not found at ${CREDENTIALS_PATH}. Run: cli4ai secrets init gmail`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
|
|
121
|
+
const credentials = JSON.parse(content);
|
|
122
|
+
const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
|
|
123
|
+
|
|
124
|
+
const oAuth2Client = new google.auth.OAuth2(
|
|
125
|
+
client_id,
|
|
126
|
+
client_secret,
|
|
127
|
+
redirect_uris[0]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Try to load existing token
|
|
131
|
+
if (fs.existsSync(TOKEN_PATH)) {
|
|
132
|
+
const token = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf-8'));
|
|
133
|
+
oAuth2Client.setCredentials(token);
|
|
134
|
+
|
|
135
|
+
// Set up automatic token refresh
|
|
136
|
+
oAuth2Client.on('tokens', (tokens) => {
|
|
137
|
+
const currentToken = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf-8'));
|
|
138
|
+
const newToken = { ...currentToken, ...tokens };
|
|
139
|
+
fs.writeFileSync(TOKEN_PATH, JSON.stringify(newToken));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Try to refresh token if it might be expired
|
|
143
|
+
try {
|
|
144
|
+
await oAuth2Client.getAccessToken();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
const error = err as Error;
|
|
147
|
+
if (error.message.includes('invalid_grant') || error.message.includes('Token has been expired')) {
|
|
148
|
+
console.error('Token expired. Re-authenticating...');
|
|
149
|
+
fs.unlinkSync(TOKEN_PATH);
|
|
150
|
+
await getNewToken(oAuth2Client);
|
|
151
|
+
} else {
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
await getNewToken(oAuth2Client);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
authClient = oAuth2Client;
|
|
160
|
+
return oAuth2Client;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get Gmail API client
|
|
165
|
+
*/
|
|
166
|
+
export async function getGmail(): Promise<gmail_v1.Gmail> {
|
|
167
|
+
if (gmailClient) return gmailClient;
|
|
168
|
+
|
|
169
|
+
const auth = await authorize();
|
|
170
|
+
gmailClient = google.gmail({ version: 'v1', auth });
|
|
171
|
+
return gmailClient;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Output data to console
|
|
176
|
+
*/
|
|
177
|
+
export function output(data: unknown, options: OutputOptions = {}): void {
|
|
178
|
+
if (options.raw) {
|
|
179
|
+
console.log(JSON.stringify(data, null, 2));
|
|
180
|
+
} else if (options.compact && Array.isArray(data)) {
|
|
181
|
+
data.forEach((item: MessageOutputData) => {
|
|
182
|
+
if (item.date && item.from && item.subject) {
|
|
183
|
+
const from = item.from.slice(0, 30).padEnd(30);
|
|
184
|
+
const subject = item.subject.slice(0, 50);
|
|
185
|
+
console.log(`[${item.date}] ${from} ${subject}`);
|
|
186
|
+
} else {
|
|
187
|
+
console.log(JSON.stringify(item));
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
} else {
|
|
191
|
+
console.log(JSON.stringify(data, null, 2));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Format date
|
|
197
|
+
*/
|
|
198
|
+
export function formatDate(timestamp: string | number | undefined | null): string | null {
|
|
199
|
+
if (!timestamp) return null;
|
|
200
|
+
const date = new Date(parseInt(String(timestamp)));
|
|
201
|
+
return date.toISOString().replace('T', ' ').slice(0, 16);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Decode base64url
|
|
206
|
+
*/
|
|
207
|
+
export function decodeBase64Url(str: string | undefined | null): string {
|
|
208
|
+
if (!str) return '';
|
|
209
|
+
// Replace URL-safe characters and add padding
|
|
210
|
+
let normalized = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
211
|
+
while (normalized.length % 4) normalized += '=';
|
|
212
|
+
return Buffer.from(normalized, 'base64').toString('utf-8');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Encode to base64url
|
|
217
|
+
*/
|
|
218
|
+
export function encodeBase64Url(str: string): string {
|
|
219
|
+
return Buffer.from(str)
|
|
220
|
+
.toString('base64')
|
|
221
|
+
.replace(/\+/g, '-')
|
|
222
|
+
.replace(/\//g, '_')
|
|
223
|
+
.replace(/=+$/, '');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Extract headers from message
|
|
228
|
+
*/
|
|
229
|
+
export function extractHeaders(headers: Array<{ name: string; value: string }> | undefined): EmailHeaders {
|
|
230
|
+
const result: EmailHeaders = {};
|
|
231
|
+
const headerNames = ['From', 'To', 'Subject', 'Date', 'Cc', 'Bcc', 'Reply-To', 'Message-ID', 'References'];
|
|
232
|
+
|
|
233
|
+
for (const header of headers || []) {
|
|
234
|
+
if (headerNames.includes(header.name)) {
|
|
235
|
+
const key = header.name.toLowerCase().replace('-', '_') as keyof EmailHeaders;
|
|
236
|
+
result[key] = header.value;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Extract body from message parts
|
|
244
|
+
*/
|
|
245
|
+
export function extractBody(payload: MessagePayload | undefined): string {
|
|
246
|
+
if (!payload) return '';
|
|
247
|
+
|
|
248
|
+
// Simple message with direct body
|
|
249
|
+
if (payload.body?.data) {
|
|
250
|
+
return decodeBase64Url(payload.body.data);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Multipart message
|
|
254
|
+
if (payload.parts) {
|
|
255
|
+
// Prefer text/plain, fall back to text/html
|
|
256
|
+
for (const part of payload.parts) {
|
|
257
|
+
if (part.mimeType === 'text/plain' && part.body?.data) {
|
|
258
|
+
return decodeBase64Url(part.body.data);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const part of payload.parts) {
|
|
262
|
+
if (part.mimeType === 'text/html' && part.body?.data) {
|
|
263
|
+
// Strip HTML tags for plain text
|
|
264
|
+
const html = decodeBase64Url(part.body.data);
|
|
265
|
+
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Recursively check nested parts
|
|
269
|
+
for (const part of payload.parts) {
|
|
270
|
+
if (part.parts) {
|
|
271
|
+
const body = extractBody(part);
|
|
272
|
+
if (body) return body;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return '';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Clean email body for display
|
|
282
|
+
*/
|
|
283
|
+
export function cleanBody(body: string, maxLength?: number | null): string {
|
|
284
|
+
if (!body) return '';
|
|
285
|
+
|
|
286
|
+
// Remove excessive whitespace
|
|
287
|
+
let cleaned = body
|
|
288
|
+
.replace(/\r\n/g, '\n')
|
|
289
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
290
|
+
.replace(/[ \t]+/g, ' ')
|
|
291
|
+
.trim();
|
|
292
|
+
|
|
293
|
+
// Truncate if needed
|
|
294
|
+
if (maxLength && cleaned.length > maxLength) {
|
|
295
|
+
cleaned = cleaned.slice(0, maxLength) + '...';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return cleaned;
|
|
299
|
+
}
|