1 | import * as wmill from "windmill-client" |
2 | import { S3Object } from "windmill-client" |
3 |
|
4 | |
5 | * Send Email |
6 | * Send an email from the connected Gmail account, with optional cc/bcc, HTML body, file attachments (from Windmill object storage) and a `from` send-as alias (see List Send-As Aliases; an unverified alias falls back to the primary address). To reply in a thread, pass the original's `thread_id` and its `Message-ID` header as `in_reply_to`, and keep the same subject. Requires gmail.send (the default scope), gmail.compose or gmail.modify on the connection. |
7 | */ |
8 | export async function main( |
9 | auth: RT.Gmail, |
10 | to: string, |
11 | subject: string, |
12 | body: string, |
13 | cc: string | undefined, |
14 | bcc: string | undefined, |
15 | from: string | undefined, |
16 | is_html: boolean = false, |
17 | attachments: S3Object[] | undefined, |
18 | thread_id: string | undefined, |
19 | in_reply_to: string | undefined |
20 | ) { |
21 | const boundary = `wm_${crypto.randomUUID()}` |
22 | const headers = [ |
23 | from ? `From: ${from}` : undefined, |
24 | `To: ${to}`, |
25 | cc ? `Cc: ${cc}` : undefined, |
26 | bcc ? `Bcc: ${bcc}` : undefined, |
27 | `Subject: =?UTF-8?B?${b64(subject)}?=`, |
28 | in_reply_to ? `In-Reply-To: ${in_reply_to}` : undefined, |
29 | in_reply_to ? `References: ${in_reply_to}` : undefined, |
30 | "MIME-Version: 1.0", |
31 | ].filter((h) => h !== undefined) |
32 | const text = [ |
33 | `Content-Type: text/${is_html ? "html" : "plain"}; charset="UTF-8"`, |
34 | "Content-Transfer-Encoding: base64", |
35 | "", |
36 | wrap(b64(body)), |
37 | ].join("\r\n") |
38 |
|
39 | let mime: string |
40 | if (!attachments?.length) { |
41 | mime = `${headers.join("\r\n")}\r\n${text}` |
42 | } else { |
43 | const parts = [text] |
44 | for (const file of attachments) { |
45 | const bytes = await wmill.loadS3File(file) |
46 | if (!bytes) throw new Error(`Attachment not found: ${file.s3}`) |
47 | const name = file.s3.split("/").pop()! |
48 | parts.push( |
49 | [ |
50 | `Content-Type: ${Bun.file(name).type}; name="${name}"`, |
51 | `Content-Disposition: attachment; filename="${name}"`, |
52 | "Content-Transfer-Encoding: base64", |
53 | "", |
54 | wrap(b64(bytes)), |
55 | ].join("\r\n") |
56 | ) |
57 | } |
58 | mime = [ |
59 | ...headers, |
60 | `Content-Type: multipart/mixed; boundary="${boundary}"`, |
61 | "", |
62 | ...parts.map((p) => `--${boundary}\r\n${p}`), |
63 | `--${boundary}--`, |
64 | ].join("\r\n") |
65 | } |
66 |
|
67 | const response = await fetch( |
68 | "https://gmail.googleapis.com/gmail/v1/users/me/messages/send", |
69 | { |
70 | method: "POST", |
71 | headers: { |
72 | Authorization: `Bearer ${auth.token}`, |
73 | "Content-Type": "application/json", |
74 | Accept: "application/json", |
75 | }, |
76 | body: JSON.stringify({ |
77 | raw: Buffer.from(mime).toString("base64url"), |
78 | threadId: thread_id || undefined, |
79 | }), |
80 | } |
81 | ) |
82 | if (!response.ok) { |
83 | throw new Error(`${response.status} ${await response.text()}`) |
84 | } |
85 |
|
86 | return await response.json() |
87 | } |
88 |
|
89 | function b64(s: string | Uint8Array) { |
90 | return Buffer.from(s).toString("base64") |
91 | } |
92 |
|
93 | function wrap(s: string) { |
94 | return s.replace(/.{76}/g, "$&\r\n") |
95 | } |
96 |
|