@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.
@@ -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
+ }