@cli4ai/gmail 1.0.11 → 1.0.14

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "gmail",
3
- "version": "1.0.11",
3
+ "version": "1.0.14",
4
4
  "description": "Gmail CLI tool for messages, threads, and drafts",
5
5
  "author": "cliforai",
6
- "license": "MIT",
7
- "entry": "run.ts",
6
+ "license": "BUSL-1.1",
7
+ "entry": "dist/run.js",
8
8
  "runtime": "node",
9
9
  "keywords": [
10
10
  "gmail",
@@ -268,7 +268,7 @@
268
268
  }
269
269
  },
270
270
  "dependencies": {
271
- "@cli4ai/lib": "^1.0.0",
271
+ "@cli4ai/lib": "^1.0.9",
272
272
  "googleapis": "^144.0.0",
273
273
  "google-auth-library": "^9.0.0",
274
274
  "commander": "^14.0.0"
@@ -0,0 +1,91 @@
1
+ import { gmail_v1 } from 'googleapis';
2
+ import type { OAuth2Client } from 'google-auth-library';
3
+ /** Output options */
4
+ export interface OutputOptions {
5
+ raw?: boolean;
6
+ compact?: boolean;
7
+ body?: boolean;
8
+ fullBody?: boolean;
9
+ attach?: string | string[];
10
+ cc?: string;
11
+ bcc?: string;
12
+ limit?: number;
13
+ query?: string;
14
+ }
15
+ /** Message output data for compact display */
16
+ export interface MessageOutputData {
17
+ date?: string | null;
18
+ from?: string;
19
+ subject?: string;
20
+ }
21
+ /** Email headers */
22
+ export interface EmailHeaders {
23
+ from?: string;
24
+ to?: string;
25
+ cc?: string;
26
+ bcc?: string;
27
+ subject?: string;
28
+ date?: string;
29
+ reply_to?: string;
30
+ message_id?: string;
31
+ references?: string;
32
+ }
33
+ /** Message payload */
34
+ export interface MessagePayload {
35
+ headers?: Array<{
36
+ name: string;
37
+ value: string;
38
+ }>;
39
+ body?: {
40
+ data?: string;
41
+ size?: number;
42
+ attachmentId?: string;
43
+ };
44
+ parts?: MessagePayload[];
45
+ mimeType?: string;
46
+ filename?: string;
47
+ partId?: string;
48
+ }
49
+ /**
50
+ * Authorize and get OAuth2 client
51
+ */
52
+ export declare function authorize(): Promise<OAuth2Client>;
53
+ /**
54
+ * Get Gmail API client
55
+ */
56
+ export declare function getGmail(): Promise<gmail_v1.Gmail>;
57
+ /**
58
+ * Output data to console
59
+ */
60
+ export declare function output(data: unknown, options?: OutputOptions): void;
61
+ /**
62
+ * Format date
63
+ */
64
+ export declare function formatDate(timestamp: string | number | undefined | null): string | null;
65
+ /**
66
+ * Decode base64url to Buffer (for binary attachments)
67
+ */
68
+ export declare function decodeBase64Url(str: string | undefined | null): Buffer;
69
+ /**
70
+ * Decode base64url to UTF-8 string (for text content)
71
+ */
72
+ export declare function decodeBase64UrlToString(str: string | undefined | null): string;
73
+ /**
74
+ * Encode to base64url
75
+ */
76
+ export declare function encodeBase64Url(str: string): string;
77
+ /**
78
+ * Extract headers from message
79
+ */
80
+ export declare function extractHeaders(headers: Array<{
81
+ name: string;
82
+ value: string;
83
+ }> | undefined): EmailHeaders;
84
+ /**
85
+ * Extract body from message parts
86
+ */
87
+ export declare function extractBody(payload: MessagePayload | undefined): string;
88
+ /**
89
+ * Clean email body for display
90
+ */
91
+ export declare function cleanBody(body: string, maxLength?: number | null): string;
@@ -0,0 +1,240 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { google } from 'googleapis';
4
+ import readline from 'readline';
5
+ import { homedir } from 'os';
6
+ // SECURITY: Store credentials in user's home directory, not in node_modules
7
+ const GMAIL_CONFIG_DIR = path.join(homedir(), '.cli4ai', 'gmail');
8
+ // Paths for credentials and token (env vars override for flexibility)
9
+ const CREDENTIALS_PATH = process.env.GMAIL_CREDENTIALS_PATH || path.join(GMAIL_CONFIG_DIR, 'credentials.json');
10
+ const TOKEN_PATH = process.env.GMAIL_TOKEN_PATH || path.join(GMAIL_CONFIG_DIR, 'token.json');
11
+ /**
12
+ * Ensure the gmail config directory exists with secure permissions
13
+ */
14
+ function ensureConfigDir() {
15
+ if (!fs.existsSync(GMAIL_CONFIG_DIR)) {
16
+ fs.mkdirSync(GMAIL_CONFIG_DIR, { recursive: true, mode: 0o700 });
17
+ }
18
+ }
19
+ // Scopes for Gmail access
20
+ const SCOPES = [
21
+ 'https://www.googleapis.com/auth/gmail.modify',
22
+ 'https://www.googleapis.com/auth/gmail.compose',
23
+ 'https://www.googleapis.com/auth/gmail.send'
24
+ ];
25
+ let gmailClient = null;
26
+ let authClient = null;
27
+ /**
28
+ * Get new token via OAuth flow
29
+ */
30
+ async function getNewToken(oAuth2Client) {
31
+ return new Promise((resolve, reject) => {
32
+ const authUrl = oAuth2Client.generateAuthUrl({
33
+ access_type: 'offline',
34
+ scope: SCOPES,
35
+ });
36
+ console.error('Authorize this app by visiting this url:');
37
+ console.error(authUrl);
38
+ const rl = readline.createInterface({
39
+ input: process.stdin,
40
+ output: process.stderr,
41
+ });
42
+ rl.question('Enter the code from that page here: ', (code) => {
43
+ rl.close();
44
+ oAuth2Client.getToken(code, (err, token) => {
45
+ if (err) {
46
+ reject(new Error(`Error retrieving access token: ${err.message}`));
47
+ return;
48
+ }
49
+ oAuth2Client.setCredentials(token);
50
+ ensureConfigDir();
51
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(token), { mode: 0o600 });
52
+ console.error('Token stored to', TOKEN_PATH);
53
+ resolve();
54
+ });
55
+ });
56
+ });
57
+ }
58
+ /**
59
+ * Authorize and get OAuth2 client
60
+ */
61
+ export async function authorize() {
62
+ if (authClient)
63
+ return authClient;
64
+ if (!fs.existsSync(CREDENTIALS_PATH)) {
65
+ throw new Error(`Credentials not found at ${CREDENTIALS_PATH}. Run: cli4ai secrets init gmail`);
66
+ }
67
+ const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
68
+ const credentials = JSON.parse(content);
69
+ const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
70
+ const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
71
+ // Try to load existing token
72
+ if (fs.existsSync(TOKEN_PATH)) {
73
+ const token = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf-8'));
74
+ oAuth2Client.setCredentials(token);
75
+ // Set up automatic token refresh
76
+ oAuth2Client.on('tokens', (tokens) => {
77
+ const currentToken = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf-8'));
78
+ const newToken = { ...currentToken, ...tokens };
79
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(newToken), { mode: 0o600 });
80
+ });
81
+ // Try to refresh token if it might be expired
82
+ try {
83
+ await oAuth2Client.getAccessToken();
84
+ }
85
+ catch (err) {
86
+ const error = err;
87
+ if (error.message.includes('invalid_grant') || error.message.includes('Token has been expired')) {
88
+ console.error('Token expired. Re-authenticating...');
89
+ fs.unlinkSync(TOKEN_PATH);
90
+ await getNewToken(oAuth2Client);
91
+ }
92
+ else {
93
+ throw err;
94
+ }
95
+ }
96
+ }
97
+ else {
98
+ await getNewToken(oAuth2Client);
99
+ }
100
+ authClient = oAuth2Client;
101
+ return oAuth2Client;
102
+ }
103
+ /**
104
+ * Get Gmail API client
105
+ */
106
+ export async function getGmail() {
107
+ if (gmailClient)
108
+ return gmailClient;
109
+ const auth = await authorize();
110
+ gmailClient = google.gmail({ version: 'v1', auth });
111
+ return gmailClient;
112
+ }
113
+ /**
114
+ * Output data to console
115
+ */
116
+ export function output(data, options = {}) {
117
+ if (options.raw) {
118
+ console.log(JSON.stringify(data, null, 2));
119
+ }
120
+ else if (options.compact && Array.isArray(data)) {
121
+ data.forEach((item) => {
122
+ if (item.date && item.from && item.subject) {
123
+ const from = item.from.slice(0, 30).padEnd(30);
124
+ const subject = item.subject.slice(0, 50);
125
+ console.log(`[${item.date}] ${from} ${subject}`);
126
+ }
127
+ else {
128
+ console.log(JSON.stringify(item));
129
+ }
130
+ });
131
+ }
132
+ else {
133
+ console.log(JSON.stringify(data, null, 2));
134
+ }
135
+ }
136
+ /**
137
+ * Format date
138
+ */
139
+ export function formatDate(timestamp) {
140
+ if (!timestamp)
141
+ return null;
142
+ const date = new Date(parseInt(String(timestamp)));
143
+ return date.toISOString().replace('T', ' ').slice(0, 16);
144
+ }
145
+ /**
146
+ * Decode base64url to Buffer (for binary attachments)
147
+ */
148
+ export function decodeBase64Url(str) {
149
+ if (!str)
150
+ return Buffer.alloc(0);
151
+ // Replace URL-safe characters and add padding
152
+ let normalized = str.replace(/-/g, '+').replace(/_/g, '/');
153
+ while (normalized.length % 4)
154
+ normalized += '=';
155
+ return Buffer.from(normalized, 'base64');
156
+ }
157
+ /**
158
+ * Decode base64url to UTF-8 string (for text content)
159
+ */
160
+ export function decodeBase64UrlToString(str) {
161
+ return decodeBase64Url(str).toString('utf-8');
162
+ }
163
+ /**
164
+ * Encode to base64url
165
+ */
166
+ export function encodeBase64Url(str) {
167
+ return Buffer.from(str)
168
+ .toString('base64')
169
+ .replace(/\+/g, '-')
170
+ .replace(/\//g, '_')
171
+ .replace(/=+$/, '');
172
+ }
173
+ /**
174
+ * Extract headers from message
175
+ */
176
+ export function extractHeaders(headers) {
177
+ const result = {};
178
+ const headerNames = ['From', 'To', 'Subject', 'Date', 'Cc', 'Bcc', 'Reply-To', 'Message-ID', 'References'];
179
+ for (const header of headers || []) {
180
+ if (headerNames.includes(header.name)) {
181
+ const key = header.name.toLowerCase().replace('-', '_');
182
+ result[key] = header.value;
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+ /**
188
+ * Extract body from message parts
189
+ */
190
+ export function extractBody(payload) {
191
+ if (!payload)
192
+ return '';
193
+ // Simple message with direct body
194
+ if (payload.body?.data) {
195
+ return decodeBase64UrlToString(payload.body.data);
196
+ }
197
+ // Multipart message
198
+ if (payload.parts) {
199
+ // Prefer text/plain, fall back to text/html
200
+ for (const part of payload.parts) {
201
+ if (part.mimeType === 'text/plain' && part.body?.data) {
202
+ return decodeBase64UrlToString(part.body.data);
203
+ }
204
+ }
205
+ for (const part of payload.parts) {
206
+ if (part.mimeType === 'text/html' && part.body?.data) {
207
+ // Strip HTML tags for plain text
208
+ const html = decodeBase64UrlToString(part.body.data);
209
+ return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
210
+ }
211
+ }
212
+ // Recursively check nested parts
213
+ for (const part of payload.parts) {
214
+ if (part.parts) {
215
+ const body = extractBody(part);
216
+ if (body)
217
+ return body;
218
+ }
219
+ }
220
+ }
221
+ return '';
222
+ }
223
+ /**
224
+ * Clean email body for display
225
+ */
226
+ export function cleanBody(body, maxLength) {
227
+ if (!body)
228
+ return '';
229
+ // Remove excessive whitespace
230
+ let cleaned = body
231
+ .replace(/\r\n/g, '\n')
232
+ .replace(/\n{3,}/g, '\n\n')
233
+ .replace(/[ \t]+/g, ' ')
234
+ .trim();
235
+ // Truncate if needed
236
+ if (maxLength && cleaned.length > maxLength) {
237
+ cleaned = cleaned.slice(0, maxLength) + '...';
238
+ }
239
+ return cleaned;
240
+ }
@@ -0,0 +1,35 @@
1
+ import { type OutputOptions } from './api.js';
2
+ /** Attachment info */
3
+ export interface AttachmentInfo {
4
+ filename: string;
5
+ mimeType?: string;
6
+ size: number;
7
+ attachmentId?: string | null;
8
+ partId?: string;
9
+ }
10
+ /** Download result */
11
+ export interface DownloadResult {
12
+ ok: boolean;
13
+ filename: string;
14
+ savedTo?: string;
15
+ size?: number;
16
+ error?: string;
17
+ }
18
+ /** Download all result */
19
+ export interface DownloadAllResult {
20
+ ok: boolean;
21
+ message?: string;
22
+ downloaded: DownloadResult[];
23
+ }
24
+ /**
25
+ * List attachments in a message
26
+ */
27
+ export declare function list(messageId: string, options?: OutputOptions): Promise<AttachmentInfo[]>;
28
+ /**
29
+ * Download an attachment
30
+ */
31
+ export declare function download(messageId: string, filename?: string, outputPath?: string, options?: OutputOptions): Promise<DownloadResult>;
32
+ /**
33
+ * Download all attachments from a message
34
+ */
35
+ export declare function downloadAll(messageId: string, outputDir?: string, options?: OutputOptions): Promise<DownloadAllResult>;
@@ -0,0 +1,148 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getGmail, output, decodeBase64Url } from './api.js';
4
+ /**
5
+ * Extract attachment info from message payload
6
+ */
7
+ function extractAttachmentInfo(payload, attachments = []) {
8
+ if (!payload)
9
+ return attachments;
10
+ // Check if this part is an attachment
11
+ if (payload.filename && payload.filename.length > 0) {
12
+ attachments.push({
13
+ filename: payload.filename,
14
+ mimeType: payload.mimeType,
15
+ size: payload.body?.size || 0,
16
+ attachmentId: payload.body?.attachmentId || null,
17
+ partId: payload.partId
18
+ });
19
+ }
20
+ // Recursively check parts
21
+ if (payload.parts) {
22
+ for (const part of payload.parts) {
23
+ extractAttachmentInfo(part, attachments);
24
+ }
25
+ }
26
+ return attachments;
27
+ }
28
+ /**
29
+ * List attachments in a message
30
+ */
31
+ export async function list(messageId, options = {}) {
32
+ const gmail = await getGmail();
33
+ const res = await gmail.users.messages.get({
34
+ userId: 'me',
35
+ id: messageId,
36
+ format: 'full'
37
+ });
38
+ const attachments = extractAttachmentInfo(res.data.payload);
39
+ output(attachments, options);
40
+ return attachments;
41
+ }
42
+ /**
43
+ * Download an attachment
44
+ */
45
+ export async function download(messageId, filename, outputPath, options = {}) {
46
+ const gmail = await getGmail();
47
+ // Get message to find attachment
48
+ const res = await gmail.users.messages.get({
49
+ userId: 'me',
50
+ id: messageId,
51
+ format: 'full'
52
+ });
53
+ const attachments = extractAttachmentInfo(res.data.payload);
54
+ // Find the attachment by filename (or get first if not specified)
55
+ let attachment;
56
+ if (filename) {
57
+ attachment = attachments.find(a => a.filename.toLowerCase() === filename.toLowerCase() ||
58
+ a.filename.toLowerCase().includes(filename.toLowerCase()));
59
+ }
60
+ else if (attachments.length === 1) {
61
+ attachment = attachments[0];
62
+ }
63
+ if (!attachment) {
64
+ throw new Error(`Attachment not found: ${filename || '(no attachments)'}`);
65
+ }
66
+ if (!attachment.attachmentId) {
67
+ throw new Error(`Attachment ${attachment.filename} has no downloadable content`);
68
+ }
69
+ // Get the attachment data
70
+ const attachmentRes = await gmail.users.messages.attachments.get({
71
+ userId: 'me',
72
+ messageId: messageId,
73
+ id: attachment.attachmentId
74
+ });
75
+ // Decode and save
76
+ const data = decodeBase64Url(attachmentRes.data.data);
77
+ // SECURITY: Sanitize filename to prevent path traversal
78
+ const safeFilename = path.basename(attachment.filename);
79
+ const savePath = outputPath || safeFilename;
80
+ fs.writeFileSync(savePath, data);
81
+ const result = {
82
+ ok: true,
83
+ filename: attachment.filename,
84
+ savedTo: path.resolve(savePath),
85
+ size: attachment.size
86
+ };
87
+ output(result, options);
88
+ return result;
89
+ }
90
+ /**
91
+ * Download all attachments from a message
92
+ */
93
+ export async function downloadAll(messageId, outputDir, options = {}) {
94
+ const gmail = await getGmail();
95
+ // Ensure output directory exists
96
+ const dir = outputDir || '.';
97
+ if (!fs.existsSync(dir)) {
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ }
100
+ // Get message
101
+ const res = await gmail.users.messages.get({
102
+ userId: 'me',
103
+ id: messageId,
104
+ format: 'full'
105
+ });
106
+ const attachments = extractAttachmentInfo(res.data.payload);
107
+ if (attachments.length === 0) {
108
+ const result = { ok: true, message: 'No attachments found', downloaded: [] };
109
+ output(result, options);
110
+ return result;
111
+ }
112
+ const downloaded = [];
113
+ for (const attachment of attachments) {
114
+ if (!attachment.attachmentId)
115
+ continue;
116
+ try {
117
+ const attachmentRes = await gmail.users.messages.attachments.get({
118
+ userId: 'me',
119
+ messageId: messageId,
120
+ id: attachment.attachmentId
121
+ });
122
+ const data = decodeBase64Url(attachmentRes.data.data);
123
+ // SECURITY: Sanitize filename to prevent path traversal
124
+ const safeFilename = path.basename(attachment.filename);
125
+ const savePath = path.join(dir, safeFilename);
126
+ fs.writeFileSync(savePath, data);
127
+ downloaded.push({
128
+ ok: true,
129
+ filename: attachment.filename,
130
+ savedTo: path.resolve(savePath),
131
+ size: attachment.size
132
+ });
133
+ }
134
+ catch (err) {
135
+ downloaded.push({
136
+ ok: false,
137
+ filename: attachment.filename,
138
+ error: err.message
139
+ });
140
+ }
141
+ }
142
+ const result = {
143
+ ok: true,
144
+ downloaded
145
+ };
146
+ output(result, options);
147
+ return result;
148
+ }
@@ -0,0 +1,89 @@
1
+ import { type OutputOptions } from './api.js';
2
+ /** Attachment path */
3
+ export interface AttachmentPath {
4
+ path: string;
5
+ mimeType?: string;
6
+ }
7
+ /** Draft data */
8
+ export interface DraftData {
9
+ id: string;
10
+ messageId?: string;
11
+ threadId?: string;
12
+ to: string;
13
+ cc?: string | null;
14
+ subject: string;
15
+ snippet?: string;
16
+ body?: string;
17
+ }
18
+ /** Draft result */
19
+ export interface DraftResult {
20
+ ok: boolean;
21
+ id?: string;
22
+ messageId?: string;
23
+ threadId?: string;
24
+ to?: string;
25
+ subject?: string;
26
+ inReplyTo?: string;
27
+ deleted?: string;
28
+ sentDraft?: string;
29
+ }
30
+ /** Draft options */
31
+ export interface DraftOptions extends OutputOptions {
32
+ cc?: string;
33
+ bcc?: string;
34
+ attach?: string | string[];
35
+ limit?: number;
36
+ }
37
+ /**
38
+ * Create multipart message with attachment
39
+ */
40
+ export declare function createMultipartMessage({ to, cc, bcc, subject, body, inReplyTo, references, attachments }: {
41
+ to: string;
42
+ cc?: string;
43
+ bcc?: string;
44
+ subject: string;
45
+ body: string;
46
+ inReplyTo?: string;
47
+ references?: string;
48
+ attachments?: AttachmentPath[];
49
+ }): string;
50
+ /**
51
+ * Simple message without attachments
52
+ */
53
+ export declare function createSimpleMessage({ to, cc, bcc, subject, body, inReplyTo, references }: {
54
+ to: string;
55
+ cc?: string;
56
+ bcc?: string;
57
+ subject: string;
58
+ body: string;
59
+ inReplyTo?: string;
60
+ references?: string;
61
+ }): string;
62
+ /**
63
+ * List all drafts
64
+ */
65
+ export declare function list(options?: DraftOptions): Promise<DraftData[]>;
66
+ /**
67
+ * Get a draft
68
+ */
69
+ export declare function get(draftId: string, options?: OutputOptions): Promise<DraftData>;
70
+ /**
71
+ * Create a new draft
72
+ */
73
+ export declare function create(to: string, subject: string, body: string, options?: DraftOptions): Promise<DraftResult>;
74
+ /**
75
+ * Create a draft reply to a message
76
+ */
77
+ export declare function createReply(messageId: string, body: string, options?: DraftOptions): Promise<DraftResult>;
78
+ /**
79
+ * Update a draft
80
+ */
81
+ export declare function update(draftId: string, to: string, subject: string, body: string, options?: DraftOptions): Promise<DraftResult>;
82
+ /**
83
+ * Delete a draft
84
+ */
85
+ export declare function remove(draftId: string, options?: OutputOptions): Promise<DraftResult>;
86
+ /**
87
+ * Send a draft
88
+ */
89
+ export declare function send(draftId: string, options?: OutputOptions): Promise<DraftResult>;