@divizend/scratch-core 1.0.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.
Files changed (61) hide show
  1. package/basic/demo.ts +11 -0
  2. package/basic/index.ts +490 -0
  3. package/core/Auth.ts +63 -0
  4. package/core/Currency.ts +16 -0
  5. package/core/Env.ts +186 -0
  6. package/core/Fragment.ts +43 -0
  7. package/core/FragmentServingMode.ts +37 -0
  8. package/core/JsonSchemaValidator.ts +173 -0
  9. package/core/ProjectRoot.ts +76 -0
  10. package/core/Scratch.ts +44 -0
  11. package/core/URI.ts +203 -0
  12. package/core/Universe.ts +406 -0
  13. package/core/index.ts +27 -0
  14. package/gsuite/core/GSuite.ts +237 -0
  15. package/gsuite/core/GSuiteAdmin.ts +81 -0
  16. package/gsuite/core/GSuiteOrgConfig.ts +47 -0
  17. package/gsuite/core/GSuiteUser.ts +115 -0
  18. package/gsuite/core/index.ts +21 -0
  19. package/gsuite/documents/Document.ts +173 -0
  20. package/gsuite/documents/Documents.ts +52 -0
  21. package/gsuite/documents/index.ts +19 -0
  22. package/gsuite/drive/Drive.ts +118 -0
  23. package/gsuite/drive/DriveFile.ts +147 -0
  24. package/gsuite/drive/index.ts +19 -0
  25. package/gsuite/gmail/Gmail.ts +430 -0
  26. package/gsuite/gmail/GmailLabel.ts +55 -0
  27. package/gsuite/gmail/GmailMessage.ts +428 -0
  28. package/gsuite/gmail/GmailMessagePart.ts +298 -0
  29. package/gsuite/gmail/GmailThread.ts +97 -0
  30. package/gsuite/gmail/index.ts +5 -0
  31. package/gsuite/gmail/utils.ts +184 -0
  32. package/gsuite/index.ts +28 -0
  33. package/gsuite/spreadsheets/CellValue.ts +71 -0
  34. package/gsuite/spreadsheets/Sheet.ts +128 -0
  35. package/gsuite/spreadsheets/SheetValues.ts +12 -0
  36. package/gsuite/spreadsheets/Spreadsheet.ts +76 -0
  37. package/gsuite/spreadsheets/Spreadsheets.ts +52 -0
  38. package/gsuite/spreadsheets/index.ts +25 -0
  39. package/gsuite/spreadsheets/utils.ts +52 -0
  40. package/gsuite/utils.ts +104 -0
  41. package/http-server/HttpServer.ts +110 -0
  42. package/http-server/NativeHttpServer.ts +1084 -0
  43. package/http-server/index.ts +3 -0
  44. package/http-server/middlewares/01-cors.ts +33 -0
  45. package/http-server/middlewares/02-static.ts +67 -0
  46. package/http-server/middlewares/03-request-logger.ts +159 -0
  47. package/http-server/middlewares/04-body-parser.ts +54 -0
  48. package/http-server/middlewares/05-no-cache.ts +23 -0
  49. package/http-server/middlewares/06-response-handler.ts +39 -0
  50. package/http-server/middlewares/handler-wrapper.ts +250 -0
  51. package/http-server/middlewares/index.ts +37 -0
  52. package/http-server/middlewares/types.ts +27 -0
  53. package/index.ts +24 -0
  54. package/package.json +37 -0
  55. package/queue/EmailQueue.ts +228 -0
  56. package/queue/RateLimiter.ts +54 -0
  57. package/queue/index.ts +2 -0
  58. package/resend/Resend.ts +190 -0
  59. package/resend/index.ts +11 -0
  60. package/s2/S2.ts +335 -0
  61. package/s2/index.ts +11 -0
@@ -0,0 +1,428 @@
1
+ /**
2
+ * GmailMessage - Email Message Processing and Serving
3
+ *
4
+ * The GmailMessage class represents an individual email message and implements
5
+ * the Fragment interface to provide multiple serving modes. It handles email
6
+ * content processing, MIME parsing, and format conversion for different use cases.
7
+ *
8
+ * Key Features:
9
+ * - Multiple serving modes (original, markdown, JSON)
10
+ * - Intelligent content type selection for rendering
11
+ * - CID URL rewriting for embedded content
12
+ * - Attachment and part management
13
+ * - Efficient content fetching with caching
14
+ *
15
+ * The class implements RFC 2046 compliant MIME parsing and provides
16
+ * intelligent content selection for optimal rendering across different formats.
17
+ *
18
+ * @class GmailMessage
19
+ * @implements Fragment
20
+ * @version 1.0.0
21
+ * @author Divizend GmbH
22
+ */
23
+
24
+ import { gmail_v1 } from "googleapis";
25
+ import TurndownService from "turndown";
26
+ import {
27
+ Gmail,
28
+ GmailMessagePart,
29
+ GmailMessagePartData,
30
+ Fragment,
31
+ FragmentServingMode,
32
+ Universe,
33
+ URI,
34
+ GmailMessageURI,
35
+ } from "../..";
36
+ import { rewriteCidUrls } from "./utils";
37
+
38
+ export class GmailMessage implements Fragment {
39
+ /**
40
+ * Set of MIME types that can be rendered directly
41
+ *
42
+ * These types represent the primary content formats that
43
+ * can be displayed to users in a readable form.
44
+ */
45
+ private static readonly RENDERABLE_TYPES = new Set([
46
+ "text/html",
47
+ "text/x-amp-html",
48
+ "text/plain",
49
+ ]);
50
+
51
+ /**
52
+ * Creates a new GmailMessage instance
53
+ *
54
+ * @param gmail - Reference to the Gmail service instance
55
+ * @param message - Raw Gmail API message data
56
+ * @param isFull - Whether the message contains full content
57
+ */
58
+ constructor(
59
+ private readonly gmail: Gmail,
60
+ public readonly message: gmail_v1.Schema$Message,
61
+ public readonly isFull: boolean
62
+ ) {}
63
+
64
+ /**
65
+ * Creates a GmailMessage from a URI
66
+ *
67
+ * This factory method parses the URI to extract the email address
68
+ * and message ID, then creates the appropriate Gmail instance
69
+ * to fetch the message.
70
+ *
71
+ * @param universe - Reference to the central Universe instance
72
+ * @param uri - URI identifying the message to retrieve
73
+ * @returns Promise<GmailMessage> - The requested message
74
+ */
75
+ static async fromURI(universe: Universe, uri: URI): Promise<GmailMessage> {
76
+ const gmailMessageUri = GmailMessageURI.fromURI(uri);
77
+ const gmail = universe.gsuite.user(gmailMessageUri.email).gmail();
78
+ return GmailMessage.fromMessageId(gmail, gmailMessageUri.messageId);
79
+ }
80
+
81
+ /**
82
+ * Creates a GmailMessage from a message ID
83
+ *
84
+ * This factory method creates a prototype message and then
85
+ * fetches the full content from the Gmail API.
86
+ *
87
+ * @param gmail - Gmail service instance for the user
88
+ * @param messageId - Gmail message identifier
89
+ * @returns Promise<GmailMessage> - The full message
90
+ */
91
+ static async fromMessageId(
92
+ gmail: Gmail,
93
+ messageId: string
94
+ ): Promise<GmailMessage> {
95
+ const proto = new GmailMessage(gmail, { id: messageId }, false);
96
+ return proto.fetch();
97
+ }
98
+
99
+ /**
100
+ * Fetches the full message content from Gmail
101
+ *
102
+ * This method retrieves the complete message data including
103
+ * headers, body parts, and attachments. It's an expensive
104
+ * operation that should be used judiciously.
105
+ *
106
+ * @returns Promise<GmailMessage> - Message with full content
107
+ */
108
+ async fetch(): Promise<GmailMessage> {
109
+ if (this.isFull) {
110
+ return this;
111
+ }
112
+
113
+ return new GmailMessage(
114
+ this.gmail,
115
+ (
116
+ await this.gmail.gmail.users.messages.get({
117
+ userId: "me",
118
+ id: this.message.id!,
119
+ format: "full",
120
+ })
121
+ ).data,
122
+ true
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Gets the URI identifier for this message
128
+ *
129
+ * The URI follows the format: gmail://email/message/messageId
130
+ * and can be used to reference this message in other parts
131
+ * of the system.
132
+ */
133
+ get uri() {
134
+ return `gmail://${this.gmail.email}/message/${this.message.id}`;
135
+ }
136
+
137
+ /**
138
+ * Gets the raw message data from the Gmail API
139
+ *
140
+ * This provides access to all the original message properties
141
+ * including headers, payload structure, and metadata.
142
+ */
143
+ get data() {
144
+ return this.message;
145
+ }
146
+
147
+ /**
148
+ * Gets the subject line of the email
149
+ *
150
+ * Extracts the subject from the message headers, providing
151
+ * a convenient way to access this commonly used field.
152
+ */
153
+ get subject() {
154
+ return this.message.payload?.headers?.find(
155
+ (h) => h.name!.toLowerCase() === "subject"
156
+ )?.value!;
157
+ }
158
+
159
+ /**
160
+ * Serves the message content in the specified format
161
+ *
162
+ * This method implements the Fragment interface and provides
163
+ * multiple serving modes optimized for different use cases:
164
+ * - ORIGINAL: Native format with CID URL rewriting
165
+ * - MARKDOWN: Converted to Markdown for easy reading
166
+ * - JSON: Structured metadata and content representation
167
+ *
168
+ * @param format - The desired serving mode
169
+ * @returns Promise containing headers and data for the served content
170
+ * @throws Error if the serving mode is not supported or content processing fails
171
+ */
172
+ async serve(format: FragmentServingMode): Promise<{
173
+ headers: { name: string; value: string }[];
174
+ data: Buffer;
175
+ }> {
176
+ if (!this.isFull) {
177
+ throw new Error("Message is not full");
178
+ }
179
+
180
+ if (format === FragmentServingMode.ORIGINAL) {
181
+ const renderable = this.chooseRenderable(this.message.payload!);
182
+ if (!renderable) {
183
+ throw new Error("No renderable part found");
184
+ }
185
+
186
+ const renderablePart = await GmailMessagePart.fromMessageAndPart(
187
+ this.gmail,
188
+ this,
189
+ renderable
190
+ );
191
+ const ret = await renderablePart.serve(format);
192
+
193
+ const chosenType = (renderablePart.part.mimeType || "").toLowerCase();
194
+ if (chosenType === "text/html" || chosenType === "text/x-amp-html") {
195
+ const text = await renderablePart.asText();
196
+ const cidMap = this.buildCidMap(this.message.payload!);
197
+ let html = rewriteCidUrls(text, this.uri, cidMap);
198
+ ret.data = Buffer.from(html);
199
+ ret.headers = ret.headers.filter(
200
+ (h) => h.name.toLowerCase() !== "content-type"
201
+ );
202
+ ret.headers.push({
203
+ name: "Content-Type",
204
+ value: "text/html; charset=utf-8",
205
+ });
206
+ }
207
+
208
+ return ret;
209
+ } else if (format === FragmentServingMode.MARKDOWN) {
210
+ const original = await this.serve(FragmentServingMode.ORIGINAL);
211
+ const contentType = original.headers.find(
212
+ (h) => h.name.toLowerCase() === "content-type"
213
+ )?.value;
214
+
215
+ let markdown = original.data.toString();
216
+ if (contentType?.includes("text/html")) {
217
+ const turndownService = new TurndownService();
218
+ turndownService.remove(["script", "style"]);
219
+ markdown = turndownService.turndown(markdown).trim();
220
+ }
221
+
222
+ return {
223
+ headers: [
224
+ { name: "Content-Type", value: "text/markdown; charset=utf-8" },
225
+ ],
226
+ data: Buffer.from(markdown),
227
+ };
228
+ } else if (format === FragmentServingMode.JSON) {
229
+ return {
230
+ headers: [{ name: "Content-Type", value: "application/json" }],
231
+ data: Buffer.from(
232
+ JSON.stringify({
233
+ message: this.message,
234
+ cidMap: this.partIdsToURIs(this.buildCidMap(this.message.payload!)),
235
+ attachments: this.partIdsToURIs(
236
+ this.buildAttachmentsMap(this.message.payload!)
237
+ ),
238
+ })
239
+ ),
240
+ };
241
+ } else {
242
+ throw new Error(`Unknown serving mode: ${format}`);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Choose the best renderable body part per RFC 2046.
248
+ * - For multipart/alternative -> scan from LAST to FIRST and pick first we can render.
249
+ * - For multipart/related -> the FIRST part is the root; recurse into it.
250
+ * - For multipart/mixed -> collect renderable candidates and pick "best" (html > plain).
251
+ * - For multipart/signed -> the FIRST part is the content.
252
+ * - For message/rfc822 -> recurse into its enclosed structure if present (Gmail exposes as parts).
253
+ * Returns the chosen message part (not yet decoded).
254
+ */
255
+ private chooseRenderable(
256
+ part: gmail_v1.Schema$MessagePart
257
+ ): gmail_v1.Schema$MessagePart | null {
258
+ const type = (part.mimeType || "").toLowerCase();
259
+
260
+ if (GmailMessage.RENDERABLE_TYPES.has(type)) return part;
261
+
262
+ if (type.startsWith("multipart/alternative")) {
263
+ const ps = part.parts || [];
264
+ for (let i = ps.length - 1; i >= 0; i--) {
265
+ const cand = this.chooseRenderable(ps[i]!);
266
+ if (cand) return cand;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ if (type.startsWith("multipart/related")) {
272
+ const ps = part.parts || [];
273
+ if (ps.length === 0) return null;
274
+ // RFC: first part is the root (typically html or alternative)
275
+ return this.chooseRenderable(ps[0]!) || null;
276
+ }
277
+
278
+ if (type.startsWith("multipart/signed")) {
279
+ const ps = part.parts || [];
280
+ if (ps.length === 0) return null;
281
+ // first part is the signed content
282
+ return this.chooseRenderable(ps[0]!) || null;
283
+ }
284
+
285
+ if (type.startsWith("multipart/mixed")) {
286
+ const ps = part.parts || [];
287
+ const cands: gmail_v1.Schema$MessagePart[] = [];
288
+ for (const p of ps) {
289
+ const c = this.chooseRenderable(p);
290
+ if (c) cands.push(c);
291
+ }
292
+ // prefer html > amp-html > plain
293
+ const score = (t: string) =>
294
+ t === "text/html"
295
+ ? 3
296
+ : t === "text/x-amp-html"
297
+ ? 2
298
+ : t === "text/plain"
299
+ ? 1
300
+ : 0;
301
+ let best: gmail_v1.Schema$MessagePart | null = null;
302
+ let bestScore = 0;
303
+ for (const c of cands) {
304
+ const s = score((c.mimeType || "").toLowerCase());
305
+ if (s > bestScore) {
306
+ best = c;
307
+ bestScore = s;
308
+ }
309
+ }
310
+ return best;
311
+ }
312
+
313
+ if (type === "message/rfc822") {
314
+ // Gmail often exposes enclosed message under .parts as a mini-tree
315
+ const ps = part.parts || [];
316
+ for (const p of ps) {
317
+ const cand = this.chooseRenderable(p);
318
+ if (cand) return cand;
319
+ }
320
+ return null;
321
+ }
322
+
323
+ // Other types are not directly renderable here
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * Generic method to traverse all parts of a message recursively.
329
+ * Calls the provided callback for each part encountered.
330
+ *
331
+ * @param root - The root message part to start traversal from
332
+ * @param callback - Function called for each part with the part and a helper GmailMessagePart instance
333
+ */
334
+ private traverseParts(
335
+ root: gmail_v1.Schema$MessagePart,
336
+ callback: (
337
+ part: gmail_v1.Schema$MessagePart,
338
+ partHelper: GmailMessagePart
339
+ ) => void
340
+ ) {
341
+ const stack: gmail_v1.Schema$MessagePart[] = [root];
342
+
343
+ while (stack.length) {
344
+ const part = stack.pop()!;
345
+ const partHelper = new GmailMessagePart(
346
+ this.gmail,
347
+ part as GmailMessagePartData,
348
+ this.message.id!,
349
+ false
350
+ );
351
+
352
+ callback(part, partHelper);
353
+
354
+ if (part.parts?.length) {
355
+ stack.push(...part.parts);
356
+ }
357
+ }
358
+ }
359
+
360
+ private buildCidMap(
361
+ root: gmail_v1.Schema$MessagePart,
362
+ map: { [key: string]: string } = {}
363
+ ) {
364
+ this.traverseParts(root, (part, partHelper) => {
365
+ const cidRaw = partHelper.getHeader("content-id");
366
+ if (cidRaw && part.partId) {
367
+ const cid = cidRaw.trim().replace(/^<|>$/g, "").toLowerCase();
368
+ if (!map[cid]) map[cid] = part.partId;
369
+ }
370
+ });
371
+ return map;
372
+ }
373
+
374
+ private partIdsToURIs(partsMap: { [key: string]: string }) {
375
+ return Object.fromEntries(
376
+ Object.entries(partsMap).map(([cid, partId]) => [
377
+ cid,
378
+ `gmail://${this.gmail.email}/message/${this.message.id}/part/${partId}`,
379
+ ])
380
+ );
381
+ }
382
+
383
+ /**
384
+ * Build a map of "regular" attachments (as shown in Gmail's UI).
385
+ * We consider a part a regular attachment if:
386
+ * - it is NOT a multipart container,
387
+ * - it has a non-empty filename,
388
+ * - it has a body.attachmentId (i.e., is a downloadable blob),
389
+ * - and its Content-Disposition is not "inline".
390
+ *
391
+ * Returns a Map where:
392
+ * key = attachment filename
393
+ * value = partId (use with users.messages.attachments.get)
394
+ */
395
+ private buildAttachmentsMap(
396
+ root: gmail_v1.Schema$MessagePart,
397
+ map: { [key: string]: string } = {}
398
+ ) {
399
+ this.traverseParts(root, (part, partHelper) => {
400
+ const type = (part.mimeType || "").toLowerCase();
401
+ const isMultipart = type.startsWith("multipart/");
402
+
403
+ const disposition = (
404
+ partHelper.getHeader("content-disposition") || ""
405
+ ).toLowerCase();
406
+ const isInline = disposition.startsWith("inline");
407
+
408
+ const filename = (part.filename || "").trim();
409
+ const hasAttachmentId = !!part.body?.attachmentId;
410
+ const isSignature = type.startsWith("application/pkcs7-signature");
411
+
412
+ if (
413
+ !isMultipart &&
414
+ filename &&
415
+ hasAttachmentId &&
416
+ !isInline &&
417
+ part.partId &&
418
+ !isSignature
419
+ ) {
420
+ if (!map[filename]) {
421
+ map[filename] = part.partId;
422
+ }
423
+ }
424
+ });
425
+
426
+ return map;
427
+ }
428
+ }