@juppytt/fws 0.1.0
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/.claude/settings.local.json +72 -0
- package/README.md +126 -0
- package/bin/fws-cli.sh +4 -0
- package/bin/fws.ts +421 -0
- package/docs/cli-reference.md +211 -0
- package/docs/gws-support.md +276 -0
- package/package.json +28 -0
- package/src/config/rewrite-cache.ts +73 -0
- package/src/index.ts +3 -0
- package/src/proxy/mitm.ts +285 -0
- package/src/server/app.ts +26 -0
- package/src/server/middleware.ts +38 -0
- package/src/server/routes/calendar.ts +483 -0
- package/src/server/routes/control.ts +151 -0
- package/src/server/routes/drive.ts +342 -0
- package/src/server/routes/gmail.ts +758 -0
- package/src/server/routes/people.ts +239 -0
- package/src/server/routes/sheets.ts +242 -0
- package/src/server/routes/tasks.ts +191 -0
- package/src/store/index.ts +24 -0
- package/src/store/seed.ts +313 -0
- package/src/store/types.ts +225 -0
- package/src/util/id.ts +9 -0
- package/test/calendar.test.ts +227 -0
- package/test/drive.test.ts +153 -0
- package/test/gmail.test.ts +215 -0
- package/test/gws-validation.test.ts +883 -0
- package/test/helpers/harness.ts +109 -0
- package/test/snapshot.test.ts +80 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getStore } from '../../store/index.js';
|
|
3
|
+
import { generateId } from '../../util/id.js';
|
|
4
|
+
|
|
5
|
+
const BASE = '/gmail/v1/users/:userId';
|
|
6
|
+
|
|
7
|
+
export function gmailRoutes(): Router {
|
|
8
|
+
const r = Router();
|
|
9
|
+
|
|
10
|
+
// GET profile
|
|
11
|
+
r.get(`${BASE}/profile`, (_req, res) => {
|
|
12
|
+
res.json(getStore().gmail.profile);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// LIST messages
|
|
16
|
+
r.get(`${BASE}/messages`, (req, res) => {
|
|
17
|
+
const store = getStore();
|
|
18
|
+
let messages = Object.values(store.gmail.messages);
|
|
19
|
+
|
|
20
|
+
// Filter by labelIds
|
|
21
|
+
const labelIds = req.query.labelIds;
|
|
22
|
+
if (labelIds) {
|
|
23
|
+
const labels = Array.isArray(labelIds) ? labelIds as string[] : [labelIds as string];
|
|
24
|
+
messages = messages.filter(m => labels.every(l => m.labelIds.includes(l)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Filter by q
|
|
28
|
+
const q = req.query.q as string | undefined;
|
|
29
|
+
if (q) {
|
|
30
|
+
messages = filterByQuery(messages, q);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Sort by internalDate descending (newest first)
|
|
34
|
+
messages.sort((a, b) => Number(b.internalDate) - Number(a.internalDate));
|
|
35
|
+
|
|
36
|
+
const maxResults = Math.min(parseInt(req.query.maxResults as string) || 100, 500);
|
|
37
|
+
const result = messages.slice(0, maxResults);
|
|
38
|
+
|
|
39
|
+
res.json({
|
|
40
|
+
messages: result.map(m => ({ id: m.id, threadId: m.threadId })),
|
|
41
|
+
resultSizeEstimate: result.length,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// GET message
|
|
46
|
+
r.get(`${BASE}/messages/:id`, (req, res) => {
|
|
47
|
+
const store = getStore();
|
|
48
|
+
const msg = store.gmail.messages[req.params.id];
|
|
49
|
+
if (!msg) {
|
|
50
|
+
return res.status(404).json({
|
|
51
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const format = (req.query.format as string) || 'full';
|
|
56
|
+
if (format === 'minimal') {
|
|
57
|
+
return res.json({ id: msg.id, threadId: msg.threadId, labelIds: msg.labelIds, snippet: msg.snippet, historyId: msg.historyId, internalDate: msg.internalDate, sizeEstimate: msg.sizeEstimate });
|
|
58
|
+
}
|
|
59
|
+
if (format === 'metadata') {
|
|
60
|
+
return res.json({ id: msg.id, threadId: msg.threadId, labelIds: msg.labelIds, snippet: msg.snippet, historyId: msg.historyId, internalDate: msg.internalDate, sizeEstimate: msg.sizeEstimate, payload: { headers: msg.payload.headers } });
|
|
61
|
+
}
|
|
62
|
+
if (format === 'raw') {
|
|
63
|
+
return res.json({ ...msg });
|
|
64
|
+
}
|
|
65
|
+
// full
|
|
66
|
+
res.json(msg);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// SEND message
|
|
70
|
+
r.post(`${BASE}/messages/send`, (req, res) => {
|
|
71
|
+
const store = getStore();
|
|
72
|
+
const id = generateId();
|
|
73
|
+
const threadId = req.body.threadId || generateId();
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
|
|
76
|
+
let headers: Array<{ name: string; value: string }> = [];
|
|
77
|
+
let bodyData = '';
|
|
78
|
+
let snippet = '';
|
|
79
|
+
|
|
80
|
+
if (req.body.raw) {
|
|
81
|
+
// Decode base64url raw RFC 2822
|
|
82
|
+
const rawText = Buffer.from(req.body.raw, 'base64url').toString('utf-8');
|
|
83
|
+
const parsed = parseRawEmail(rawText);
|
|
84
|
+
headers = parsed.headers;
|
|
85
|
+
bodyData = Buffer.from(parsed.body).toString('base64url');
|
|
86
|
+
snippet = parsed.body.slice(0, 100);
|
|
87
|
+
} else {
|
|
88
|
+
headers = [
|
|
89
|
+
{ name: 'From', value: store.gmail.profile.emailAddress },
|
|
90
|
+
{ name: 'To', value: 'recipient@example.com' },
|
|
91
|
+
{ name: 'Subject', value: '(no subject)' },
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const msg = {
|
|
96
|
+
id,
|
|
97
|
+
threadId,
|
|
98
|
+
labelIds: ['SENT'],
|
|
99
|
+
snippet,
|
|
100
|
+
historyId: String(store.gmail.nextHistoryId++),
|
|
101
|
+
internalDate: String(now),
|
|
102
|
+
sizeEstimate: bodyData.length,
|
|
103
|
+
payload: {
|
|
104
|
+
partId: '',
|
|
105
|
+
mimeType: 'text/plain',
|
|
106
|
+
filename: '',
|
|
107
|
+
headers,
|
|
108
|
+
body: { size: bodyData.length, data: bodyData },
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
store.gmail.messages[id] = msg;
|
|
113
|
+
store.gmail.profile.messagesTotal++;
|
|
114
|
+
store.gmail.profile.threadsTotal++;
|
|
115
|
+
|
|
116
|
+
res.json({ id: msg.id, threadId: msg.threadId, labelIds: msg.labelIds });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// INSERT message
|
|
120
|
+
r.post(`${BASE}/messages`, (req, res) => {
|
|
121
|
+
const store = getStore();
|
|
122
|
+
const id = generateId();
|
|
123
|
+
const threadId = req.body.threadId || generateId();
|
|
124
|
+
const labelIds = req.body.labelIds || ['INBOX'];
|
|
125
|
+
|
|
126
|
+
const msg = {
|
|
127
|
+
id,
|
|
128
|
+
threadId,
|
|
129
|
+
labelIds,
|
|
130
|
+
snippet: '',
|
|
131
|
+
historyId: String(store.gmail.nextHistoryId++),
|
|
132
|
+
internalDate: String(Date.now()),
|
|
133
|
+
sizeEstimate: 0,
|
|
134
|
+
payload: {
|
|
135
|
+
partId: '',
|
|
136
|
+
mimeType: 'text/plain',
|
|
137
|
+
filename: '',
|
|
138
|
+
headers: [] as Array<{ name: string; value: string }>,
|
|
139
|
+
body: { size: 0, data: '' },
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (req.body.raw) {
|
|
144
|
+
const rawText = Buffer.from(req.body.raw, 'base64url').toString('utf-8');
|
|
145
|
+
const parsed = parseRawEmail(rawText);
|
|
146
|
+
msg.payload.headers = parsed.headers;
|
|
147
|
+
msg.payload.body = { size: parsed.body.length, data: Buffer.from(parsed.body).toString('base64url') };
|
|
148
|
+
msg.snippet = parsed.body.slice(0, 100);
|
|
149
|
+
msg.sizeEstimate = parsed.body.length;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
store.gmail.messages[id] = msg;
|
|
153
|
+
store.gmail.profile.messagesTotal++;
|
|
154
|
+
store.gmail.profile.threadsTotal++;
|
|
155
|
+
|
|
156
|
+
res.json({ id: msg.id, threadId: msg.threadId, labelIds: msg.labelIds });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// DELETE message
|
|
160
|
+
r.delete(`${BASE}/messages/:id`, (req, res) => {
|
|
161
|
+
const store = getStore();
|
|
162
|
+
if (!store.gmail.messages[req.params.id]) {
|
|
163
|
+
return res.status(404).json({
|
|
164
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
delete store.gmail.messages[req.params.id];
|
|
168
|
+
store.gmail.profile.messagesTotal--;
|
|
169
|
+
res.status(204).send();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// TRASH message
|
|
173
|
+
r.post(`${BASE}/messages/:id/trash`, (req, res) => {
|
|
174
|
+
const store = getStore();
|
|
175
|
+
const msg = store.gmail.messages[req.params.id];
|
|
176
|
+
if (!msg) {
|
|
177
|
+
return res.status(404).json({
|
|
178
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
msg.labelIds = msg.labelIds.filter(l => l !== 'INBOX');
|
|
182
|
+
if (!msg.labelIds.includes('TRASH')) msg.labelIds.push('TRASH');
|
|
183
|
+
res.json(msg);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// UNTRASH message
|
|
187
|
+
r.post(`${BASE}/messages/:id/untrash`, (req, res) => {
|
|
188
|
+
const store = getStore();
|
|
189
|
+
const msg = store.gmail.messages[req.params.id];
|
|
190
|
+
if (!msg) {
|
|
191
|
+
return res.status(404).json({
|
|
192
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
msg.labelIds = msg.labelIds.filter(l => l !== 'TRASH');
|
|
196
|
+
if (!msg.labelIds.includes('INBOX')) msg.labelIds.push('INBOX');
|
|
197
|
+
res.json(msg);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// MODIFY message labels
|
|
201
|
+
r.post(`${BASE}/messages/:id/modify`, (req, res) => {
|
|
202
|
+
const store = getStore();
|
|
203
|
+
const msg = store.gmail.messages[req.params.id];
|
|
204
|
+
if (!msg) {
|
|
205
|
+
return res.status(404).json({
|
|
206
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const { addLabelIds = [], removeLabelIds = [] } = req.body;
|
|
210
|
+
msg.labelIds = msg.labelIds.filter((l: string) => !removeLabelIds.includes(l));
|
|
211
|
+
for (const label of addLabelIds) {
|
|
212
|
+
if (!msg.labelIds.includes(label)) msg.labelIds.push(label);
|
|
213
|
+
}
|
|
214
|
+
res.json(msg);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// LIST labels
|
|
218
|
+
r.get(`${BASE}/labels`, (_req, res) => {
|
|
219
|
+
res.json({ labels: Object.values(getStore().gmail.labels) });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// GET label
|
|
223
|
+
r.get(`${BASE}/labels/:id`, (req, res) => {
|
|
224
|
+
const label = getStore().gmail.labels[req.params.id];
|
|
225
|
+
if (!label) {
|
|
226
|
+
return res.status(404).json({
|
|
227
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
res.json(label);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// CREATE label
|
|
234
|
+
r.post(`${BASE}/labels`, (req, res) => {
|
|
235
|
+
const store = getStore();
|
|
236
|
+
const id = `Label_${generateId(8)}`;
|
|
237
|
+
const label = {
|
|
238
|
+
id,
|
|
239
|
+
name: req.body.name || 'Untitled',
|
|
240
|
+
type: 'user' as const,
|
|
241
|
+
messageListVisibility: req.body.messageListVisibility || 'show',
|
|
242
|
+
labelListVisibility: req.body.labelListVisibility || 'labelShow',
|
|
243
|
+
};
|
|
244
|
+
store.gmail.labels[id] = label;
|
|
245
|
+
res.json(label);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// UPDATE label (PUT - full replace)
|
|
249
|
+
r.put(`${BASE}/labels/:id`, (req, res) => {
|
|
250
|
+
const store = getStore();
|
|
251
|
+
const label = store.gmail.labels[req.params.id];
|
|
252
|
+
if (!label) {
|
|
253
|
+
return res.status(404).json({
|
|
254
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (label.type === 'system') {
|
|
258
|
+
return res.status(400).json({
|
|
259
|
+
error: { code: 400, message: 'Cannot modify system labels.', status: 'INVALID_ARGUMENT' },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
store.gmail.labels[req.params.id] = { ...req.body, id: label.id, type: label.type };
|
|
263
|
+
res.json(store.gmail.labels[req.params.id]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// PATCH label
|
|
267
|
+
r.patch(`${BASE}/labels/:id`, (req, res) => {
|
|
268
|
+
const store = getStore();
|
|
269
|
+
const label = store.gmail.labels[req.params.id];
|
|
270
|
+
if (!label) {
|
|
271
|
+
return res.status(404).json({
|
|
272
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (label.type === 'system') {
|
|
276
|
+
return res.status(400).json({
|
|
277
|
+
error: { code: 400, message: 'Cannot modify system labels.', status: 'INVALID_ARGUMENT' },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
Object.assign(label, req.body, { id: label.id, type: label.type });
|
|
281
|
+
res.json(label);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// DELETE label
|
|
285
|
+
r.delete(`${BASE}/labels/:id`, (req, res) => {
|
|
286
|
+
const store = getStore();
|
|
287
|
+
const label = store.gmail.labels[req.params.id];
|
|
288
|
+
if (!label) {
|
|
289
|
+
return res.status(404).json({
|
|
290
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (label.type === 'system') {
|
|
294
|
+
return res.status(400).json({
|
|
295
|
+
error: { code: 400, message: 'Cannot delete system labels.', status: 'INVALID_ARGUMENT' },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
delete store.gmail.labels[req.params.id];
|
|
299
|
+
res.status(204).send();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// === Settings ===
|
|
303
|
+
|
|
304
|
+
// GET sendAs
|
|
305
|
+
r.get(`${BASE}/settings/sendAs`, (_req, res) => {
|
|
306
|
+
const store = getStore();
|
|
307
|
+
const email = store.gmail.profile.emailAddress;
|
|
308
|
+
res.json({
|
|
309
|
+
sendAs: [
|
|
310
|
+
{
|
|
311
|
+
sendAsEmail: email,
|
|
312
|
+
displayName: 'Test User',
|
|
313
|
+
isDefault: true,
|
|
314
|
+
isPrimary: true,
|
|
315
|
+
treatAsAlias: false,
|
|
316
|
+
verificationStatus: 'accepted',
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// GET sendAs entry
|
|
323
|
+
r.get(`${BASE}/settings/sendAs/:sendAsEmail`, (req, res) => {
|
|
324
|
+
const store = getStore();
|
|
325
|
+
const email = store.gmail.profile.emailAddress;
|
|
326
|
+
if (req.params.sendAsEmail !== email) {
|
|
327
|
+
return res.status(404).json({
|
|
328
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
res.json({
|
|
332
|
+
sendAsEmail: email,
|
|
333
|
+
displayName: 'Test User',
|
|
334
|
+
isDefault: true,
|
|
335
|
+
isPrimary: true,
|
|
336
|
+
treatAsAlias: false,
|
|
337
|
+
verificationStatus: 'accepted',
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// IMPORT message (similar to insert)
|
|
342
|
+
r.post(`${BASE}/messages/import`, (req, res) => {
|
|
343
|
+
const store = getStore();
|
|
344
|
+
const id = generateId();
|
|
345
|
+
const threadId = req.body.threadId || generateId();
|
|
346
|
+
const labelIds = req.body.labelIds || ['INBOX'];
|
|
347
|
+
|
|
348
|
+
const msg: any = {
|
|
349
|
+
id,
|
|
350
|
+
threadId,
|
|
351
|
+
labelIds,
|
|
352
|
+
snippet: '',
|
|
353
|
+
historyId: String(store.gmail.nextHistoryId++),
|
|
354
|
+
internalDate: String(Date.now()),
|
|
355
|
+
sizeEstimate: 0,
|
|
356
|
+
payload: {
|
|
357
|
+
partId: '',
|
|
358
|
+
mimeType: 'text/plain',
|
|
359
|
+
filename: '',
|
|
360
|
+
headers: [] as Array<{ name: string; value: string }>,
|
|
361
|
+
body: { size: 0, data: '' },
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
if (req.body.raw) {
|
|
366
|
+
const rawText = Buffer.from(req.body.raw, 'base64url').toString('utf-8');
|
|
367
|
+
const parsed = parseRawEmail(rawText);
|
|
368
|
+
msg.payload.headers = parsed.headers;
|
|
369
|
+
msg.payload.body = { size: parsed.body.length, data: Buffer.from(parsed.body).toString('base64url') };
|
|
370
|
+
msg.snippet = parsed.body.slice(0, 100);
|
|
371
|
+
msg.sizeEstimate = parsed.body.length;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
store.gmail.messages[id] = msg;
|
|
375
|
+
store.gmail.profile.messagesTotal++;
|
|
376
|
+
store.gmail.profile.threadsTotal++;
|
|
377
|
+
|
|
378
|
+
res.json({ id: msg.id, threadId: msg.threadId, labelIds: msg.labelIds });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// BATCH DELETE messages
|
|
382
|
+
r.post(`${BASE}/messages/batchDelete`, (req, res) => {
|
|
383
|
+
const store = getStore();
|
|
384
|
+
const ids: string[] = req.body.ids || [];
|
|
385
|
+
for (const id of ids) {
|
|
386
|
+
if (store.gmail.messages[id]) {
|
|
387
|
+
delete store.gmail.messages[id];
|
|
388
|
+
store.gmail.profile.messagesTotal--;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
res.status(204).send();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// BATCH MODIFY messages
|
|
395
|
+
r.post(`${BASE}/messages/batchModify`, (req, res) => {
|
|
396
|
+
const store = getStore();
|
|
397
|
+
const ids: string[] = req.body.ids || [];
|
|
398
|
+
const { addLabelIds = [], removeLabelIds = [] } = req.body;
|
|
399
|
+
for (const id of ids) {
|
|
400
|
+
const msg = store.gmail.messages[id];
|
|
401
|
+
if (msg) {
|
|
402
|
+
msg.labelIds = msg.labelIds.filter((l: string) => !removeLabelIds.includes(l));
|
|
403
|
+
for (const label of addLabelIds) {
|
|
404
|
+
if (!msg.labelIds.includes(label)) msg.labelIds.push(label);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
res.status(204).send();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// === Attachments ===
|
|
412
|
+
|
|
413
|
+
// GET attachment
|
|
414
|
+
r.get(`${BASE}/messages/:messageId/attachments/:id`, (req, res) => {
|
|
415
|
+
const store = getStore();
|
|
416
|
+
const msg = store.gmail.messages[req.params.messageId];
|
|
417
|
+
|
|
418
|
+
// Try to find real attachment data from message parts
|
|
419
|
+
if (msg?.payload?.parts) {
|
|
420
|
+
for (const part of msg.payload.parts) {
|
|
421
|
+
if (part.body?.attachmentId === req.params.id && part.body?.data) {
|
|
422
|
+
return res.json({
|
|
423
|
+
attachmentId: req.params.id,
|
|
424
|
+
size: part.body.size,
|
|
425
|
+
data: part.body.data,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Return fake attachment data as fallback
|
|
432
|
+
const fakeData = Buffer.from('fake attachment content').toString('base64url');
|
|
433
|
+
res.json({
|
|
434
|
+
attachmentId: req.params.id,
|
|
435
|
+
size: 23,
|
|
436
|
+
data: fakeData,
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// === Drafts ===
|
|
441
|
+
|
|
442
|
+
// LIST drafts
|
|
443
|
+
r.get(`${BASE}/drafts`, (_req, res) => {
|
|
444
|
+
const store = getStore();
|
|
445
|
+
const drafts = Object.values(store.gmail.messages).filter(m => m.labelIds.includes('DRAFT'));
|
|
446
|
+
res.json({
|
|
447
|
+
drafts: drafts.map(m => ({
|
|
448
|
+
id: m.id,
|
|
449
|
+
message: { id: m.id, threadId: m.threadId },
|
|
450
|
+
})),
|
|
451
|
+
resultSizeEstimate: drafts.length,
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// GET draft
|
|
456
|
+
r.get(`${BASE}/drafts/:id`, (req, res) => {
|
|
457
|
+
const store = getStore();
|
|
458
|
+
const msg = store.gmail.messages[req.params.id];
|
|
459
|
+
if (!msg || !msg.labelIds.includes('DRAFT')) {
|
|
460
|
+
return res.status(404).json({
|
|
461
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
res.json({
|
|
465
|
+
id: msg.id,
|
|
466
|
+
message: msg,
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// CREATE draft
|
|
471
|
+
r.post(`${BASE}/drafts`, (req, res) => {
|
|
472
|
+
const store = getStore();
|
|
473
|
+
const id = generateId();
|
|
474
|
+
const threadId = req.body?.message?.threadId || generateId();
|
|
475
|
+
const labelIds = ['DRAFT'];
|
|
476
|
+
|
|
477
|
+
const msg: any = {
|
|
478
|
+
id,
|
|
479
|
+
threadId,
|
|
480
|
+
labelIds,
|
|
481
|
+
snippet: '',
|
|
482
|
+
historyId: String(store.gmail.nextHistoryId++),
|
|
483
|
+
internalDate: String(Date.now()),
|
|
484
|
+
sizeEstimate: 0,
|
|
485
|
+
payload: {
|
|
486
|
+
partId: '',
|
|
487
|
+
mimeType: 'text/plain',
|
|
488
|
+
filename: '',
|
|
489
|
+
headers: [] as Array<{ name: string; value: string }>,
|
|
490
|
+
body: { size: 0, data: '' },
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// Handle multipart upload (message/rfc822) or JSON body
|
|
495
|
+
const raw = req.body?.raw || req.body?.message?.raw;
|
|
496
|
+
if (raw) {
|
|
497
|
+
const rawText = Buffer.from(raw, 'base64url').toString('utf-8');
|
|
498
|
+
const parsed = parseRawEmail(rawText);
|
|
499
|
+
msg.payload.headers = parsed.headers;
|
|
500
|
+
msg.payload.body = { size: parsed.body.length, data: Buffer.from(parsed.body).toString('base64url') };
|
|
501
|
+
msg.snippet = parsed.body.slice(0, 100);
|
|
502
|
+
msg.sizeEstimate = parsed.body.length;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
store.gmail.messages[id] = msg;
|
|
506
|
+
store.gmail.profile.messagesTotal++;
|
|
507
|
+
|
|
508
|
+
res.json({
|
|
509
|
+
id,
|
|
510
|
+
message: { id, threadId, labelIds },
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// UPDATE draft
|
|
515
|
+
r.put(`${BASE}/drafts/:id`, (req, res) => {
|
|
516
|
+
const store = getStore();
|
|
517
|
+
const msg = store.gmail.messages[req.params.id];
|
|
518
|
+
if (!msg || !msg.labelIds.includes('DRAFT')) {
|
|
519
|
+
return res.status(404).json({
|
|
520
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const raw = req.body?.raw || req.body?.message?.raw;
|
|
525
|
+
if (raw) {
|
|
526
|
+
const rawText = Buffer.from(raw, 'base64url').toString('utf-8');
|
|
527
|
+
const parsed = parseRawEmail(rawText);
|
|
528
|
+
msg.payload.headers = parsed.headers;
|
|
529
|
+
msg.payload.body = { size: parsed.body.length, data: Buffer.from(parsed.body).toString('base64url') };
|
|
530
|
+
msg.snippet = parsed.body.slice(0, 100);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
res.json({
|
|
534
|
+
id: msg.id,
|
|
535
|
+
message: msg,
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// DELETE draft
|
|
540
|
+
r.delete(`${BASE}/drafts/:id`, (req, res) => {
|
|
541
|
+
const store = getStore();
|
|
542
|
+
const msg = store.gmail.messages[req.params.id];
|
|
543
|
+
if (!msg || !msg.labelIds.includes('DRAFT')) {
|
|
544
|
+
return res.status(404).json({
|
|
545
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
delete store.gmail.messages[req.params.id];
|
|
549
|
+
store.gmail.profile.messagesTotal--;
|
|
550
|
+
res.status(204).send();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// SEND draft
|
|
554
|
+
r.post(`${BASE}/drafts/send`, (req, res) => {
|
|
555
|
+
const store = getStore();
|
|
556
|
+
const draftId = req.body?.id;
|
|
557
|
+
const msg = draftId ? store.gmail.messages[draftId] : null;
|
|
558
|
+
if (!msg) {
|
|
559
|
+
return res.status(404).json({
|
|
560
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
// Convert draft to sent message
|
|
564
|
+
msg.labelIds = msg.labelIds.filter(l => l !== 'DRAFT');
|
|
565
|
+
msg.labelIds.push('SENT');
|
|
566
|
+
res.json({ id: msg.id, threadId: msg.threadId, labelIds: msg.labelIds });
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// === History ===
|
|
570
|
+
|
|
571
|
+
// LIST history
|
|
572
|
+
r.get(`${BASE}/history`, (req, res) => {
|
|
573
|
+
const store = getStore();
|
|
574
|
+
const startHistoryId = parseInt(req.query.startHistoryId as string) || 0;
|
|
575
|
+
const historyTypes = req.query.historyTypes as string | undefined;
|
|
576
|
+
|
|
577
|
+
// Build history from messages with historyId > startHistoryId
|
|
578
|
+
const messages = Object.values(store.gmail.messages)
|
|
579
|
+
.filter(m => parseInt(m.historyId) > startHistoryId);
|
|
580
|
+
|
|
581
|
+
const history = messages.map(m => {
|
|
582
|
+
const entry: any = {
|
|
583
|
+
id: m.historyId,
|
|
584
|
+
messages: [{ id: m.id, threadId: m.threadId }],
|
|
585
|
+
};
|
|
586
|
+
if (!historyTypes || historyTypes === 'messageAdded') {
|
|
587
|
+
entry.messagesAdded = [{ message: { id: m.id, threadId: m.threadId, labelIds: m.labelIds } }];
|
|
588
|
+
}
|
|
589
|
+
return entry;
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
res.json({
|
|
593
|
+
history,
|
|
594
|
+
historyId: String(store.gmail.nextHistoryId - 1),
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// LIST threads
|
|
599
|
+
r.get(`${BASE}/threads`, (req, res) => {
|
|
600
|
+
const store = getStore();
|
|
601
|
+
const messages = Object.values(store.gmail.messages);
|
|
602
|
+
const threadMap = new Map<string, typeof messages>();
|
|
603
|
+
for (const msg of messages) {
|
|
604
|
+
const arr = threadMap.get(msg.threadId) || [];
|
|
605
|
+
arr.push(msg);
|
|
606
|
+
threadMap.set(msg.threadId, arr);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const threads = Array.from(threadMap.entries()).map(([id, msgs]) => ({
|
|
610
|
+
id,
|
|
611
|
+
snippet: msgs[0].snippet,
|
|
612
|
+
historyId: msgs[msgs.length - 1].historyId,
|
|
613
|
+
}));
|
|
614
|
+
|
|
615
|
+
const maxResults = Math.min(parseInt(req.query.maxResults as string) || 100, 500);
|
|
616
|
+
res.json({
|
|
617
|
+
threads: threads.slice(0, maxResults),
|
|
618
|
+
resultSizeEstimate: threads.length,
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// GET thread
|
|
623
|
+
r.get(`${BASE}/threads/:id`, (req, res) => {
|
|
624
|
+
const store = getStore();
|
|
625
|
+
const messages = Object.values(store.gmail.messages).filter(m => m.threadId === req.params.id);
|
|
626
|
+
if (messages.length === 0) {
|
|
627
|
+
return res.status(404).json({
|
|
628
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
res.json({
|
|
632
|
+
id: req.params.id,
|
|
633
|
+
historyId: messages[messages.length - 1].historyId,
|
|
634
|
+
messages,
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// DELETE thread
|
|
639
|
+
r.delete(`${BASE}/threads/:id`, (req, res) => {
|
|
640
|
+
const store = getStore();
|
|
641
|
+
const messages = Object.values(store.gmail.messages).filter(m => m.threadId === req.params.id);
|
|
642
|
+
if (messages.length === 0) {
|
|
643
|
+
return res.status(404).json({
|
|
644
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
for (const msg of messages) {
|
|
648
|
+
delete store.gmail.messages[msg.id];
|
|
649
|
+
store.gmail.profile.messagesTotal--;
|
|
650
|
+
}
|
|
651
|
+
store.gmail.profile.threadsTotal--;
|
|
652
|
+
res.status(204).send();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// TRASH thread
|
|
656
|
+
r.post(`${BASE}/threads/:id/trash`, (req, res) => {
|
|
657
|
+
const store = getStore();
|
|
658
|
+
const messages = Object.values(store.gmail.messages).filter(m => m.threadId === req.params.id);
|
|
659
|
+
if (messages.length === 0) {
|
|
660
|
+
return res.status(404).json({
|
|
661
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
for (const msg of messages) {
|
|
665
|
+
msg.labelIds = msg.labelIds.filter(l => l !== 'INBOX');
|
|
666
|
+
if (!msg.labelIds.includes('TRASH')) msg.labelIds.push('TRASH');
|
|
667
|
+
}
|
|
668
|
+
res.json({
|
|
669
|
+
id: req.params.id,
|
|
670
|
+
historyId: messages[messages.length - 1].historyId,
|
|
671
|
+
messages,
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// UNTRASH thread
|
|
676
|
+
r.post(`${BASE}/threads/:id/untrash`, (req, res) => {
|
|
677
|
+
const store = getStore();
|
|
678
|
+
const messages = Object.values(store.gmail.messages).filter(m => m.threadId === req.params.id);
|
|
679
|
+
if (messages.length === 0) {
|
|
680
|
+
return res.status(404).json({
|
|
681
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
for (const msg of messages) {
|
|
685
|
+
msg.labelIds = msg.labelIds.filter(l => l !== 'TRASH');
|
|
686
|
+
if (!msg.labelIds.includes('INBOX')) msg.labelIds.push('INBOX');
|
|
687
|
+
}
|
|
688
|
+
res.json({
|
|
689
|
+
id: req.params.id,
|
|
690
|
+
historyId: messages[messages.length - 1].historyId,
|
|
691
|
+
messages,
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// MODIFY thread labels
|
|
696
|
+
r.post(`${BASE}/threads/:id/modify`, (req, res) => {
|
|
697
|
+
const store = getStore();
|
|
698
|
+
const messages = Object.values(store.gmail.messages).filter(m => m.threadId === req.params.id);
|
|
699
|
+
if (messages.length === 0) {
|
|
700
|
+
return res.status(404).json({
|
|
701
|
+
error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' },
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
const { addLabelIds = [], removeLabelIds = [] } = req.body;
|
|
705
|
+
for (const msg of messages) {
|
|
706
|
+
msg.labelIds = msg.labelIds.filter((l: string) => !removeLabelIds.includes(l));
|
|
707
|
+
for (const label of addLabelIds) {
|
|
708
|
+
if (!msg.labelIds.includes(label)) msg.labelIds.push(label);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
res.json({
|
|
712
|
+
id: req.params.id,
|
|
713
|
+
historyId: messages[messages.length - 1].historyId,
|
|
714
|
+
messages,
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
return r;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function getHeader(headers: Array<{ name: string; value: string }>, name: string): string {
|
|
722
|
+
return headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || '';
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function filterByQuery(messages: ReturnType<typeof Object.values<any>>, q: string): any[] {
|
|
726
|
+
const tokens = q.match(/(?:[^\s"]+|"[^"]*")/g) || [];
|
|
727
|
+
return messages.filter((msg: any) => {
|
|
728
|
+
const headers = msg.payload?.headers || [];
|
|
729
|
+
return tokens.every((token: string) => {
|
|
730
|
+
if (token.startsWith('from:')) return getHeader(headers, 'From').toLowerCase().includes(token.slice(5).toLowerCase());
|
|
731
|
+
if (token.startsWith('to:')) return getHeader(headers, 'To').toLowerCase().includes(token.slice(3).toLowerCase());
|
|
732
|
+
if (token.startsWith('subject:')) return getHeader(headers, 'Subject').toLowerCase().includes(token.slice(8).toLowerCase());
|
|
733
|
+
if (token === 'is:unread') return msg.labelIds.includes('UNREAD');
|
|
734
|
+
if (token === 'is:starred') return msg.labelIds.includes('STARRED');
|
|
735
|
+
if (token === 'in:inbox') return msg.labelIds.includes('INBOX');
|
|
736
|
+
if (token === 'in:sent') return msg.labelIds.includes('SENT');
|
|
737
|
+
if (token === 'in:trash') return msg.labelIds.includes('TRASH');
|
|
738
|
+
if (token.startsWith('label:')) return msg.labelIds.includes(token.slice(6));
|
|
739
|
+
// Free text search on snippet and subject
|
|
740
|
+
const text = (msg.snippet + ' ' + getHeader(headers, 'Subject')).toLowerCase();
|
|
741
|
+
return text.includes(token.toLowerCase().replace(/"/g, ''));
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function parseRawEmail(raw: string): { headers: Array<{ name: string; value: string }>; body: string } {
|
|
747
|
+
const parts = raw.split(/\r?\n\r?\n/);
|
|
748
|
+
const headerSection = parts[0] || '';
|
|
749
|
+
const body = parts.slice(1).join('\n\n');
|
|
750
|
+
const headers: Array<{ name: string; value: string }> = [];
|
|
751
|
+
for (const line of headerSection.split(/\r?\n/)) {
|
|
752
|
+
const colonIdx = line.indexOf(':');
|
|
753
|
+
if (colonIdx > 0) {
|
|
754
|
+
headers.push({ name: line.slice(0, colonIdx).trim(), value: line.slice(colonIdx + 1).trim() });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return { headers, body };
|
|
758
|
+
}
|