@hasna/connectors 1.3.13 → 1.3.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/bin/index.js +1 -1
- package/bin/mcp.js +62 -17
- package/connectors/connect-gmail/bin/index.js +7027 -0
- package/connectors/connect-gmail/dist/index.js +2174 -0
- package/connectors/connect-gmail/package.json +1 -1
- package/connectors/connect-gmail/src/api/client.ts +13 -3
- package/connectors/connect-gmail/src/api/index.ts +98 -3
- package/connectors/connect-gmail/src/index.ts +4 -0
- package/connectors/connect-gmail/src/utils/auth.ts +31 -18
- package/connectors/connect-stripe/package.json +1 -1
- package/connectors/connect-stripe/src/api/index.ts +9 -2
- package/connectors/connect-stripe/src/index.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,2174 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/types/index.ts
|
|
3
|
+
class GmailApiError extends Error {
|
|
4
|
+
statusCode;
|
|
5
|
+
errors;
|
|
6
|
+
constructor(message, statusCode, errors) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "GmailApiError";
|
|
9
|
+
this.statusCode = statusCode;
|
|
10
|
+
this.errors = errors;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/utils/auth.ts
|
|
15
|
+
import { createServer } from "http";
|
|
16
|
+
|
|
17
|
+
// src/utils/config.ts
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, renameSync, cpSync } from "fs";
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
var DEFAULT_PROFILE = "default";
|
|
22
|
+
var CURRENT_PROFILE_FILE = "current_profile";
|
|
23
|
+
var PROFILES_DIR = "profiles";
|
|
24
|
+
var profileOverride;
|
|
25
|
+
function resolveBaseConfigDir() {
|
|
26
|
+
return join(homedir(), ".connectors", "connect-gmail");
|
|
27
|
+
}
|
|
28
|
+
var BASE_CONFIG_DIR = resolveBaseConfigDir();
|
|
29
|
+
function ensureBaseConfigDir() {
|
|
30
|
+
if (!existsSync(BASE_CONFIG_DIR)) {
|
|
31
|
+
mkdirSync(BASE_CONFIG_DIR, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function getProfilesDir() {
|
|
35
|
+
return join(BASE_CONFIG_DIR, PROFILES_DIR);
|
|
36
|
+
}
|
|
37
|
+
function getCurrentProfileFile() {
|
|
38
|
+
return join(BASE_CONFIG_DIR, CURRENT_PROFILE_FILE);
|
|
39
|
+
}
|
|
40
|
+
function migrateToProfileStructure() {
|
|
41
|
+
const profilesDir = getProfilesDir();
|
|
42
|
+
const defaultProfileDir = join(profilesDir, DEFAULT_PROFILE);
|
|
43
|
+
if (existsSync(profilesDir)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const oldConfigFile = join(BASE_CONFIG_DIR, "config.json");
|
|
47
|
+
const oldTokensFile = join(BASE_CONFIG_DIR, "tokens.json");
|
|
48
|
+
const oldSettingsFile = join(BASE_CONFIG_DIR, "settings.json");
|
|
49
|
+
const oldContactsDir = join(BASE_CONFIG_DIR, "contacts");
|
|
50
|
+
const hasOldStructure = existsSync(oldConfigFile) || existsSync(oldTokensFile) || existsSync(oldSettingsFile) || existsSync(oldContactsDir);
|
|
51
|
+
if (!hasOldStructure) {
|
|
52
|
+
mkdirSync(profilesDir, { recursive: true });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
mkdirSync(defaultProfileDir, { recursive: true });
|
|
56
|
+
if (existsSync(oldConfigFile)) {
|
|
57
|
+
renameSync(oldConfigFile, join(defaultProfileDir, "config.json"));
|
|
58
|
+
}
|
|
59
|
+
if (existsSync(oldTokensFile)) {
|
|
60
|
+
renameSync(oldTokensFile, join(defaultProfileDir, "tokens.json"));
|
|
61
|
+
}
|
|
62
|
+
if (existsSync(oldSettingsFile)) {
|
|
63
|
+
renameSync(oldSettingsFile, join(defaultProfileDir, "settings.json"));
|
|
64
|
+
}
|
|
65
|
+
if (existsSync(oldContactsDir)) {
|
|
66
|
+
cpSync(oldContactsDir, join(defaultProfileDir, "contacts"), { recursive: true });
|
|
67
|
+
rmSync(oldContactsDir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
writeFileSync(getCurrentProfileFile(), DEFAULT_PROFILE);
|
|
70
|
+
}
|
|
71
|
+
function getCurrentProfile() {
|
|
72
|
+
if (profileOverride) {
|
|
73
|
+
return profileOverride;
|
|
74
|
+
}
|
|
75
|
+
ensureBaseConfigDir();
|
|
76
|
+
migrateToProfileStructure();
|
|
77
|
+
const currentProfileFile = getCurrentProfileFile();
|
|
78
|
+
if (existsSync(currentProfileFile)) {
|
|
79
|
+
try {
|
|
80
|
+
const profile = readFileSync(currentProfileFile, "utf-8").trim();
|
|
81
|
+
if (profile && profileExists(profile)) {
|
|
82
|
+
return profile;
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
return DEFAULT_PROFILE;
|
|
87
|
+
}
|
|
88
|
+
function profileExists(profile) {
|
|
89
|
+
const profileDir = join(getProfilesDir(), profile);
|
|
90
|
+
return existsSync(profileDir);
|
|
91
|
+
}
|
|
92
|
+
function listProfiles() {
|
|
93
|
+
ensureBaseConfigDir();
|
|
94
|
+
migrateToProfileStructure();
|
|
95
|
+
const profilesDir = getProfilesDir();
|
|
96
|
+
if (!existsSync(profilesDir)) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
return readdirSync(profilesDir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name).sort();
|
|
100
|
+
}
|
|
101
|
+
function resolveConfigDir() {
|
|
102
|
+
ensureBaseConfigDir();
|
|
103
|
+
migrateToProfileStructure();
|
|
104
|
+
const profile = getCurrentProfile();
|
|
105
|
+
const profileDir = join(getProfilesDir(), profile);
|
|
106
|
+
if (!existsSync(profileDir)) {
|
|
107
|
+
mkdirSync(profileDir, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
return profileDir;
|
|
110
|
+
}
|
|
111
|
+
function getConfigDirInternal() {
|
|
112
|
+
return resolveConfigDir();
|
|
113
|
+
}
|
|
114
|
+
function getConfigDir() {
|
|
115
|
+
return getConfigDirInternal();
|
|
116
|
+
}
|
|
117
|
+
function ensureConfigDir() {
|
|
118
|
+
const configDir = getConfigDirInternal();
|
|
119
|
+
if (!existsSync(configDir)) {
|
|
120
|
+
mkdirSync(configDir, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function getExportsDir() {
|
|
124
|
+
return join(getConfigDirInternal(), "exports");
|
|
125
|
+
}
|
|
126
|
+
function ensureExportsDir() {
|
|
127
|
+
const dir = getExportsDir();
|
|
128
|
+
if (!existsSync(dir)) {
|
|
129
|
+
mkdirSync(dir, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
return dir;
|
|
132
|
+
}
|
|
133
|
+
function loadConfig() {
|
|
134
|
+
ensureConfigDir();
|
|
135
|
+
const configFile = join(getConfigDirInternal(), "config.json");
|
|
136
|
+
if (!existsSync(configFile)) {
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const content = readFileSync(configFile, "utf-8");
|
|
141
|
+
return JSON.parse(content);
|
|
142
|
+
} catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function saveConfig(config) {
|
|
147
|
+
ensureConfigDir();
|
|
148
|
+
const configFile = join(getConfigDirInternal(), "config.json");
|
|
149
|
+
writeFileSync(configFile, JSON.stringify(config, null, 2));
|
|
150
|
+
}
|
|
151
|
+
function loadBaseConfig() {
|
|
152
|
+
ensureBaseConfigDir();
|
|
153
|
+
const configFile = join(BASE_CONFIG_DIR, "credentials.json");
|
|
154
|
+
if (!existsSync(configFile)) {
|
|
155
|
+
const profiles = listProfiles();
|
|
156
|
+
for (const profile of profiles) {
|
|
157
|
+
const profileConfigFile = join(getProfilesDir(), profile, "config.json");
|
|
158
|
+
if (existsSync(profileConfigFile)) {
|
|
159
|
+
try {
|
|
160
|
+
const content = readFileSync(profileConfigFile, "utf-8");
|
|
161
|
+
const profileConfig = JSON.parse(content);
|
|
162
|
+
if (profileConfig.clientId && profileConfig.clientSecret) {
|
|
163
|
+
writeFileSync(configFile, JSON.stringify({
|
|
164
|
+
clientId: profileConfig.clientId,
|
|
165
|
+
clientSecret: profileConfig.clientSecret
|
|
166
|
+
}, null, 2));
|
|
167
|
+
return { clientId: profileConfig.clientId, clientSecret: profileConfig.clientSecret };
|
|
168
|
+
}
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const content = readFileSync(configFile, "utf-8");
|
|
176
|
+
return JSON.parse(content);
|
|
177
|
+
} catch {
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function getClientId() {
|
|
182
|
+
return process.env.GMAIL_CLIENT_ID || loadBaseConfig().clientId;
|
|
183
|
+
}
|
|
184
|
+
function getClientSecret() {
|
|
185
|
+
return process.env.GMAIL_CLIENT_SECRET || loadBaseConfig().clientSecret;
|
|
186
|
+
}
|
|
187
|
+
function loadTokens() {
|
|
188
|
+
ensureConfigDir();
|
|
189
|
+
const tokensFile = join(getConfigDirInternal(), "tokens.json");
|
|
190
|
+
if (!existsSync(tokensFile)) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const content = readFileSync(tokensFile, "utf-8");
|
|
195
|
+
return JSON.parse(content);
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function saveTokens(tokens) {
|
|
201
|
+
ensureConfigDir();
|
|
202
|
+
const tokensFile = join(getConfigDirInternal(), "tokens.json");
|
|
203
|
+
writeFileSync(tokensFile, JSON.stringify(tokens, null, 2), { mode: 384 });
|
|
204
|
+
}
|
|
205
|
+
function getUserEmail() {
|
|
206
|
+
return loadConfig().userEmail;
|
|
207
|
+
}
|
|
208
|
+
function getUserName() {
|
|
209
|
+
return loadConfig().userName;
|
|
210
|
+
}
|
|
211
|
+
function getFormattedSender() {
|
|
212
|
+
const email = getUserEmail();
|
|
213
|
+
const name = getUserName();
|
|
214
|
+
if (!email) {
|
|
215
|
+
throw new Error("User email not configured");
|
|
216
|
+
}
|
|
217
|
+
if (name) {
|
|
218
|
+
return `"${name}" <${email}>`;
|
|
219
|
+
}
|
|
220
|
+
return email;
|
|
221
|
+
}
|
|
222
|
+
function clearTokens() {
|
|
223
|
+
const tokensFile = join(getConfigDirInternal(), "tokens.json");
|
|
224
|
+
if (existsSync(tokensFile)) {
|
|
225
|
+
writeFileSync(tokensFile, "{}");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function clearConfig() {
|
|
229
|
+
saveConfig({});
|
|
230
|
+
clearTokens();
|
|
231
|
+
}
|
|
232
|
+
function isAuthenticated() {
|
|
233
|
+
const tokens = loadTokens();
|
|
234
|
+
return tokens !== null && tokens.accessToken !== undefined && tokens.refreshToken !== undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/utils/auth.ts
|
|
238
|
+
var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
239
|
+
var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
240
|
+
var GMAIL_SCOPES = [
|
|
241
|
+
"https://www.googleapis.com/auth/gmail.readonly",
|
|
242
|
+
"https://www.googleapis.com/auth/gmail.send",
|
|
243
|
+
"https://www.googleapis.com/auth/gmail.compose",
|
|
244
|
+
"https://www.googleapis.com/auth/gmail.modify",
|
|
245
|
+
"https://www.googleapis.com/auth/gmail.labels",
|
|
246
|
+
"https://www.googleapis.com/auth/gmail.settings.basic",
|
|
247
|
+
"https://mail.google.com/"
|
|
248
|
+
].join(" ");
|
|
249
|
+
var REDIRECT_PORT = 8089;
|
|
250
|
+
var REDIRECT_URI = `http://127.0.0.1:${REDIRECT_PORT}`;
|
|
251
|
+
function getAuthUrl() {
|
|
252
|
+
const clientId = getClientId();
|
|
253
|
+
if (!clientId) {
|
|
254
|
+
throw new Error('Client ID not configured. Run "connect-gmail config set-credentials" first.');
|
|
255
|
+
}
|
|
256
|
+
const params = new URLSearchParams({
|
|
257
|
+
client_id: clientId,
|
|
258
|
+
redirect_uri: REDIRECT_URI,
|
|
259
|
+
response_type: "code",
|
|
260
|
+
scope: GMAIL_SCOPES,
|
|
261
|
+
access_type: "offline",
|
|
262
|
+
prompt: "consent"
|
|
263
|
+
});
|
|
264
|
+
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
|
265
|
+
}
|
|
266
|
+
async function exchangeCodeForTokens(code) {
|
|
267
|
+
const clientId = getClientId();
|
|
268
|
+
const clientSecret = getClientSecret();
|
|
269
|
+
if (!clientId || !clientSecret) {
|
|
270
|
+
throw new Error("OAuth credentials not configured");
|
|
271
|
+
}
|
|
272
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: {
|
|
275
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
276
|
+
},
|
|
277
|
+
body: new URLSearchParams({
|
|
278
|
+
code,
|
|
279
|
+
client_id: clientId,
|
|
280
|
+
client_secret: clientSecret,
|
|
281
|
+
redirect_uri: REDIRECT_URI,
|
|
282
|
+
grant_type: "authorization_code"
|
|
283
|
+
})
|
|
284
|
+
});
|
|
285
|
+
if (!response.ok) {
|
|
286
|
+
const errorText = await response.text();
|
|
287
|
+
let errorMessage = `Token exchange failed: ${response.status} ${response.statusText}`;
|
|
288
|
+
try {
|
|
289
|
+
const error = JSON.parse(errorText);
|
|
290
|
+
errorMessage = `Token exchange failed: ${error.error_description || error.error || response.statusText}`;
|
|
291
|
+
console.error("Token exchange error details:", error);
|
|
292
|
+
} catch {
|
|
293
|
+
errorMessage = `Token exchange failed: ${errorText || response.statusText}`;
|
|
294
|
+
}
|
|
295
|
+
throw new Error(errorMessage);
|
|
296
|
+
}
|
|
297
|
+
const data = await response.json();
|
|
298
|
+
const tokens = {
|
|
299
|
+
accessToken: data.access_token,
|
|
300
|
+
refreshToken: data.refresh_token,
|
|
301
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
302
|
+
tokenType: data.token_type,
|
|
303
|
+
scope: data.scope
|
|
304
|
+
};
|
|
305
|
+
return tokens;
|
|
306
|
+
}
|
|
307
|
+
async function refreshTokens(clientId, clientSecret, refreshToken, currentScope) {
|
|
308
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: {
|
|
311
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
312
|
+
},
|
|
313
|
+
body: new URLSearchParams({
|
|
314
|
+
client_id: clientId,
|
|
315
|
+
client_secret: clientSecret,
|
|
316
|
+
refresh_token: refreshToken,
|
|
317
|
+
grant_type: "refresh_token"
|
|
318
|
+
})
|
|
319
|
+
});
|
|
320
|
+
if (!response.ok) {
|
|
321
|
+
let errorMessage = `Token refresh failed: ${response.status} ${response.statusText}`;
|
|
322
|
+
try {
|
|
323
|
+
const errorBody = await response.json();
|
|
324
|
+
const detail = errorBody.error_description || errorBody.error;
|
|
325
|
+
if (detail) {
|
|
326
|
+
errorMessage = `Token refresh failed: ${detail}. Please run "connect-gmail auth login" again.`;
|
|
327
|
+
}
|
|
328
|
+
} catch {}
|
|
329
|
+
throw new Error(errorMessage);
|
|
330
|
+
}
|
|
331
|
+
const data = await response.json();
|
|
332
|
+
return {
|
|
333
|
+
accessToken: data.access_token,
|
|
334
|
+
refreshToken,
|
|
335
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
336
|
+
tokenType: data.token_type,
|
|
337
|
+
scope: data.scope || currentScope || ""
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
async function refreshAccessToken() {
|
|
341
|
+
const clientId = getClientId();
|
|
342
|
+
const clientSecret = getClientSecret();
|
|
343
|
+
const currentTokens = loadTokens();
|
|
344
|
+
if (!clientId || !clientSecret) {
|
|
345
|
+
throw new Error("OAuth credentials not configured");
|
|
346
|
+
}
|
|
347
|
+
if (!currentTokens?.refreshToken) {
|
|
348
|
+
throw new Error("No refresh token available. Please login again.");
|
|
349
|
+
}
|
|
350
|
+
const tokens = await refreshTokens(clientId, clientSecret, currentTokens.refreshToken, currentTokens.scope);
|
|
351
|
+
saveTokens(tokens);
|
|
352
|
+
return tokens;
|
|
353
|
+
}
|
|
354
|
+
function startCallbackServer() {
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
const server = createServer(async (req, res) => {
|
|
357
|
+
const url = new URL(req.url || "", `http://127.0.0.1:${REDIRECT_PORT}`);
|
|
358
|
+
if (url.pathname === "/" || url.pathname === "/callback") {
|
|
359
|
+
const code = url.searchParams.get("code");
|
|
360
|
+
const error = url.searchParams.get("error");
|
|
361
|
+
if (error) {
|
|
362
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
363
|
+
res.end(`
|
|
364
|
+
<html>
|
|
365
|
+
<body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
|
|
366
|
+
<div style="text-align: center;">
|
|
367
|
+
<h1 style="color: #dc3545;">Authentication Failed</h1>
|
|
368
|
+
<p>Error: ${error}</p>
|
|
369
|
+
<p>You can close this window.</p>
|
|
370
|
+
</div>
|
|
371
|
+
</body>
|
|
372
|
+
</html>
|
|
373
|
+
`);
|
|
374
|
+
server.close();
|
|
375
|
+
resolve({ success: false, error });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (code) {
|
|
379
|
+
try {
|
|
380
|
+
const tokens = await exchangeCodeForTokens(code);
|
|
381
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
382
|
+
res.end(`
|
|
383
|
+
<html>
|
|
384
|
+
<body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
|
|
385
|
+
<div style="text-align: center;">
|
|
386
|
+
<h1 style="color: #28a745;">Authentication Successful!</h1>
|
|
387
|
+
<p>You can close this window and return to the terminal.</p>
|
|
388
|
+
</div>
|
|
389
|
+
</body>
|
|
390
|
+
</html>
|
|
391
|
+
`);
|
|
392
|
+
server.close();
|
|
393
|
+
resolve({ success: true, tokens });
|
|
394
|
+
} catch (err) {
|
|
395
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
396
|
+
res.end(`
|
|
397
|
+
<html>
|
|
398
|
+
<body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
|
|
399
|
+
<div style="text-align: center;">
|
|
400
|
+
<h1 style="color: #dc3545;">Authentication Failed</h1>
|
|
401
|
+
<p>Error: ${String(err)}</p>
|
|
402
|
+
<p>You can close this window.</p>
|
|
403
|
+
</div>
|
|
404
|
+
</body>
|
|
405
|
+
</html>
|
|
406
|
+
`);
|
|
407
|
+
server.close();
|
|
408
|
+
resolve({ success: false, error: String(err) });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
server.listen(REDIRECT_PORT, () => {});
|
|
414
|
+
setTimeout(() => {
|
|
415
|
+
server.close();
|
|
416
|
+
resolve({ success: false, error: "Authentication timed out" });
|
|
417
|
+
}, 5 * 60 * 1000);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
async function getValidAccessToken() {
|
|
421
|
+
const tokens = loadTokens();
|
|
422
|
+
if (!tokens) {
|
|
423
|
+
throw new Error('Not authenticated. Run "connect-gmail auth login" first.');
|
|
424
|
+
}
|
|
425
|
+
if (Date.now() >= tokens.expiresAt - 5 * 60 * 1000) {
|
|
426
|
+
const newTokens = await refreshAccessToken();
|
|
427
|
+
return newTokens.accessToken;
|
|
428
|
+
}
|
|
429
|
+
return tokens.accessToken;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/api/client.ts
|
|
433
|
+
var GMAIL_API_BASE = "https://gmail.googleapis.com/gmail/v1";
|
|
434
|
+
|
|
435
|
+
class GmailClient {
|
|
436
|
+
accessToken;
|
|
437
|
+
userId = "me";
|
|
438
|
+
tokenProvider;
|
|
439
|
+
constructor(options) {
|
|
440
|
+
this.tokenProvider = options?.tokenProvider;
|
|
441
|
+
}
|
|
442
|
+
setUserId(userId) {
|
|
443
|
+
this.userId = userId;
|
|
444
|
+
}
|
|
445
|
+
getUserId() {
|
|
446
|
+
return this.userId;
|
|
447
|
+
}
|
|
448
|
+
buildUrl(path, params) {
|
|
449
|
+
const url = new URL(`${GMAIL_API_BASE}${path}`);
|
|
450
|
+
if (params) {
|
|
451
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
452
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
453
|
+
url.searchParams.append(key, String(value));
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return url.toString();
|
|
458
|
+
}
|
|
459
|
+
async request(path, options = {}) {
|
|
460
|
+
const { method = "GET", params, body, headers = {} } = options;
|
|
461
|
+
const accessToken = this.tokenProvider ? await this.tokenProvider() : await getValidAccessToken();
|
|
462
|
+
const url = this.buildUrl(path, params);
|
|
463
|
+
const requestHeaders = {
|
|
464
|
+
Authorization: `Bearer ${accessToken}`,
|
|
465
|
+
Accept: "application/json",
|
|
466
|
+
...headers
|
|
467
|
+
};
|
|
468
|
+
if (body && ["POST", "PUT", "PATCH"].includes(method)) {
|
|
469
|
+
if (typeof body === "string") {
|
|
470
|
+
requestHeaders["Content-Type"] = "message/rfc822";
|
|
471
|
+
} else {
|
|
472
|
+
requestHeaders["Content-Type"] = "application/json";
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const fetchOptions = {
|
|
476
|
+
method,
|
|
477
|
+
headers: requestHeaders
|
|
478
|
+
};
|
|
479
|
+
if (body && ["POST", "PUT", "PATCH"].includes(method)) {
|
|
480
|
+
fetchOptions.body = typeof body === "string" ? body : JSON.stringify(body);
|
|
481
|
+
}
|
|
482
|
+
const response = await fetch(url, fetchOptions);
|
|
483
|
+
if (response.status === 204) {
|
|
484
|
+
return {};
|
|
485
|
+
}
|
|
486
|
+
let data;
|
|
487
|
+
const contentType = response.headers.get("content-type") || "";
|
|
488
|
+
if (contentType.includes("application/json")) {
|
|
489
|
+
const text = await response.text();
|
|
490
|
+
if (text) {
|
|
491
|
+
try {
|
|
492
|
+
data = JSON.parse(text);
|
|
493
|
+
} catch {
|
|
494
|
+
data = text;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} else {
|
|
498
|
+
data = await response.text();
|
|
499
|
+
}
|
|
500
|
+
if (!response.ok) {
|
|
501
|
+
const errorData = data;
|
|
502
|
+
const errorMessage = errorData?.error?.message || String(data || response.statusText);
|
|
503
|
+
throw new GmailApiError(errorMessage, response.status, errorData?.error?.errors);
|
|
504
|
+
}
|
|
505
|
+
return data;
|
|
506
|
+
}
|
|
507
|
+
async get(path, params) {
|
|
508
|
+
return this.request(path, { method: "GET", params });
|
|
509
|
+
}
|
|
510
|
+
async post(path, body, params) {
|
|
511
|
+
return this.request(path, { method: "POST", body, params });
|
|
512
|
+
}
|
|
513
|
+
async put(path, body, params) {
|
|
514
|
+
return this.request(path, { method: "PUT", body, params });
|
|
515
|
+
}
|
|
516
|
+
async patch(path, body, params) {
|
|
517
|
+
return this.request(path, { method: "PATCH", body, params });
|
|
518
|
+
}
|
|
519
|
+
async delete(path, params) {
|
|
520
|
+
return this.request(path, { method: "DELETE", params });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/utils/contacts.ts
|
|
525
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, unlinkSync } from "fs";
|
|
526
|
+
import { join as join2 } from "path";
|
|
527
|
+
function getContactsDir() {
|
|
528
|
+
const dir = join2(getConfigDir(), "contacts");
|
|
529
|
+
if (!existsSync2(dir)) {
|
|
530
|
+
mkdirSync2(dir, { recursive: true });
|
|
531
|
+
}
|
|
532
|
+
return dir;
|
|
533
|
+
}
|
|
534
|
+
function emailToFilename(email) {
|
|
535
|
+
return email.toLowerCase().replace(/[^a-z0-9]/g, "_") + ".json";
|
|
536
|
+
}
|
|
537
|
+
function getContact(email) {
|
|
538
|
+
const contactsDir = getContactsDir();
|
|
539
|
+
const filename = emailToFilename(email);
|
|
540
|
+
const filepath = join2(contactsDir, filename);
|
|
541
|
+
if (!existsSync2(filepath)) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
const content = readFileSync2(filepath, "utf-8");
|
|
546
|
+
return JSON.parse(content);
|
|
547
|
+
} catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function formatEmailWithName(email, fallbackName) {
|
|
552
|
+
const contact = getContact(email);
|
|
553
|
+
let displayName = fallbackName;
|
|
554
|
+
if (contact) {
|
|
555
|
+
if (contact.name) {
|
|
556
|
+
displayName = contact.name;
|
|
557
|
+
} else if (contact.firstName || contact.lastName) {
|
|
558
|
+
displayName = [contact.firstName, contact.lastName].filter(Boolean).join(" ");
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (displayName) {
|
|
562
|
+
return `"${displayName}" <${email}>`;
|
|
563
|
+
}
|
|
564
|
+
return email;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/utils/markdown.ts
|
|
568
|
+
function markdownToHtml(markdown) {
|
|
569
|
+
let html = markdown;
|
|
570
|
+
html = html.replace(/&(?!amp;|lt;|gt;|quot;|#)/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
571
|
+
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
|
|
572
|
+
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
|
|
573
|
+
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
|
|
574
|
+
html = html.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, "<hr>");
|
|
575
|
+
html = html.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
|
|
576
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
577
|
+
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
|
578
|
+
html = html.replace(/___(.+?)___/g, "<strong><em>$1</em></strong>");
|
|
579
|
+
html = html.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
|
580
|
+
html = html.replace(/_(.+?)_/g, "<em>$1</em>");
|
|
581
|
+
html = html.replace(/~~(.+?)~~/g, "<del>$1</del>");
|
|
582
|
+
html = html.replace(/`([^`]+)`/g, '<code style="background:#f4f4f4;padding:2px 4px;border-radius:3px;">$1</code>');
|
|
583
|
+
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
584
|
+
return `<pre style="background:#f4f4f4;padding:12px;border-radius:4px;overflow-x:auto;"><code>${code.trim()}</code></pre>`;
|
|
585
|
+
});
|
|
586
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:#1a73e8;">$1</a>');
|
|
587
|
+
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;">');
|
|
588
|
+
html = html.replace(/^> (.+)$/gm, '<blockquote style="border-left:4px solid #ddd;margin:0;padding-left:16px;color:#666;">$1</blockquote>');
|
|
589
|
+
html = html.replace(/^[\*\-] (.+)$/gm, '<li style="margin:0;padding:0;">$1</li>');
|
|
590
|
+
html = html.replace(/(<li style[^>]*>.*?<\/li>\n?)+/g, '<ul style="margin:4px 0;padding-left:20px;">$&</ul>');
|
|
591
|
+
html = html.replace(/^\d+\. (.+)$/gm, '<li style="margin:0;padding:0;">$1</li>');
|
|
592
|
+
html = html.replace(/\n\n+/g, "</p><p>");
|
|
593
|
+
html = `<p>${html}</p>`;
|
|
594
|
+
html = html.replace(/<\/li>\n<li/g, "</li><li");
|
|
595
|
+
html = html.replace(/<\/li>\n<\/ul>/g, "</li></ul>");
|
|
596
|
+
html = html.replace(/\n/g, "<br>");
|
|
597
|
+
html = html.replace(/<p><\/p>/g, "");
|
|
598
|
+
html = html.replace(/<p>(<h[1-6]>)/g, "$1");
|
|
599
|
+
html = html.replace(/(<\/h[1-6]>)<\/p>/g, "$1");
|
|
600
|
+
html = html.replace(/<p>(<ul)/g, "$1");
|
|
601
|
+
html = html.replace(/(<\/ul>)<\/p>/g, "$1");
|
|
602
|
+
html = html.replace(/<p>(<pre)/g, "$1");
|
|
603
|
+
html = html.replace(/(<\/pre>)<\/p>/g, "$1");
|
|
604
|
+
html = html.replace(/<p>(<blockquote)/g, "$1");
|
|
605
|
+
html = html.replace(/(<\/blockquote>)<\/p>/g, "$1");
|
|
606
|
+
html = html.replace(/<p><hr><\/p>/g, "<hr>");
|
|
607
|
+
return html;
|
|
608
|
+
}
|
|
609
|
+
function wrapInEmailTemplate(html) {
|
|
610
|
+
return `<!DOCTYPE html>
|
|
611
|
+
<html>
|
|
612
|
+
<head>
|
|
613
|
+
<meta charset="UTF-8">
|
|
614
|
+
<style>
|
|
615
|
+
ul, ol { margin: 4px 0; padding-left: 20px; }
|
|
616
|
+
li { margin: 0; padding: 0; line-height: 1.5; }
|
|
617
|
+
p { margin: 0 0 8px 0; }
|
|
618
|
+
</style>
|
|
619
|
+
</head>
|
|
620
|
+
<body style="font-family:Arial,sans-serif;font-size:14px;line-height:1.6;color:#202124;">
|
|
621
|
+
${html}
|
|
622
|
+
</body>
|
|
623
|
+
</html>`;
|
|
624
|
+
}
|
|
625
|
+
function looksLikeMarkdown(text) {
|
|
626
|
+
const markdownPatterns = [
|
|
627
|
+
/^#{1,6} /m,
|
|
628
|
+
/\*\*.+\*\*/,
|
|
629
|
+
/\[.+\]\(.+\)/,
|
|
630
|
+
/^[\*\-] /m,
|
|
631
|
+
/^\d+\. /m,
|
|
632
|
+
/```/,
|
|
633
|
+
/^> /m
|
|
634
|
+
];
|
|
635
|
+
return markdownPatterns.some((pattern) => pattern.test(text));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/utils/settings.ts
|
|
639
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
640
|
+
import { join as join3 } from "path";
|
|
641
|
+
var DEFAULT_SETTINGS = {
|
|
642
|
+
appendSignature: true,
|
|
643
|
+
appendSignatureToReplies: false,
|
|
644
|
+
markdownEnabled: true,
|
|
645
|
+
defaultFormat: "pretty",
|
|
646
|
+
defaultSendAsHtml: true
|
|
647
|
+
};
|
|
648
|
+
function getSettingsPath() {
|
|
649
|
+
return join3(getConfigDir(), "settings.json");
|
|
650
|
+
}
|
|
651
|
+
function loadSettings() {
|
|
652
|
+
ensureConfigDir();
|
|
653
|
+
const filepath = getSettingsPath();
|
|
654
|
+
if (!existsSync3(filepath)) {
|
|
655
|
+
saveSettings(DEFAULT_SETTINGS);
|
|
656
|
+
return DEFAULT_SETTINGS;
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
const content = readFileSync3(filepath, "utf-8");
|
|
660
|
+
const loaded = JSON.parse(content);
|
|
661
|
+
return { ...DEFAULT_SETTINGS, ...loaded };
|
|
662
|
+
} catch {
|
|
663
|
+
return DEFAULT_SETTINGS;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function saveSettings(settings) {
|
|
667
|
+
ensureConfigDir();
|
|
668
|
+
const filepath = getSettingsPath();
|
|
669
|
+
writeFileSync3(filepath, JSON.stringify(settings, null, 2));
|
|
670
|
+
}
|
|
671
|
+
function getSignature() {
|
|
672
|
+
return loadSettings().signature;
|
|
673
|
+
}
|
|
674
|
+
function shouldAppendSignature(isReply = false) {
|
|
675
|
+
const settings = loadSettings();
|
|
676
|
+
if (isReply) {
|
|
677
|
+
return settings.appendSignatureToReplies;
|
|
678
|
+
}
|
|
679
|
+
return settings.appendSignature;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/api/messages.ts
|
|
683
|
+
function encodeHeaderValue(value) {
|
|
684
|
+
if (!/[^\x00-\x7F]/.test(value)) {
|
|
685
|
+
return value;
|
|
686
|
+
}
|
|
687
|
+
const encoded = Buffer.from(value, "utf-8").toString("base64");
|
|
688
|
+
return `=?UTF-8?B?${encoded}?=`;
|
|
689
|
+
}
|
|
690
|
+
function htmlToPlainText(html) {
|
|
691
|
+
return html.replace(/<br\s*\/?>/gi, `
|
|
692
|
+
`).replace(/<\/p>/gi, `
|
|
693
|
+
|
|
694
|
+
`).replace(/<\/div>/gi, `
|
|
695
|
+
`).replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/\n{3,}/g, `
|
|
696
|
+
|
|
697
|
+
`).trim();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
class MessagesApi {
|
|
701
|
+
client;
|
|
702
|
+
constructor(client) {
|
|
703
|
+
this.client = client;
|
|
704
|
+
}
|
|
705
|
+
async list(options = {}) {
|
|
706
|
+
const params = {
|
|
707
|
+
maxResults: options.maxResults || 10,
|
|
708
|
+
pageToken: options.pageToken,
|
|
709
|
+
q: options.q,
|
|
710
|
+
includeSpamTrash: options.includeSpamTrash
|
|
711
|
+
};
|
|
712
|
+
if (options.labelIds && options.labelIds.length > 0) {
|
|
713
|
+
params.labelIds = options.labelIds.join(",");
|
|
714
|
+
}
|
|
715
|
+
return this.client.get(`/users/${this.client.getUserId()}/messages`, params);
|
|
716
|
+
}
|
|
717
|
+
async get(messageId, format = "full") {
|
|
718
|
+
return this.client.get(`/users/${this.client.getUserId()}/messages/${messageId}`, { format });
|
|
719
|
+
}
|
|
720
|
+
async send(options) {
|
|
721
|
+
const message = this.buildRawMessage(options);
|
|
722
|
+
const encodedMessage = Buffer.from(message).toString("base64url");
|
|
723
|
+
const body = {
|
|
724
|
+
raw: encodedMessage
|
|
725
|
+
};
|
|
726
|
+
if (options.threadId) {
|
|
727
|
+
body.threadId = options.threadId;
|
|
728
|
+
}
|
|
729
|
+
return this.client.post(`/users/${this.client.getUserId()}/messages/send`, body);
|
|
730
|
+
}
|
|
731
|
+
async reply(messageId, options) {
|
|
732
|
+
const original = await this.get(messageId, "full");
|
|
733
|
+
const headers = original.payload?.headers || [];
|
|
734
|
+
const getHeader = (name) => headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value || "";
|
|
735
|
+
const originalFrom = getHeader("From");
|
|
736
|
+
const originalTo = getHeader("To");
|
|
737
|
+
const originalSubject = getHeader("Subject");
|
|
738
|
+
const originalMessageId = getHeader("Message-ID") || getHeader("Message-Id");
|
|
739
|
+
const originalReferences = getHeader("References");
|
|
740
|
+
const myEmail = getUserEmail();
|
|
741
|
+
let replyTo = originalFrom;
|
|
742
|
+
if (originalFrom.includes(myEmail || "")) {
|
|
743
|
+
replyTo = originalTo;
|
|
744
|
+
}
|
|
745
|
+
let references = originalReferences ? `${originalReferences} ${originalMessageId}` : originalMessageId;
|
|
746
|
+
let subject = originalSubject;
|
|
747
|
+
if (!subject.toLowerCase().startsWith("re:")) {
|
|
748
|
+
subject = `Re: ${subject}`;
|
|
749
|
+
}
|
|
750
|
+
const message = this.buildRawMessage({
|
|
751
|
+
to: replyTo,
|
|
752
|
+
cc: options.cc,
|
|
753
|
+
bcc: options.bcc,
|
|
754
|
+
subject,
|
|
755
|
+
body: options.body,
|
|
756
|
+
isHtml: options.isHtml,
|
|
757
|
+
threadId: original.threadId,
|
|
758
|
+
inReplyTo: originalMessageId,
|
|
759
|
+
references,
|
|
760
|
+
isReply: true
|
|
761
|
+
});
|
|
762
|
+
const encodedMessage = Buffer.from(message).toString("base64url");
|
|
763
|
+
return this.client.post(`/users/${this.client.getUserId()}/messages/send`, {
|
|
764
|
+
raw: encodedMessage,
|
|
765
|
+
threadId: original.threadId
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
async trash(messageId) {
|
|
769
|
+
return this.client.post(`/users/${this.client.getUserId()}/messages/${messageId}/trash`);
|
|
770
|
+
}
|
|
771
|
+
async untrash(messageId) {
|
|
772
|
+
return this.client.post(`/users/${this.client.getUserId()}/messages/${messageId}/untrash`);
|
|
773
|
+
}
|
|
774
|
+
async delete(messageId) {
|
|
775
|
+
await this.client.delete(`/users/${this.client.getUserId()}/messages/${messageId}`);
|
|
776
|
+
}
|
|
777
|
+
async modify(messageId, addLabelIds, removeLabelIds) {
|
|
778
|
+
return this.client.post(`/users/${this.client.getUserId()}/messages/${messageId}/modify`, {
|
|
779
|
+
addLabelIds: addLabelIds || [],
|
|
780
|
+
removeLabelIds: removeLabelIds || []
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
async addLabel(messageId, labelId) {
|
|
784
|
+
return this.modify(messageId, [labelId], undefined);
|
|
785
|
+
}
|
|
786
|
+
async removeLabel(messageId, labelId) {
|
|
787
|
+
return this.modify(messageId, undefined, [labelId]);
|
|
788
|
+
}
|
|
789
|
+
async addLabels(messageId, labelIds) {
|
|
790
|
+
return this.modify(messageId, labelIds, undefined);
|
|
791
|
+
}
|
|
792
|
+
async removeLabels(messageId, labelIds) {
|
|
793
|
+
return this.modify(messageId, undefined, labelIds);
|
|
794
|
+
}
|
|
795
|
+
async markAsRead(messageId) {
|
|
796
|
+
return this.modify(messageId, undefined, ["UNREAD"]);
|
|
797
|
+
}
|
|
798
|
+
async markAsUnread(messageId) {
|
|
799
|
+
return this.modify(messageId, ["UNREAD"]);
|
|
800
|
+
}
|
|
801
|
+
async star(messageId) {
|
|
802
|
+
return this.modify(messageId, ["STARRED"]);
|
|
803
|
+
}
|
|
804
|
+
async unstar(messageId) {
|
|
805
|
+
return this.modify(messageId, undefined, ["STARRED"]);
|
|
806
|
+
}
|
|
807
|
+
async archive(messageId) {
|
|
808
|
+
return this.modify(messageId, undefined, ["INBOX"]);
|
|
809
|
+
}
|
|
810
|
+
buildRawMessage(options) {
|
|
811
|
+
const settings = loadSettings();
|
|
812
|
+
const isReply = options.isReply || !!options.inReplyTo;
|
|
813
|
+
const formatAddresses = (addresses) => {
|
|
814
|
+
const addrs = Array.isArray(addresses) ? addresses : [addresses];
|
|
815
|
+
return addrs.map((addr) => {
|
|
816
|
+
if (addr.includes("<") && addr.includes(">")) {
|
|
817
|
+
return addr;
|
|
818
|
+
}
|
|
819
|
+
return formatEmailWithName(addr);
|
|
820
|
+
}).join(", ");
|
|
821
|
+
};
|
|
822
|
+
const to = formatAddresses(options.to);
|
|
823
|
+
const cc = options.cc ? formatAddresses(options.cc) : "";
|
|
824
|
+
const bcc = options.bcc ? formatAddresses(options.bcc) : "";
|
|
825
|
+
let body = options.body;
|
|
826
|
+
let isHtml = options.isHtml || false;
|
|
827
|
+
if (settings.markdownEnabled && !isHtml && looksLikeMarkdown(body)) {
|
|
828
|
+
body = markdownToHtml(body);
|
|
829
|
+
isHtml = true;
|
|
830
|
+
}
|
|
831
|
+
if (shouldAppendSignature(isReply)) {
|
|
832
|
+
const signature = getSignature();
|
|
833
|
+
if (signature) {
|
|
834
|
+
if (!isHtml) {
|
|
835
|
+
body = body.replace(/\n/g, "<br>");
|
|
836
|
+
isHtml = true;
|
|
837
|
+
}
|
|
838
|
+
body += `<br><br>${signature}`;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
let from;
|
|
842
|
+
try {
|
|
843
|
+
from = getFormattedSender();
|
|
844
|
+
} catch {
|
|
845
|
+
from = getUserEmail() || "";
|
|
846
|
+
}
|
|
847
|
+
let message = "";
|
|
848
|
+
message += `From: ${from}\r
|
|
849
|
+
`;
|
|
850
|
+
message += `To: ${to}\r
|
|
851
|
+
`;
|
|
852
|
+
if (cc)
|
|
853
|
+
message += `Cc: ${cc}\r
|
|
854
|
+
`;
|
|
855
|
+
if (bcc)
|
|
856
|
+
message += `Bcc: ${bcc}\r
|
|
857
|
+
`;
|
|
858
|
+
message += `Subject: ${encodeHeaderValue(options.subject)}\r
|
|
859
|
+
`;
|
|
860
|
+
if (options.inReplyTo) {
|
|
861
|
+
message += `In-Reply-To: ${options.inReplyTo}\r
|
|
862
|
+
`;
|
|
863
|
+
}
|
|
864
|
+
if (options.references) {
|
|
865
|
+
message += `References: ${options.references}\r
|
|
866
|
+
`;
|
|
867
|
+
}
|
|
868
|
+
message += `MIME-Version: 1.0\r
|
|
869
|
+
`;
|
|
870
|
+
const mixedBoundary = `mixed_${Date.now()}`;
|
|
871
|
+
const altBoundary = `alt_${Date.now()}`;
|
|
872
|
+
if (options.attachments && options.attachments.length > 0) {
|
|
873
|
+
message += `Content-Type: multipart/mixed; boundary="${mixedBoundary}"\r
|
|
874
|
+
\r
|
|
875
|
+
`;
|
|
876
|
+
message += `--${mixedBoundary}\r
|
|
877
|
+
`;
|
|
878
|
+
if (isHtml) {
|
|
879
|
+
const htmlBody = wrapInEmailTemplate(body);
|
|
880
|
+
const plainBody = htmlToPlainText(body);
|
|
881
|
+
message += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r
|
|
882
|
+
\r
|
|
883
|
+
`;
|
|
884
|
+
message += `--${altBoundary}\r
|
|
885
|
+
`;
|
|
886
|
+
message += `Content-Type: text/plain; charset="UTF-8"\r
|
|
887
|
+
\r
|
|
888
|
+
`;
|
|
889
|
+
message += `${plainBody}\r
|
|
890
|
+
`;
|
|
891
|
+
message += `--${altBoundary}\r
|
|
892
|
+
`;
|
|
893
|
+
message += `Content-Type: text/html; charset="UTF-8"\r
|
|
894
|
+
\r
|
|
895
|
+
`;
|
|
896
|
+
message += `${htmlBody}\r
|
|
897
|
+
`;
|
|
898
|
+
message += `--${altBoundary}--\r
|
|
899
|
+
`;
|
|
900
|
+
} else {
|
|
901
|
+
message += `Content-Type: text/plain; charset="UTF-8"\r
|
|
902
|
+
\r
|
|
903
|
+
`;
|
|
904
|
+
message += `${body}\r
|
|
905
|
+
`;
|
|
906
|
+
}
|
|
907
|
+
for (const attachment of options.attachments) {
|
|
908
|
+
message += `--${mixedBoundary}\r
|
|
909
|
+
`;
|
|
910
|
+
message += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r
|
|
911
|
+
`;
|
|
912
|
+
message += `Content-Disposition: attachment; filename="${attachment.filename}"\r
|
|
913
|
+
`;
|
|
914
|
+
message += `Content-Transfer-Encoding: base64\r
|
|
915
|
+
\r
|
|
916
|
+
`;
|
|
917
|
+
message += `${attachment.data}\r
|
|
918
|
+
`;
|
|
919
|
+
}
|
|
920
|
+
message += `--${mixedBoundary}--`;
|
|
921
|
+
} else if (isHtml) {
|
|
922
|
+
const htmlBody = wrapInEmailTemplate(body);
|
|
923
|
+
const plainBody = htmlToPlainText(body);
|
|
924
|
+
message += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r
|
|
925
|
+
\r
|
|
926
|
+
`;
|
|
927
|
+
message += `--${altBoundary}\r
|
|
928
|
+
`;
|
|
929
|
+
message += `Content-Type: text/plain; charset="UTF-8"\r
|
|
930
|
+
\r
|
|
931
|
+
`;
|
|
932
|
+
message += `${plainBody}\r
|
|
933
|
+
`;
|
|
934
|
+
message += `--${altBoundary}\r
|
|
935
|
+
`;
|
|
936
|
+
message += `Content-Type: text/html; charset="UTF-8"\r
|
|
937
|
+
\r
|
|
938
|
+
`;
|
|
939
|
+
message += `${htmlBody}\r
|
|
940
|
+
`;
|
|
941
|
+
message += `--${altBoundary}--`;
|
|
942
|
+
} else {
|
|
943
|
+
message += `Content-Type: text/plain; charset="UTF-8"\r
|
|
944
|
+
\r
|
|
945
|
+
`;
|
|
946
|
+
message += body;
|
|
947
|
+
}
|
|
948
|
+
return message;
|
|
949
|
+
}
|
|
950
|
+
extractBody(message, preferHtml = false) {
|
|
951
|
+
if (!message.payload)
|
|
952
|
+
return "";
|
|
953
|
+
const targetType = preferHtml ? "text/html" : "text/plain";
|
|
954
|
+
const getBaseMime = (mimeType) => {
|
|
955
|
+
if (!mimeType)
|
|
956
|
+
return "";
|
|
957
|
+
return mimeType.split(";")[0].trim().toLowerCase();
|
|
958
|
+
};
|
|
959
|
+
const collectTextParts = (part, results = []) => {
|
|
960
|
+
if (part.body?.data && part.mimeType) {
|
|
961
|
+
const baseMime = getBaseMime(part.mimeType);
|
|
962
|
+
if (baseMime.startsWith("text/")) {
|
|
963
|
+
results.push({
|
|
964
|
+
mimeType: baseMime,
|
|
965
|
+
data: Buffer.from(part.body.data, "base64url").toString("utf-8")
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (part.parts) {
|
|
970
|
+
for (const p of part.parts) {
|
|
971
|
+
collectTextParts(p, results);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return results;
|
|
975
|
+
};
|
|
976
|
+
const textParts = collectTextParts(message.payload);
|
|
977
|
+
const exactMatch = textParts.find((p) => p.mimeType === targetType);
|
|
978
|
+
if (exactMatch) {
|
|
979
|
+
return exactMatch.data;
|
|
980
|
+
}
|
|
981
|
+
const altMatch = textParts.find((p) => p.mimeType.startsWith("text/"));
|
|
982
|
+
return altMatch?.data || "";
|
|
983
|
+
}
|
|
984
|
+
extractInlineImages(message) {
|
|
985
|
+
if (!message.payload)
|
|
986
|
+
return [];
|
|
987
|
+
const images = [];
|
|
988
|
+
const collectImages = (part) => {
|
|
989
|
+
if (part.body?.data && part.mimeType?.startsWith("image/")) {
|
|
990
|
+
const contentIdHeader = part.headers?.find((h) => h.name.toLowerCase() === "content-id");
|
|
991
|
+
if (contentIdHeader) {
|
|
992
|
+
const contentId = contentIdHeader.value.replace(/^<|>$/g, "");
|
|
993
|
+
images.push({
|
|
994
|
+
contentId,
|
|
995
|
+
mimeType: part.mimeType,
|
|
996
|
+
data: part.body.data
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (part.parts) {
|
|
1001
|
+
for (const p of part.parts) {
|
|
1002
|
+
collectImages(p);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
collectImages(message.payload);
|
|
1007
|
+
return images;
|
|
1008
|
+
}
|
|
1009
|
+
getMessageStructure(message) {
|
|
1010
|
+
if (!message.payload)
|
|
1011
|
+
return {};
|
|
1012
|
+
const buildStructure = (part, depth = 0) => {
|
|
1013
|
+
const result = {
|
|
1014
|
+
mimeType: part.mimeType,
|
|
1015
|
+
size: part.body?.size || 0,
|
|
1016
|
+
hasData: !!part.body?.data,
|
|
1017
|
+
hasAttachmentId: !!part.body?.attachmentId
|
|
1018
|
+
};
|
|
1019
|
+
if (part.filename) {
|
|
1020
|
+
result.filename = part.filename;
|
|
1021
|
+
}
|
|
1022
|
+
const contentIdHeader = part.headers?.find((h) => h.name.toLowerCase() === "content-id");
|
|
1023
|
+
if (contentIdHeader) {
|
|
1024
|
+
result.contentId = contentIdHeader.value;
|
|
1025
|
+
}
|
|
1026
|
+
if (part.parts && part.parts.length > 0) {
|
|
1027
|
+
result.parts = part.parts.map((p) => buildStructure(p, depth + 1));
|
|
1028
|
+
}
|
|
1029
|
+
return result;
|
|
1030
|
+
};
|
|
1031
|
+
return buildStructure(message.payload);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/api/labels.ts
|
|
1036
|
+
class LabelsApi {
|
|
1037
|
+
client;
|
|
1038
|
+
constructor(client) {
|
|
1039
|
+
this.client = client;
|
|
1040
|
+
}
|
|
1041
|
+
async list() {
|
|
1042
|
+
return this.client.get(`/users/${this.client.getUserId()}/labels`);
|
|
1043
|
+
}
|
|
1044
|
+
async get(labelId) {
|
|
1045
|
+
return this.client.get(`/users/${this.client.getUserId()}/labels/${labelId}`);
|
|
1046
|
+
}
|
|
1047
|
+
async create(options) {
|
|
1048
|
+
const body = {
|
|
1049
|
+
name: options.name
|
|
1050
|
+
};
|
|
1051
|
+
if (options.messageListVisibility) {
|
|
1052
|
+
body.messageListVisibility = options.messageListVisibility;
|
|
1053
|
+
}
|
|
1054
|
+
if (options.labelListVisibility) {
|
|
1055
|
+
body.labelListVisibility = options.labelListVisibility;
|
|
1056
|
+
}
|
|
1057
|
+
if (options.backgroundColor || options.textColor) {
|
|
1058
|
+
body.color = {
|
|
1059
|
+
backgroundColor: options.backgroundColor,
|
|
1060
|
+
textColor: options.textColor
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
return this.client.post(`/users/${this.client.getUserId()}/labels`, body);
|
|
1064
|
+
}
|
|
1065
|
+
async update(labelId, options) {
|
|
1066
|
+
const body = {
|
|
1067
|
+
id: labelId
|
|
1068
|
+
};
|
|
1069
|
+
if (options.name) {
|
|
1070
|
+
body.name = options.name;
|
|
1071
|
+
}
|
|
1072
|
+
if (options.messageListVisibility) {
|
|
1073
|
+
body.messageListVisibility = options.messageListVisibility;
|
|
1074
|
+
}
|
|
1075
|
+
if (options.labelListVisibility) {
|
|
1076
|
+
body.labelListVisibility = options.labelListVisibility;
|
|
1077
|
+
}
|
|
1078
|
+
if (options.backgroundColor || options.textColor) {
|
|
1079
|
+
body.color = {
|
|
1080
|
+
backgroundColor: options.backgroundColor,
|
|
1081
|
+
textColor: options.textColor
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
return this.client.patch(`/users/${this.client.getUserId()}/labels/${labelId}`, body);
|
|
1085
|
+
}
|
|
1086
|
+
async delete(labelId) {
|
|
1087
|
+
await this.client.delete(`/users/${this.client.getUserId()}/labels/${labelId}`);
|
|
1088
|
+
}
|
|
1089
|
+
async getByName(name) {
|
|
1090
|
+
const { labels } = await this.list();
|
|
1091
|
+
return labels.find((label) => label.name.toLowerCase() === name.toLowerCase());
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// src/api/threads.ts
|
|
1096
|
+
class ThreadsApi {
|
|
1097
|
+
client;
|
|
1098
|
+
constructor(client) {
|
|
1099
|
+
this.client = client;
|
|
1100
|
+
}
|
|
1101
|
+
async list(options = {}) {
|
|
1102
|
+
const params = {
|
|
1103
|
+
maxResults: options.maxResults || 10,
|
|
1104
|
+
pageToken: options.pageToken,
|
|
1105
|
+
q: options.q,
|
|
1106
|
+
includeSpamTrash: options.includeSpamTrash
|
|
1107
|
+
};
|
|
1108
|
+
if (options.labelIds && options.labelIds.length > 0) {
|
|
1109
|
+
params.labelIds = options.labelIds.join(",");
|
|
1110
|
+
}
|
|
1111
|
+
return this.client.get(`/users/${this.client.getUserId()}/threads`, params);
|
|
1112
|
+
}
|
|
1113
|
+
async get(threadId, format = "full") {
|
|
1114
|
+
return this.client.get(`/users/${this.client.getUserId()}/threads/${threadId}`, { format });
|
|
1115
|
+
}
|
|
1116
|
+
async trash(threadId) {
|
|
1117
|
+
return this.client.post(`/users/${this.client.getUserId()}/threads/${threadId}/trash`);
|
|
1118
|
+
}
|
|
1119
|
+
async untrash(threadId) {
|
|
1120
|
+
return this.client.post(`/users/${this.client.getUserId()}/threads/${threadId}/untrash`);
|
|
1121
|
+
}
|
|
1122
|
+
async delete(threadId) {
|
|
1123
|
+
await this.client.delete(`/users/${this.client.getUserId()}/threads/${threadId}`);
|
|
1124
|
+
}
|
|
1125
|
+
async modify(threadId, addLabelIds, removeLabelIds) {
|
|
1126
|
+
return this.client.post(`/users/${this.client.getUserId()}/threads/${threadId}/modify`, {
|
|
1127
|
+
addLabelIds: addLabelIds || [],
|
|
1128
|
+
removeLabelIds: removeLabelIds || []
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// src/api/profile.ts
|
|
1134
|
+
class ProfileApi {
|
|
1135
|
+
client;
|
|
1136
|
+
constructor(client) {
|
|
1137
|
+
this.client = client;
|
|
1138
|
+
}
|
|
1139
|
+
async get() {
|
|
1140
|
+
return this.client.get(`/users/${this.client.getUserId()}/profile`);
|
|
1141
|
+
}
|
|
1142
|
+
async listSendAs() {
|
|
1143
|
+
return this.client.get(`/users/${this.client.getUserId()}/settings/sendAs`);
|
|
1144
|
+
}
|
|
1145
|
+
async getSendAs(sendAsEmail) {
|
|
1146
|
+
return this.client.get(`/users/${this.client.getUserId()}/settings/sendAs/${sendAsEmail}`);
|
|
1147
|
+
}
|
|
1148
|
+
async getPrimarySendAs() {
|
|
1149
|
+
const { sendAs } = await this.listSendAs();
|
|
1150
|
+
return sendAs.find((s) => s.isPrimary || s.isDefault);
|
|
1151
|
+
}
|
|
1152
|
+
async getSignature() {
|
|
1153
|
+
const primary = await this.getPrimarySendAs();
|
|
1154
|
+
return primary?.signature;
|
|
1155
|
+
}
|
|
1156
|
+
async getDisplayName() {
|
|
1157
|
+
const primary = await this.getPrimarySendAs();
|
|
1158
|
+
return primary?.displayName;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// src/api/drafts.ts
|
|
1163
|
+
function encodeHeaderValue2(value) {
|
|
1164
|
+
if (!/[^\x00-\x7F]/.test(value)) {
|
|
1165
|
+
return value;
|
|
1166
|
+
}
|
|
1167
|
+
const encoded = Buffer.from(value, "utf-8").toString("base64");
|
|
1168
|
+
return `=?UTF-8?B?${encoded}?=`;
|
|
1169
|
+
}
|
|
1170
|
+
function htmlToPlainText2(html) {
|
|
1171
|
+
return html.replace(/<br\s*\/?>/gi, `
|
|
1172
|
+
`).replace(/<\/p>/gi, `
|
|
1173
|
+
|
|
1174
|
+
`).replace(/<\/div>/gi, `
|
|
1175
|
+
`).replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/\n{3,}/g, `
|
|
1176
|
+
|
|
1177
|
+
`).trim();
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
class DraftsApi {
|
|
1181
|
+
client;
|
|
1182
|
+
constructor(client) {
|
|
1183
|
+
this.client = client;
|
|
1184
|
+
}
|
|
1185
|
+
async list(maxResults = 10) {
|
|
1186
|
+
return this.client.get(`/users/${this.client.getUserId()}/drafts`, { maxResults });
|
|
1187
|
+
}
|
|
1188
|
+
async get(draftId) {
|
|
1189
|
+
return this.client.get(`/users/${this.client.getUserId()}/drafts/${draftId}`);
|
|
1190
|
+
}
|
|
1191
|
+
async create(options) {
|
|
1192
|
+
const message = this.buildRawMessage(options);
|
|
1193
|
+
const encodedMessage = Buffer.from(message).toString("base64url");
|
|
1194
|
+
return this.client.post(`/users/${this.client.getUserId()}/drafts`, {
|
|
1195
|
+
message: {
|
|
1196
|
+
raw: encodedMessage
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
async update(draftId, options) {
|
|
1201
|
+
const message = this.buildRawMessage(options);
|
|
1202
|
+
const encodedMessage = Buffer.from(message).toString("base64url");
|
|
1203
|
+
return this.client.put(`/users/${this.client.getUserId()}/drafts/${draftId}`, {
|
|
1204
|
+
message: {
|
|
1205
|
+
raw: encodedMessage
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
async delete(draftId) {
|
|
1210
|
+
await this.client.delete(`/users/${this.client.getUserId()}/drafts/${draftId}`);
|
|
1211
|
+
}
|
|
1212
|
+
async send(draftId) {
|
|
1213
|
+
return this.client.post(`/users/${this.client.getUserId()}/drafts/send`, { id: draftId });
|
|
1214
|
+
}
|
|
1215
|
+
buildRawMessage(options) {
|
|
1216
|
+
const settings = loadSettings();
|
|
1217
|
+
const formatAddresses = (addresses) => {
|
|
1218
|
+
const addrs = Array.isArray(addresses) ? addresses : [addresses];
|
|
1219
|
+
return addrs.map((addr) => {
|
|
1220
|
+
if (addr.includes("<") && addr.includes(">")) {
|
|
1221
|
+
return addr;
|
|
1222
|
+
}
|
|
1223
|
+
return formatEmailWithName(addr);
|
|
1224
|
+
}).join(", ");
|
|
1225
|
+
};
|
|
1226
|
+
const to = formatAddresses(options.to);
|
|
1227
|
+
const cc = options.cc ? formatAddresses(options.cc) : "";
|
|
1228
|
+
const bcc = options.bcc ? formatAddresses(options.bcc) : "";
|
|
1229
|
+
let body = options.body;
|
|
1230
|
+
let isHtml = options.isHtml || false;
|
|
1231
|
+
if (settings.markdownEnabled && !isHtml && looksLikeMarkdown(body)) {
|
|
1232
|
+
body = markdownToHtml(body);
|
|
1233
|
+
isHtml = true;
|
|
1234
|
+
}
|
|
1235
|
+
if (shouldAppendSignature(false)) {
|
|
1236
|
+
const signature = getSignature();
|
|
1237
|
+
if (signature) {
|
|
1238
|
+
if (!isHtml) {
|
|
1239
|
+
body = body.replace(/\n/g, "<br>");
|
|
1240
|
+
isHtml = true;
|
|
1241
|
+
}
|
|
1242
|
+
body += `<br><br>${signature}`;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
let from;
|
|
1246
|
+
try {
|
|
1247
|
+
from = getFormattedSender();
|
|
1248
|
+
} catch {
|
|
1249
|
+
from = getUserEmail() || "";
|
|
1250
|
+
}
|
|
1251
|
+
let message = "";
|
|
1252
|
+
message += `From: ${from}\r
|
|
1253
|
+
`;
|
|
1254
|
+
message += `To: ${to}\r
|
|
1255
|
+
`;
|
|
1256
|
+
if (cc)
|
|
1257
|
+
message += `Cc: ${cc}\r
|
|
1258
|
+
`;
|
|
1259
|
+
if (bcc)
|
|
1260
|
+
message += `Bcc: ${bcc}\r
|
|
1261
|
+
`;
|
|
1262
|
+
message += `Subject: ${encodeHeaderValue2(options.subject)}\r
|
|
1263
|
+
`;
|
|
1264
|
+
message += `MIME-Version: 1.0\r
|
|
1265
|
+
`;
|
|
1266
|
+
if (isHtml) {
|
|
1267
|
+
const boundary = `boundary_${Date.now()}`;
|
|
1268
|
+
const htmlBody = wrapInEmailTemplate(body);
|
|
1269
|
+
const plainBody = htmlToPlainText2(body);
|
|
1270
|
+
message += `Content-Type: multipart/alternative; boundary="${boundary}"\r
|
|
1271
|
+
\r
|
|
1272
|
+
`;
|
|
1273
|
+
message += `--${boundary}\r
|
|
1274
|
+
`;
|
|
1275
|
+
message += `Content-Type: text/plain; charset="UTF-8"\r
|
|
1276
|
+
\r
|
|
1277
|
+
`;
|
|
1278
|
+
message += `${plainBody}\r
|
|
1279
|
+
`;
|
|
1280
|
+
message += `--${boundary}\r
|
|
1281
|
+
`;
|
|
1282
|
+
message += `Content-Type: text/html; charset="UTF-8"\r
|
|
1283
|
+
\r
|
|
1284
|
+
`;
|
|
1285
|
+
message += `${htmlBody}\r
|
|
1286
|
+
`;
|
|
1287
|
+
message += `--${boundary}--`;
|
|
1288
|
+
} else {
|
|
1289
|
+
message += `Content-Type: text/plain; charset="UTF-8"\r
|
|
1290
|
+
\r
|
|
1291
|
+
`;
|
|
1292
|
+
message += body;
|
|
1293
|
+
}
|
|
1294
|
+
return message;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// src/api/filters.ts
|
|
1299
|
+
class FiltersApi {
|
|
1300
|
+
client;
|
|
1301
|
+
constructor(client) {
|
|
1302
|
+
this.client = client;
|
|
1303
|
+
}
|
|
1304
|
+
async list() {
|
|
1305
|
+
return this.client.get(`/users/${this.client.getUserId()}/settings/filters`);
|
|
1306
|
+
}
|
|
1307
|
+
async get(filterId) {
|
|
1308
|
+
return this.client.get(`/users/${this.client.getUserId()}/settings/filters/${filterId}`);
|
|
1309
|
+
}
|
|
1310
|
+
async create(options) {
|
|
1311
|
+
return this.client.post(`/users/${this.client.getUserId()}/settings/filters`, {
|
|
1312
|
+
criteria: options.criteria,
|
|
1313
|
+
action: this.buildAction(options.action)
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
async delete(filterId) {
|
|
1317
|
+
await this.client.delete(`/users/${this.client.getUserId()}/settings/filters/${filterId}`);
|
|
1318
|
+
}
|
|
1319
|
+
buildAction(action) {
|
|
1320
|
+
const result = {};
|
|
1321
|
+
if (action.addLabelIds) {
|
|
1322
|
+
result.addLabelIds = action.addLabelIds;
|
|
1323
|
+
}
|
|
1324
|
+
if (action.removeLabelIds) {
|
|
1325
|
+
result.removeLabelIds = action.removeLabelIds;
|
|
1326
|
+
}
|
|
1327
|
+
if (action.forward) {
|
|
1328
|
+
result.forward = action.forward;
|
|
1329
|
+
}
|
|
1330
|
+
if (action.markImportant) {
|
|
1331
|
+
result.addLabelIds = [...result.addLabelIds || [], "IMPORTANT"];
|
|
1332
|
+
}
|
|
1333
|
+
if (action.neverMarkImportant) {
|
|
1334
|
+
result.removeLabelIds = [...result.removeLabelIds || [], "IMPORTANT"];
|
|
1335
|
+
}
|
|
1336
|
+
if (action.markRead) {
|
|
1337
|
+
result.removeLabelIds = [...result.removeLabelIds || [], "UNREAD"];
|
|
1338
|
+
}
|
|
1339
|
+
if (action.archive) {
|
|
1340
|
+
result.removeLabelIds = [...result.removeLabelIds || [], "INBOX"];
|
|
1341
|
+
}
|
|
1342
|
+
if (action.trash) {
|
|
1343
|
+
result.addLabelIds = [...result.addLabelIds || [], "TRASH"];
|
|
1344
|
+
}
|
|
1345
|
+
if (action.star) {
|
|
1346
|
+
result.addLabelIds = [...result.addLabelIds || [], "STARRED"];
|
|
1347
|
+
}
|
|
1348
|
+
if (action.neverSpam) {
|
|
1349
|
+
result.removeLabelIds = [...result.removeLabelIds || [], "SPAM"];
|
|
1350
|
+
}
|
|
1351
|
+
return result;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// src/api/attachments.ts
|
|
1356
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
1357
|
+
import { join as join4 } from "path";
|
|
1358
|
+
class AttachmentsApi {
|
|
1359
|
+
client;
|
|
1360
|
+
constructor(client) {
|
|
1361
|
+
this.client = client;
|
|
1362
|
+
}
|
|
1363
|
+
getAttachmentsDir(messageId) {
|
|
1364
|
+
const dir = join4(getConfigDir(), "attachments", messageId);
|
|
1365
|
+
if (!existsSync4(dir)) {
|
|
1366
|
+
mkdirSync3(dir, { recursive: true });
|
|
1367
|
+
}
|
|
1368
|
+
return dir;
|
|
1369
|
+
}
|
|
1370
|
+
extractAttachments(part, attachments = []) {
|
|
1371
|
+
if (part.body?.attachmentId && part.filename) {
|
|
1372
|
+
attachments.push({
|
|
1373
|
+
attachmentId: part.body.attachmentId,
|
|
1374
|
+
filename: part.filename,
|
|
1375
|
+
mimeType: part.mimeType,
|
|
1376
|
+
size: part.body.size,
|
|
1377
|
+
partId: part.partId
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
if (part.parts) {
|
|
1381
|
+
for (const subpart of part.parts) {
|
|
1382
|
+
this.extractAttachments(subpart, attachments);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
return attachments;
|
|
1386
|
+
}
|
|
1387
|
+
async list(messageId) {
|
|
1388
|
+
const message = await this.client.get(`/users/${this.client.getUserId()}/messages/${messageId}`, { format: "full" });
|
|
1389
|
+
if (!message.payload) {
|
|
1390
|
+
return [];
|
|
1391
|
+
}
|
|
1392
|
+
return this.extractAttachments(message.payload);
|
|
1393
|
+
}
|
|
1394
|
+
async get(messageId, attachmentId) {
|
|
1395
|
+
return this.client.get(`/users/${this.client.getUserId()}/messages/${messageId}/attachments/${attachmentId}`);
|
|
1396
|
+
}
|
|
1397
|
+
async download(messageId, attachmentId, filename, mimeType, outputDir) {
|
|
1398
|
+
const cleanFilename = filename.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ");
|
|
1399
|
+
const data = await this.get(messageId, attachmentId);
|
|
1400
|
+
let dir;
|
|
1401
|
+
if (outputDir) {
|
|
1402
|
+
if (!existsSync4(outputDir)) {
|
|
1403
|
+
mkdirSync3(outputDir, { recursive: true });
|
|
1404
|
+
}
|
|
1405
|
+
dir = outputDir;
|
|
1406
|
+
} else {
|
|
1407
|
+
dir = this.getAttachmentsDir(messageId);
|
|
1408
|
+
}
|
|
1409
|
+
const filepath = join4(dir, cleanFilename);
|
|
1410
|
+
const buffer = Buffer.from(data.data, "base64url");
|
|
1411
|
+
writeFileSync4(filepath, buffer);
|
|
1412
|
+
return {
|
|
1413
|
+
filename: cleanFilename,
|
|
1414
|
+
path: filepath,
|
|
1415
|
+
size: buffer.length,
|
|
1416
|
+
mimeType
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
async downloadAll(messageId, outputDir) {
|
|
1420
|
+
const attachments = await this.list(messageId);
|
|
1421
|
+
const downloaded = [];
|
|
1422
|
+
for (const attachment of attachments) {
|
|
1423
|
+
const result = await this.download(messageId, attachment.attachmentId, attachment.filename, attachment.mimeType, outputDir);
|
|
1424
|
+
downloaded.push(result);
|
|
1425
|
+
}
|
|
1426
|
+
return downloaded;
|
|
1427
|
+
}
|
|
1428
|
+
getStoragePath(messageId) {
|
|
1429
|
+
return this.getAttachmentsDir(messageId);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// src/api/export.ts
|
|
1434
|
+
import { writeFileSync as writeFileSync5, mkdirSync as mkdirSync4, existsSync as existsSync5, appendFileSync } from "fs";
|
|
1435
|
+
import { join as join5, dirname } from "path";
|
|
1436
|
+
class ExportApi {
|
|
1437
|
+
client;
|
|
1438
|
+
constructor(client) {
|
|
1439
|
+
this.client = client;
|
|
1440
|
+
}
|
|
1441
|
+
async exportMessages(options = {}) {
|
|
1442
|
+
const format = options.format || "eml";
|
|
1443
|
+
const messages = await this.getMessages(options);
|
|
1444
|
+
if (messages.length === 0) {
|
|
1445
|
+
const outputDir = options.outputDir || ensureExportsDir();
|
|
1446
|
+
const filename = options.filename || `emails_${new Date().toISOString().split("T")[0]}.${format === "mbox" ? "mbox" : "eml"}`;
|
|
1447
|
+
const filePath = join5(outputDir, filename);
|
|
1448
|
+
writeFileSync5(filePath, "", "utf-8");
|
|
1449
|
+
return {
|
|
1450
|
+
messageCount: 0,
|
|
1451
|
+
filePath,
|
|
1452
|
+
format
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
if (format === "mbox") {
|
|
1456
|
+
return this.exportToMbox(messages, options);
|
|
1457
|
+
} else {
|
|
1458
|
+
return this.exportToEml(messages, options);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
async exportLabel(labelId, options = {}) {
|
|
1462
|
+
return this.exportMessages({
|
|
1463
|
+
...options,
|
|
1464
|
+
labelIds: [labelId]
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
async exportInbox(options = {}) {
|
|
1468
|
+
return this.exportLabel("INBOX", options);
|
|
1469
|
+
}
|
|
1470
|
+
async exportSent(options = {}) {
|
|
1471
|
+
return this.exportLabel("SENT", options);
|
|
1472
|
+
}
|
|
1473
|
+
async exportStarred(options = {}) {
|
|
1474
|
+
return this.exportLabel("STARRED", options);
|
|
1475
|
+
}
|
|
1476
|
+
async exportMessage(messageId, options = {}) {
|
|
1477
|
+
const message = await this.client.get(`/users/${this.client.getUserId()}/messages/${messageId}`, { format: "raw" });
|
|
1478
|
+
const outputDir = options.outputDir || ensureExportsDir();
|
|
1479
|
+
const filename = options.filename || `message_${messageId}.eml`;
|
|
1480
|
+
const filePath = join5(outputDir, filename);
|
|
1481
|
+
if (!existsSync5(dirname(filePath))) {
|
|
1482
|
+
mkdirSync4(dirname(filePath), { recursive: true });
|
|
1483
|
+
}
|
|
1484
|
+
const rawContent = this.decodeBase64Url(message.raw || "");
|
|
1485
|
+
writeFileSync5(filePath, rawContent, "utf-8");
|
|
1486
|
+
return {
|
|
1487
|
+
messageCount: 1,
|
|
1488
|
+
filePath,
|
|
1489
|
+
format: "eml"
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
async exportThread(threadId, options = {}) {
|
|
1493
|
+
const thread = await this.client.get(`/users/${this.client.getUserId()}/threads/${threadId}`, { format: "minimal" });
|
|
1494
|
+
const messageIds = thread.messages.map((m) => m.id);
|
|
1495
|
+
const messages = [];
|
|
1496
|
+
for (const id of messageIds) {
|
|
1497
|
+
const message = await this.client.get(`/users/${this.client.getUserId()}/messages/${id}`, { format: "raw" });
|
|
1498
|
+
messages.push(message);
|
|
1499
|
+
}
|
|
1500
|
+
const format = options.format || "mbox";
|
|
1501
|
+
if (format === "mbox") {
|
|
1502
|
+
return this.exportToMbox(messages, {
|
|
1503
|
+
...options,
|
|
1504
|
+
filename: options.filename || `thread_${threadId}.mbox`
|
|
1505
|
+
});
|
|
1506
|
+
} else {
|
|
1507
|
+
return this.exportToEml(messages, {
|
|
1508
|
+
...options,
|
|
1509
|
+
filename: options.filename || `thread_${threadId}`
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
async exportToEml(messages, options) {
|
|
1514
|
+
const outputDir = options.outputDir || ensureExportsDir();
|
|
1515
|
+
const timestamp = new Date().toISOString().split("T")[0];
|
|
1516
|
+
const exportDir = join5(outputDir, options.filename || `emails_${timestamp}`);
|
|
1517
|
+
if (!existsSync5(exportDir)) {
|
|
1518
|
+
mkdirSync4(exportDir, { recursive: true });
|
|
1519
|
+
}
|
|
1520
|
+
for (let i = 0;i < messages.length; i++) {
|
|
1521
|
+
const message = messages[i];
|
|
1522
|
+
const rawContent = this.decodeBase64Url(message.raw || "");
|
|
1523
|
+
const subjectMatch = rawContent.match(/^Subject:\s*(.+)$/m);
|
|
1524
|
+
let subject = subjectMatch ? subjectMatch[1].trim() : `message_${i + 1}`;
|
|
1525
|
+
subject = this.sanitizeFilename(subject).slice(0, 50);
|
|
1526
|
+
const filename = `${i + 1}_${message.id}_${subject}.eml`;
|
|
1527
|
+
const filePath = join5(exportDir, filename);
|
|
1528
|
+
writeFileSync5(filePath, rawContent, "utf-8");
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
messageCount: messages.length,
|
|
1532
|
+
filePath: exportDir,
|
|
1533
|
+
format: "eml"
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
async exportToMbox(messages, options) {
|
|
1537
|
+
const outputDir = options.outputDir || ensureExportsDir();
|
|
1538
|
+
const filename = options.filename || `emails_${new Date().toISOString().split("T")[0]}.mbox`;
|
|
1539
|
+
const filePath = join5(outputDir, filename);
|
|
1540
|
+
if (!existsSync5(dirname(filePath))) {
|
|
1541
|
+
mkdirSync4(dirname(filePath), { recursive: true });
|
|
1542
|
+
}
|
|
1543
|
+
writeFileSync5(filePath, "", "utf-8");
|
|
1544
|
+
for (const message of messages) {
|
|
1545
|
+
const rawContent = this.decodeBase64Url(message.raw || "");
|
|
1546
|
+
const fromMatch = rawContent.match(/^From:\s*(.+)$/m);
|
|
1547
|
+
let fromAddr = "unknown@unknown.com";
|
|
1548
|
+
if (fromMatch) {
|
|
1549
|
+
const emailMatch = fromMatch[1].match(/<([^>]+)>/) || fromMatch[1].match(/([^\s<>]+@[^\s<>]+)/);
|
|
1550
|
+
if (emailMatch) {
|
|
1551
|
+
fromAddr = emailMatch[1];
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const dateMatch = rawContent.match(/^Date:\s*(.+)$/m);
|
|
1555
|
+
let mboxDate = new Date().toUTCString();
|
|
1556
|
+
if (dateMatch) {
|
|
1557
|
+
try {
|
|
1558
|
+
const parsed = new Date(dateMatch[1]);
|
|
1559
|
+
if (!isNaN(parsed.getTime())) {
|
|
1560
|
+
mboxDate = parsed.toUTCString().replace(/,/g, "").replace(/ GMT$/, "");
|
|
1561
|
+
}
|
|
1562
|
+
} catch {}
|
|
1563
|
+
}
|
|
1564
|
+
const mboxLine = `From ${fromAddr} ${mboxDate}
|
|
1565
|
+
`;
|
|
1566
|
+
appendFileSync(filePath, mboxLine, "utf-8");
|
|
1567
|
+
const escapedContent = rawContent.replace(/^From /gm, ">From ");
|
|
1568
|
+
appendFileSync(filePath, escapedContent, "utf-8");
|
|
1569
|
+
if (!rawContent.endsWith(`
|
|
1570
|
+
`)) {
|
|
1571
|
+
appendFileSync(filePath, `
|
|
1572
|
+
`, "utf-8");
|
|
1573
|
+
}
|
|
1574
|
+
appendFileSync(filePath, `
|
|
1575
|
+
`, "utf-8");
|
|
1576
|
+
}
|
|
1577
|
+
return {
|
|
1578
|
+
messageCount: messages.length,
|
|
1579
|
+
filePath,
|
|
1580
|
+
format: "mbox"
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
async getMessages(options) {
|
|
1584
|
+
const messages = [];
|
|
1585
|
+
let pageToken;
|
|
1586
|
+
const maxResults = options.maxResults || 1000;
|
|
1587
|
+
let fetched = 0;
|
|
1588
|
+
do {
|
|
1589
|
+
const params = {
|
|
1590
|
+
maxResults: Math.min(100, maxResults - fetched),
|
|
1591
|
+
pageToken,
|
|
1592
|
+
q: options.query,
|
|
1593
|
+
includeSpamTrash: false
|
|
1594
|
+
};
|
|
1595
|
+
if (options.labelIds && options.labelIds.length > 0) {
|
|
1596
|
+
params.labelIds = options.labelIds.join(",");
|
|
1597
|
+
}
|
|
1598
|
+
const response = await this.client.get(`/users/${this.client.getUserId()}/messages`, params);
|
|
1599
|
+
if (!response.messages || response.messages.length === 0) {
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
1602
|
+
for (const msg of response.messages) {
|
|
1603
|
+
if (fetched >= maxResults)
|
|
1604
|
+
break;
|
|
1605
|
+
const fullMessage = await this.client.get(`/users/${this.client.getUserId()}/messages/${msg.id}`, { format: "raw" });
|
|
1606
|
+
messages.push(fullMessage);
|
|
1607
|
+
fetched++;
|
|
1608
|
+
}
|
|
1609
|
+
pageToken = response.nextPageToken;
|
|
1610
|
+
} while (pageToken && fetched < maxResults);
|
|
1611
|
+
return messages;
|
|
1612
|
+
}
|
|
1613
|
+
decodeBase64Url(encoded) {
|
|
1614
|
+
let base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
1615
|
+
while (base64.length % 4) {
|
|
1616
|
+
base64 += "=";
|
|
1617
|
+
}
|
|
1618
|
+
return Buffer.from(base64, "base64").toString("utf-8");
|
|
1619
|
+
}
|
|
1620
|
+
sanitizeFilename(name) {
|
|
1621
|
+
return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "").replace(/\s+/g, "_").replace(/^\.+/, "").slice(0, 200);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// src/api/bulk.ts
|
|
1626
|
+
function isLabelId(value) {
|
|
1627
|
+
return value.startsWith("Label_") || /^[A-Z0-9_]+$/.test(value);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
class BulkApi {
|
|
1631
|
+
client;
|
|
1632
|
+
messages;
|
|
1633
|
+
labels;
|
|
1634
|
+
constructor(client) {
|
|
1635
|
+
this.client = client;
|
|
1636
|
+
this.messages = new MessagesApi(client);
|
|
1637
|
+
this.labels = new LabelsApi(client);
|
|
1638
|
+
}
|
|
1639
|
+
async preview(query, maxResults = 50) {
|
|
1640
|
+
const messages = await this.fetchMessages(query, maxResults);
|
|
1641
|
+
return {
|
|
1642
|
+
messages,
|
|
1643
|
+
total: messages.length,
|
|
1644
|
+
query
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
async modifyLabels(options) {
|
|
1648
|
+
const {
|
|
1649
|
+
query,
|
|
1650
|
+
maxResults = 100,
|
|
1651
|
+
concurrency = 10,
|
|
1652
|
+
dryRun = false,
|
|
1653
|
+
addLabelIds = [],
|
|
1654
|
+
removeLabelIds = [],
|
|
1655
|
+
addLabels = [],
|
|
1656
|
+
removeLabels = [],
|
|
1657
|
+
skipIfLabeled = false,
|
|
1658
|
+
offset = 0,
|
|
1659
|
+
onProgress,
|
|
1660
|
+
onError
|
|
1661
|
+
} = options;
|
|
1662
|
+
const resolvedAddIds = [...addLabelIds];
|
|
1663
|
+
const resolvedRemoveIds = [...removeLabelIds];
|
|
1664
|
+
if (addLabels.length > 0 || removeLabels.length > 0) {
|
|
1665
|
+
const needsLookup = [...addLabels, ...removeLabels].some((v) => !isLabelId(v));
|
|
1666
|
+
let labelMap = new Map;
|
|
1667
|
+
if (needsLookup) {
|
|
1668
|
+
const allLabels = await this.labels.list();
|
|
1669
|
+
labelMap = new Map(allLabels.labels.map((l) => [l.name.toLowerCase(), l.id]));
|
|
1670
|
+
}
|
|
1671
|
+
for (const value of addLabels) {
|
|
1672
|
+
if (isLabelId(value)) {
|
|
1673
|
+
resolvedAddIds.push(value);
|
|
1674
|
+
} else {
|
|
1675
|
+
const id = labelMap.get(value.toLowerCase());
|
|
1676
|
+
if (id)
|
|
1677
|
+
resolvedAddIds.push(id);
|
|
1678
|
+
else
|
|
1679
|
+
throw new Error(`Label not found: ${value}`);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
for (const value of removeLabels) {
|
|
1683
|
+
if (isLabelId(value)) {
|
|
1684
|
+
resolvedRemoveIds.push(value);
|
|
1685
|
+
} else {
|
|
1686
|
+
const id = labelMap.get(value.toLowerCase());
|
|
1687
|
+
if (id)
|
|
1688
|
+
resolvedRemoveIds.push(id);
|
|
1689
|
+
else
|
|
1690
|
+
throw new Error(`Label not found: ${value}`);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
if (resolvedAddIds.length === 0 && resolvedRemoveIds.length === 0) {
|
|
1695
|
+
throw new Error("At least one label to add or remove is required");
|
|
1696
|
+
}
|
|
1697
|
+
const fetchLimit = maxResults === Infinity ? Number.MAX_SAFE_INTEGER : maxResults + offset;
|
|
1698
|
+
let messages = await this.fetchMessages(query, fetchLimit);
|
|
1699
|
+
if (offset > 0) {
|
|
1700
|
+
messages = messages.slice(offset);
|
|
1701
|
+
}
|
|
1702
|
+
if (maxResults !== Infinity && messages.length > maxResults) {
|
|
1703
|
+
messages = messages.slice(0, maxResults);
|
|
1704
|
+
}
|
|
1705
|
+
if (skipIfLabeled && resolvedAddIds.length > 0) {
|
|
1706
|
+
messages = messages.filter((msg) => {
|
|
1707
|
+
const existing = msg.labelIds || [];
|
|
1708
|
+
return !resolvedAddIds.every((id) => existing.includes(id));
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
return this.executeBatch(messages, {
|
|
1712
|
+
dryRun,
|
|
1713
|
+
concurrency,
|
|
1714
|
+
onProgress,
|
|
1715
|
+
onError,
|
|
1716
|
+
operation: async (msg) => {
|
|
1717
|
+
await this.messages.modify(msg.id, resolvedAddIds, resolvedRemoveIds);
|
|
1718
|
+
}
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
async addLabels(options) {
|
|
1722
|
+
return this.modifyLabels({
|
|
1723
|
+
...options,
|
|
1724
|
+
removeLabelIds: [],
|
|
1725
|
+
removeLabels: []
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
async removeLabels(options) {
|
|
1729
|
+
return this.modifyLabels({
|
|
1730
|
+
...options,
|
|
1731
|
+
addLabelIds: [],
|
|
1732
|
+
addLabels: []
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
async archive(options) {
|
|
1736
|
+
const messages = await this.fetchMessages(options.query, options.maxResults || 100);
|
|
1737
|
+
return this.executeBatch(messages, {
|
|
1738
|
+
dryRun: options.dryRun || false,
|
|
1739
|
+
concurrency: options.concurrency || 10,
|
|
1740
|
+
onProgress: options.onProgress,
|
|
1741
|
+
onError: options.onError,
|
|
1742
|
+
operation: async (msg) => {
|
|
1743
|
+
await this.messages.archive(msg.id);
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
async unarchive(options) {
|
|
1748
|
+
const messages = await this.fetchMessages(options.query, options.maxResults || 100);
|
|
1749
|
+
return this.executeBatch(messages, {
|
|
1750
|
+
dryRun: options.dryRun || false,
|
|
1751
|
+
concurrency: options.concurrency || 10,
|
|
1752
|
+
onProgress: options.onProgress,
|
|
1753
|
+
onError: options.onError,
|
|
1754
|
+
operation: async (msg) => {
|
|
1755
|
+
await this.messages.modify(msg.id, ["INBOX"], undefined);
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
async trash(options) {
|
|
1760
|
+
const messages = await this.fetchMessages(options.query, options.maxResults || 100);
|
|
1761
|
+
return this.executeBatch(messages, {
|
|
1762
|
+
dryRun: options.dryRun || false,
|
|
1763
|
+
concurrency: options.concurrency || 10,
|
|
1764
|
+
onProgress: options.onProgress,
|
|
1765
|
+
onError: options.onError,
|
|
1766
|
+
operation: async (msg) => {
|
|
1767
|
+
await this.messages.trash(msg.id);
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
async delete(options) {
|
|
1772
|
+
const messages = await this.fetchMessages(options.query, options.maxResults || 100);
|
|
1773
|
+
return this.executeBatch(messages, {
|
|
1774
|
+
dryRun: options.dryRun || false,
|
|
1775
|
+
concurrency: options.concurrency || 10,
|
|
1776
|
+
onProgress: options.onProgress,
|
|
1777
|
+
onError: options.onError,
|
|
1778
|
+
operation: async (msg) => {
|
|
1779
|
+
await this.messages.delete(msg.id);
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
async untrash(options) {
|
|
1784
|
+
const messages = await this.fetchMessages(options.query, options.maxResults || 100);
|
|
1785
|
+
return this.executeBatch(messages, {
|
|
1786
|
+
dryRun: options.dryRun || false,
|
|
1787
|
+
concurrency: options.concurrency || 10,
|
|
1788
|
+
onProgress: options.onProgress,
|
|
1789
|
+
onError: options.onError,
|
|
1790
|
+
operation: async (msg) => {
|
|
1791
|
+
await this.messages.untrash(msg.id);
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
async markAsRead(options) {
|
|
1796
|
+
const messages = await this.fetchMessages(options.query, options.maxResults || 100);
|
|
1797
|
+
return this.executeBatch(messages, {
|
|
1798
|
+
dryRun: options.dryRun || false,
|
|
1799
|
+
concurrency: options.concurrency || 10,
|
|
1800
|
+
onProgress: options.onProgress,
|
|
1801
|
+
onError: options.onError,
|
|
1802
|
+
operation: async (msg) => {
|
|
1803
|
+
await this.messages.markAsRead(msg.id);
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
async markAsUnread(options) {
|
|
1808
|
+
const messages = await this.fetchMessages(options.query, options.maxResults || 100);
|
|
1809
|
+
return this.executeBatch(messages, {
|
|
1810
|
+
dryRun: options.dryRun || false,
|
|
1811
|
+
concurrency: options.concurrency || 10,
|
|
1812
|
+
onProgress: options.onProgress,
|
|
1813
|
+
onError: options.onError,
|
|
1814
|
+
operation: async (msg) => {
|
|
1815
|
+
await this.messages.markAsUnread(msg.id);
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
async star(options) {
|
|
1820
|
+
const messages = await this.fetchMessages(options.query, options.maxResults || 100);
|
|
1821
|
+
return this.executeBatch(messages, {
|
|
1822
|
+
dryRun: options.dryRun || false,
|
|
1823
|
+
concurrency: options.concurrency || 10,
|
|
1824
|
+
onProgress: options.onProgress,
|
|
1825
|
+
onError: options.onError,
|
|
1826
|
+
operation: async (msg) => {
|
|
1827
|
+
await this.messages.star(msg.id);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
async unstar(options) {
|
|
1832
|
+
const messages = await this.fetchMessages(options.query, options.maxResults || 100);
|
|
1833
|
+
return this.executeBatch(messages, {
|
|
1834
|
+
dryRun: options.dryRun || false,
|
|
1835
|
+
concurrency: options.concurrency || 10,
|
|
1836
|
+
onProgress: options.onProgress,
|
|
1837
|
+
onError: options.onError,
|
|
1838
|
+
operation: async (msg) => {
|
|
1839
|
+
await this.messages.unstar(msg.id);
|
|
1840
|
+
}
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
async batchModifyLabels(options) {
|
|
1844
|
+
const {
|
|
1845
|
+
query,
|
|
1846
|
+
maxResults = 1000,
|
|
1847
|
+
addLabelIds = [],
|
|
1848
|
+
removeLabelIds = [],
|
|
1849
|
+
addLabels = [],
|
|
1850
|
+
removeLabels = [],
|
|
1851
|
+
dryRun = false,
|
|
1852
|
+
skipIfLabeled = false,
|
|
1853
|
+
offset = 0
|
|
1854
|
+
} = options;
|
|
1855
|
+
const resolvedAddIds = [...addLabelIds];
|
|
1856
|
+
const resolvedRemoveIds = [...removeLabelIds];
|
|
1857
|
+
if (addLabels.length > 0 || removeLabels.length > 0) {
|
|
1858
|
+
const needsLookup = [...addLabels, ...removeLabels].some((v) => !isLabelId(v));
|
|
1859
|
+
let labelMap = new Map;
|
|
1860
|
+
if (needsLookup) {
|
|
1861
|
+
const allLabels = await this.labels.list();
|
|
1862
|
+
labelMap = new Map(allLabels.labels.map((l) => [l.name.toLowerCase(), l.id]));
|
|
1863
|
+
}
|
|
1864
|
+
for (const value of addLabels) {
|
|
1865
|
+
if (isLabelId(value)) {
|
|
1866
|
+
resolvedAddIds.push(value);
|
|
1867
|
+
} else {
|
|
1868
|
+
const id = labelMap.get(value.toLowerCase());
|
|
1869
|
+
if (id)
|
|
1870
|
+
resolvedAddIds.push(id);
|
|
1871
|
+
else
|
|
1872
|
+
throw new Error(`Label not found: ${value}`);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
for (const value of removeLabels) {
|
|
1876
|
+
if (isLabelId(value)) {
|
|
1877
|
+
resolvedRemoveIds.push(value);
|
|
1878
|
+
} else {
|
|
1879
|
+
const id = labelMap.get(value.toLowerCase());
|
|
1880
|
+
if (id)
|
|
1881
|
+
resolvedRemoveIds.push(id);
|
|
1882
|
+
else
|
|
1883
|
+
throw new Error(`Label not found: ${value}`);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
let messageIds;
|
|
1888
|
+
if (skipIfLabeled && resolvedAddIds.length > 0) {
|
|
1889
|
+
const fetchLimit = maxResults === Infinity ? Number.MAX_SAFE_INTEGER : maxResults + offset;
|
|
1890
|
+
let msgs = await this.fetchMessages(query, fetchLimit);
|
|
1891
|
+
if (offset > 0)
|
|
1892
|
+
msgs = msgs.slice(offset);
|
|
1893
|
+
if (maxResults !== Infinity && msgs.length > maxResults)
|
|
1894
|
+
msgs = msgs.slice(0, maxResults);
|
|
1895
|
+
msgs = msgs.filter((msg) => {
|
|
1896
|
+
const existing = msg.labelIds || [];
|
|
1897
|
+
return !resolvedAddIds.every((id) => existing.includes(id));
|
|
1898
|
+
});
|
|
1899
|
+
messageIds = msgs.map((m) => m.id);
|
|
1900
|
+
} else {
|
|
1901
|
+
const fetchLimit = maxResults === Infinity ? Number.MAX_SAFE_INTEGER : maxResults + offset;
|
|
1902
|
+
let ids = await this.fetchMessageIds(query, fetchLimit);
|
|
1903
|
+
if (offset > 0)
|
|
1904
|
+
ids = ids.slice(offset);
|
|
1905
|
+
if (maxResults !== Infinity && ids.length > maxResults)
|
|
1906
|
+
ids = ids.slice(0, maxResults);
|
|
1907
|
+
messageIds = ids;
|
|
1908
|
+
}
|
|
1909
|
+
const messages = messageIds;
|
|
1910
|
+
const result = {
|
|
1911
|
+
total: messages.length,
|
|
1912
|
+
success: 0,
|
|
1913
|
+
failed: 0,
|
|
1914
|
+
skipped: 0,
|
|
1915
|
+
errors: [],
|
|
1916
|
+
processedMessages: []
|
|
1917
|
+
};
|
|
1918
|
+
if (messages.length === 0) {
|
|
1919
|
+
return result;
|
|
1920
|
+
}
|
|
1921
|
+
if (dryRun) {
|
|
1922
|
+
result.success = messages.length;
|
|
1923
|
+
result.processedMessages = messages.map((id) => ({ id, threadId: "" }));
|
|
1924
|
+
return result;
|
|
1925
|
+
}
|
|
1926
|
+
const batchSize = 1000;
|
|
1927
|
+
const batches = this.chunkArray(messages, batchSize);
|
|
1928
|
+
for (const batch of batches) {
|
|
1929
|
+
try {
|
|
1930
|
+
await this.client.post(`/users/${this.client.getUserId()}/messages/batchModify`, {
|
|
1931
|
+
ids: batch,
|
|
1932
|
+
addLabelIds: resolvedAddIds.length > 0 ? resolvedAddIds : undefined,
|
|
1933
|
+
removeLabelIds: resolvedRemoveIds.length > 0 ? resolvedRemoveIds : undefined
|
|
1934
|
+
});
|
|
1935
|
+
result.success += batch.length;
|
|
1936
|
+
result.processedMessages.push(...batch.map((id) => ({ id, threadId: "" })));
|
|
1937
|
+
} catch (err) {
|
|
1938
|
+
result.failed += batch.length;
|
|
1939
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1940
|
+
for (const id of batch) {
|
|
1941
|
+
result.errors.push({ messageId: id, error: errorMessage });
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
return result;
|
|
1946
|
+
}
|
|
1947
|
+
async batchDelete(options) {
|
|
1948
|
+
const { query, maxResults = 1000, dryRun = false } = options;
|
|
1949
|
+
const messages = await this.fetchMessageIds(query, maxResults);
|
|
1950
|
+
const result = {
|
|
1951
|
+
total: messages.length,
|
|
1952
|
+
success: 0,
|
|
1953
|
+
failed: 0,
|
|
1954
|
+
skipped: 0,
|
|
1955
|
+
errors: [],
|
|
1956
|
+
processedMessages: []
|
|
1957
|
+
};
|
|
1958
|
+
if (messages.length === 0) {
|
|
1959
|
+
return result;
|
|
1960
|
+
}
|
|
1961
|
+
if (dryRun) {
|
|
1962
|
+
result.success = messages.length;
|
|
1963
|
+
result.processedMessages = messages.map((id) => ({ id, threadId: "" }));
|
|
1964
|
+
return result;
|
|
1965
|
+
}
|
|
1966
|
+
const batchSize = 1000;
|
|
1967
|
+
const batches = this.chunkArray(messages, batchSize);
|
|
1968
|
+
for (const batch of batches) {
|
|
1969
|
+
try {
|
|
1970
|
+
await this.client.post(`/users/${this.client.getUserId()}/messages/batchDelete`, { ids: batch });
|
|
1971
|
+
result.success += batch.length;
|
|
1972
|
+
result.processedMessages.push(...batch.map((id) => ({ id, threadId: "" })));
|
|
1973
|
+
} catch (err) {
|
|
1974
|
+
result.failed += batch.length;
|
|
1975
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1976
|
+
for (const id of batch) {
|
|
1977
|
+
result.errors.push({ messageId: id, error: errorMessage });
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
return result;
|
|
1982
|
+
}
|
|
1983
|
+
async fetchMessages(query, maxResults) {
|
|
1984
|
+
const messages = [];
|
|
1985
|
+
let pageToken;
|
|
1986
|
+
while (messages.length < maxResults) {
|
|
1987
|
+
const response = await this.messages.list({
|
|
1988
|
+
q: query,
|
|
1989
|
+
maxResults: Math.min(100, maxResults - messages.length),
|
|
1990
|
+
pageToken
|
|
1991
|
+
});
|
|
1992
|
+
if (!response.messages || response.messages.length === 0) {
|
|
1993
|
+
break;
|
|
1994
|
+
}
|
|
1995
|
+
const metadataPromises = response.messages.map(async (m) => {
|
|
1996
|
+
const msg = await this.messages.get(m.id, "metadata");
|
|
1997
|
+
const headers = msg.payload?.headers || [];
|
|
1998
|
+
const getHeader = (name) => headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
|
|
1999
|
+
return {
|
|
2000
|
+
id: m.id,
|
|
2001
|
+
threadId: m.threadId,
|
|
2002
|
+
from: getHeader("from"),
|
|
2003
|
+
subject: getHeader("subject"),
|
|
2004
|
+
date: getHeader("date"),
|
|
2005
|
+
snippet: msg.snippet,
|
|
2006
|
+
labelIds: msg.labelIds
|
|
2007
|
+
};
|
|
2008
|
+
});
|
|
2009
|
+
const fetchedMessages = await Promise.all(metadataPromises);
|
|
2010
|
+
messages.push(...fetchedMessages);
|
|
2011
|
+
pageToken = response.nextPageToken;
|
|
2012
|
+
if (!pageToken)
|
|
2013
|
+
break;
|
|
2014
|
+
}
|
|
2015
|
+
return messages;
|
|
2016
|
+
}
|
|
2017
|
+
async fetchMessageIds(query, maxResults) {
|
|
2018
|
+
const messageIds = [];
|
|
2019
|
+
let pageToken;
|
|
2020
|
+
while (messageIds.length < maxResults) {
|
|
2021
|
+
const response = await this.messages.list({
|
|
2022
|
+
q: query,
|
|
2023
|
+
maxResults: Math.min(500, maxResults - messageIds.length),
|
|
2024
|
+
pageToken
|
|
2025
|
+
});
|
|
2026
|
+
if (!response.messages || response.messages.length === 0) {
|
|
2027
|
+
break;
|
|
2028
|
+
}
|
|
2029
|
+
messageIds.push(...response.messages.map((m) => m.id));
|
|
2030
|
+
pageToken = response.nextPageToken;
|
|
2031
|
+
if (!pageToken)
|
|
2032
|
+
break;
|
|
2033
|
+
}
|
|
2034
|
+
return messageIds;
|
|
2035
|
+
}
|
|
2036
|
+
async executeBatch(messages, options) {
|
|
2037
|
+
const { dryRun, concurrency, onProgress, onError, operation } = options;
|
|
2038
|
+
const result = {
|
|
2039
|
+
total: messages.length,
|
|
2040
|
+
success: 0,
|
|
2041
|
+
failed: 0,
|
|
2042
|
+
skipped: 0,
|
|
2043
|
+
errors: [],
|
|
2044
|
+
processedMessages: []
|
|
2045
|
+
};
|
|
2046
|
+
if (messages.length === 0) {
|
|
2047
|
+
return result;
|
|
2048
|
+
}
|
|
2049
|
+
const chunks = this.chunkArray(messages, concurrency);
|
|
2050
|
+
for (const chunk of chunks) {
|
|
2051
|
+
await Promise.all(chunk.map(async (msg) => {
|
|
2052
|
+
try {
|
|
2053
|
+
if (dryRun) {
|
|
2054
|
+
result.success++;
|
|
2055
|
+
result.processedMessages.push(msg);
|
|
2056
|
+
} else {
|
|
2057
|
+
await operation(msg);
|
|
2058
|
+
result.success++;
|
|
2059
|
+
result.processedMessages.push(msg);
|
|
2060
|
+
}
|
|
2061
|
+
if (onProgress) {
|
|
2062
|
+
onProgress(result.success + result.failed, result.total, msg);
|
|
2063
|
+
}
|
|
2064
|
+
} catch (err) {
|
|
2065
|
+
result.failed++;
|
|
2066
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2067
|
+
result.errors.push({ messageId: msg.id, error: errorMessage });
|
|
2068
|
+
if (onError) {
|
|
2069
|
+
onError(err instanceof Error ? err : new Error(errorMessage), msg);
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}));
|
|
2073
|
+
}
|
|
2074
|
+
return result;
|
|
2075
|
+
}
|
|
2076
|
+
chunkArray(array, size) {
|
|
2077
|
+
const chunks = [];
|
|
2078
|
+
for (let i = 0;i < array.length; i += size) {
|
|
2079
|
+
chunks.push(array.slice(i, i + size));
|
|
2080
|
+
}
|
|
2081
|
+
return chunks;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// src/api/index.ts
|
|
2086
|
+
class Gmail {
|
|
2087
|
+
client;
|
|
2088
|
+
messages;
|
|
2089
|
+
labels;
|
|
2090
|
+
threads;
|
|
2091
|
+
profile;
|
|
2092
|
+
drafts;
|
|
2093
|
+
filters;
|
|
2094
|
+
attachments;
|
|
2095
|
+
export;
|
|
2096
|
+
bulk;
|
|
2097
|
+
constructor(client) {
|
|
2098
|
+
this.client = client ?? new GmailClient;
|
|
2099
|
+
this.messages = new MessagesApi(this.client);
|
|
2100
|
+
this.labels = new LabelsApi(this.client);
|
|
2101
|
+
this.threads = new ThreadsApi(this.client);
|
|
2102
|
+
this.profile = new ProfileApi(this.client);
|
|
2103
|
+
this.drafts = new DraftsApi(this.client);
|
|
2104
|
+
this.filters = new FiltersApi(this.client);
|
|
2105
|
+
this.attachments = new AttachmentsApi(this.client);
|
|
2106
|
+
this.export = new ExportApi(this.client);
|
|
2107
|
+
this.bulk = new BulkApi(this.client);
|
|
2108
|
+
}
|
|
2109
|
+
static create() {
|
|
2110
|
+
return new Gmail;
|
|
2111
|
+
}
|
|
2112
|
+
static fromEnv() {
|
|
2113
|
+
const accessToken = process.env.GMAIL_ACCESS_TOKEN;
|
|
2114
|
+
const refreshToken = process.env.GMAIL_REFRESH_TOKEN;
|
|
2115
|
+
const clientId = process.env.GMAIL_CLIENT_ID;
|
|
2116
|
+
const clientSecret = process.env.GMAIL_CLIENT_SECRET;
|
|
2117
|
+
const expiresAt = process.env.GMAIL_TOKEN_EXPIRES_AT ? parseInt(process.env.GMAIL_TOKEN_EXPIRES_AT, 10) : undefined;
|
|
2118
|
+
if (refreshToken && clientId && clientSecret) {
|
|
2119
|
+
return Gmail.createWithTokens({
|
|
2120
|
+
accessToken: accessToken ?? "",
|
|
2121
|
+
refreshToken,
|
|
2122
|
+
clientId,
|
|
2123
|
+
clientSecret,
|
|
2124
|
+
expiresAt: accessToken ? expiresAt : 0
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
if (accessToken) {
|
|
2128
|
+
const client = new GmailClient({ tokenProvider: async () => accessToken });
|
|
2129
|
+
return new Gmail(client);
|
|
2130
|
+
}
|
|
2131
|
+
throw new Error("Missing Gmail env vars. Provide GMAIL_ACCESS_TOKEN, " + "or GMAIL_REFRESH_TOKEN + GMAIL_CLIENT_ID + GMAIL_CLIENT_SECRET");
|
|
2132
|
+
}
|
|
2133
|
+
static createWithTokens(tokens, onRefresh) {
|
|
2134
|
+
let current = { ...tokens };
|
|
2135
|
+
const tokenProvider = async () => {
|
|
2136
|
+
const isExpired = current.expiresAt === undefined || Date.now() >= current.expiresAt - 5 * 60 * 1000;
|
|
2137
|
+
if (isExpired) {
|
|
2138
|
+
const refreshed = await refreshTokens(current.clientId, current.clientSecret, current.refreshToken);
|
|
2139
|
+
current = {
|
|
2140
|
+
accessToken: refreshed.accessToken,
|
|
2141
|
+
refreshToken: refreshed.refreshToken,
|
|
2142
|
+
clientId: current.clientId,
|
|
2143
|
+
clientSecret: current.clientSecret,
|
|
2144
|
+
expiresAt: refreshed.expiresAt
|
|
2145
|
+
};
|
|
2146
|
+
onRefresh?.(current);
|
|
2147
|
+
}
|
|
2148
|
+
return current.accessToken;
|
|
2149
|
+
};
|
|
2150
|
+
const client = new GmailClient({ tokenProvider });
|
|
2151
|
+
return new Gmail(client);
|
|
2152
|
+
}
|
|
2153
|
+
getClient() {
|
|
2154
|
+
return this.client;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
export {
|
|
2158
|
+
startCallbackServer,
|
|
2159
|
+
saveTokens,
|
|
2160
|
+
refreshTokens,
|
|
2161
|
+
refreshAccessToken,
|
|
2162
|
+
loadTokens,
|
|
2163
|
+
isAuthenticated,
|
|
2164
|
+
getValidAccessToken,
|
|
2165
|
+
getAuthUrl,
|
|
2166
|
+
clearConfig,
|
|
2167
|
+
ThreadsApi,
|
|
2168
|
+
ProfileApi,
|
|
2169
|
+
MessagesApi,
|
|
2170
|
+
LabelsApi,
|
|
2171
|
+
GmailClient,
|
|
2172
|
+
GmailApiError,
|
|
2173
|
+
Gmail
|
|
2174
|
+
};
|