@mcinteerj/openclaw-gmail 1.3.0 → 1.4.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/src/channel.ts CHANGED
@@ -21,27 +21,27 @@ import { setGmailRuntime, getGmailRuntime } from "./runtime.js";
21
21
  import { sendGmailText, type GmailOutboundContext } from "./outbound.js";
22
22
  import { gmailThreading } from "./threading.js";
23
23
  import { normalizeGmailTarget, isGmailThreadId, isAllowed } from "./normalize.js";
24
- import { parseInboundGmail, type GogPayload } from "./inbound.js";
25
- import { monitorGmail, quarantineMessage } from "./monitor.js";
26
- import { extractAttachments } from "./attachments.js";
24
+ import { monitorGmail } from "./monitor.js";
27
25
  import { Semaphore } from "./semaphore.js";
26
+ import { createGmailClient, type GmailClient } from "./gmail-client.js";
28
27
  import crypto from "node:crypto";
29
28
 
30
29
  const meta = {
31
30
  id: "gmail",
32
31
  label: "Gmail",
33
- selectionLabel: "Gmail (gog)",
32
+ selectionLabel: "Gmail",
34
33
  detailLabel: "Gmail",
35
34
  docsPath: "/channels/gmail",
36
35
  docsLabel: "gmail",
37
- blurb: "Uses gog for secure Gmail access.",
36
+ blurb: "Gmail integration via direct API or gog CLI.",
38
37
  systemImage: "envelope",
39
38
  order: 100,
40
39
  showConfigured: true,
41
40
  };
42
41
 
43
- // Map to store active account contexts
42
+ // Map to store active account contexts and their clients
44
43
  const activeAccounts = new Map<string, ChannelGatewayContext<ResolvedGmailAccount>>();
44
+ const activeClients = new Map<string, GmailClient>();
45
45
 
46
46
  // Limit concurrent dispatches to avoid memory spikes
47
47
  const dispatchSemaphore = new Semaphore(5);
@@ -65,7 +65,7 @@ function buildGmailMsgContext(
65
65
  CommandBody: msg.text,
66
66
  From: msg.sender.id,
67
67
  To: to,
68
- SessionKey: `gmail:${account.email}:${msg.threadId}`,
68
+ SessionKey: `agent:main:gmail:${account.email}:${msg.threadId}`,
69
69
  AccountId: msg.accountId,
70
70
  ChatType: "direct",
71
71
  ConversationLabel: threadLabel,
@@ -84,7 +84,7 @@ function buildGmailMsgContext(
84
84
  MediaUrl: msg.mediaUrl,
85
85
  CommandAuthorized: false,
86
86
  OriginatingChannel: "gmail" as const,
87
- OriginatingTo: to,
87
+ OriginatingTo: msg.threadId,
88
88
  });
89
89
 
90
90
  return ctx;
@@ -93,6 +93,7 @@ function buildGmailMsgContext(
93
93
  async function dispatchGmailMessage(
94
94
  ctx: ChannelGatewayContext<ResolvedGmailAccount>,
95
95
  msg: InboundMessage,
96
+ client: GmailClient,
96
97
  ) {
97
98
  const { account, accountId, cfg, log } = ctx;
98
99
  const runtime = getGmailRuntime();
@@ -101,7 +102,7 @@ async function dispatchGmailMessage(
101
102
  await dispatchSemaphore.run(async () => {
102
103
  try {
103
104
  log?.info(`[gmail][${requestId}] Dispatching message ${msg.channelMessageId} from ${msg.sender.id}`);
104
-
105
+
105
106
  // Build the dispatch context
106
107
  const ctxPayload = buildGmailMsgContext(msg, account, cfg);
107
108
  const gmailCfg = cfg.channels?.gmail as GmailConfig | undefined;
@@ -109,10 +110,10 @@ async function dispatchGmailMessage(
109
110
  // Build reply dispatcher options using gateway's reply capability
110
111
  const deliver = async (payload: { text: string }) => {
111
112
  const originalSubject = msg.raw?.subject ||
112
- msg.raw?.headers?.subject ||
113
+ msg.raw?.headers?.subject ||
113
114
  msg.raw?.payload?.headers?.find((h: any) => h.name.toLowerCase() === "subject")?.value;
114
-
115
- const replySubject = originalSubject
115
+
116
+ const replySubject = originalSubject
116
117
  ? (originalSubject.toLowerCase().startsWith("re:") ? originalSubject : `Re: ${originalSubject}`)
117
118
  : "Re: ";
118
119
 
@@ -124,6 +125,7 @@ async function dispatchGmailMessage(
124
125
  threadId: msg.threadId,
125
126
  replyToId: msg.channelMessageId,
126
127
  subject: replySubject,
128
+ client,
127
129
  });
128
130
  };
129
131
 
@@ -188,6 +190,16 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
188
190
  historyId: { type: "string" },
189
191
  delegate: { type: "string" },
190
192
  archiveOnReply: { type: "boolean", default: true },
193
+ backend: { type: "string", enum: ["gog", "api"] },
194
+ oauth: {
195
+ type: "object",
196
+ properties: {
197
+ clientId: { type: "string" },
198
+ clientSecret: { type: "string" },
199
+ refreshToken: { type: "string" },
200
+ },
201
+ required: ["clientId", "clientSecret", "refreshToken"],
202
+ },
191
203
  },
192
204
  required: ["email"],
193
205
  },
@@ -237,7 +249,12 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
237
249
  outbound: {
238
250
  deliveryMode: "gateway",
239
251
  textChunkLimit: 8000,
240
- sendText: sendGmailText,
252
+ sendText: (ctx: any) => {
253
+ const account = resolveGmailAccount(ctx.cfg, ctx.accountId);
254
+ const emailKey = account.email?.toLowerCase();
255
+ const client = (emailKey && activeClients.get(emailKey)) || createGmailClient(account, ctx.cfg);
256
+ return sendGmailText({ ...ctx, client });
257
+ },
241
258
  resolveTarget: ({ to, allowFrom }) => {
242
259
  const trimmed = to?.trim() ?? "";
243
260
  const normalized = normalizeGmailTarget(trimmed);
@@ -300,8 +317,10 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
300
317
  "### Attachments",
301
318
  "- **Location**: All attachments are stored in \`.attachments/{{threadId}}/\` relative to your workspace.",
302
319
  "- **Auto-Download**: Files under 5MB are already there. The message text contains their paths.",
303
- "- **Manual Download**: For larger files (listed with an ID), download them to that same folder:",
304
- `- Command: \`mkdir -p .attachments/{{threadId}} && gog gmail attachment <messageId> <attachmentId> --account ${account.email} --out .attachments/{{threadId}}/<filename>\``,
320
+ "- **Manual Download**: For larger files (listed with an ID), download them to that same folder.",
321
+ ...(account.backend === "api"
322
+ ? ["- The attachment download tool is available as part of the Gmail API client."]
323
+ : [`- Command: \`mkdir -p .attachments/{{threadId}} && gog gmail attachment <messageId> <attachmentId> --account ${account.email} --out .attachments/{{threadId}}/<filename>\``]),
305
324
  ];
306
325
  },
307
326
  },
@@ -310,15 +329,19 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
310
329
  supportsAction: ({ action }: { action: string }) => action === "send",
311
330
  handleAction: async (ctx: any) => {
312
331
  if (ctx.action !== "send") return { ok: false, error: new Error(`Unsupported action: ${ctx.action}`) };
313
-
332
+
314
333
  const { params, accountId, cfg, toolContext } = ctx;
334
+ const account = resolveGmailAccount(cfg, accountId);
335
+ const emailKey = account.email?.toLowerCase();
336
+ const client = (emailKey && activeClients.get(emailKey)) || createGmailClient(account, cfg);
337
+
315
338
  const to = (params.target || params.to) as string;
316
339
  const text = params.message as string;
317
-
340
+
318
341
  const isThread = isGmailThreadId(to);
319
342
  let subject = params.subject as string | undefined;
320
343
  let replyToId: string | undefined;
321
-
344
+
322
345
  if (isThread && toolContext?.currentThreadTs) {
323
346
  replyToId = toolContext.currentThreadTs;
324
347
  }
@@ -331,8 +354,9 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
331
354
  threadId: isThread ? to : undefined,
332
355
  replyToId,
333
356
  subject,
357
+ client,
334
358
  });
335
-
359
+
336
360
  return { ok: true, content: [{ type: "text", text: "Message sent via Gmail." }] };
337
361
  },
338
362
  },
@@ -340,8 +364,12 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
340
364
  startAccount: async (ctx) => {
341
365
  ctx.log?.info(`[gmail] Account ${ctx.account.accountId} started`);
342
366
 
343
- if (ctx.account.email) {
344
- activeAccounts.set(ctx.account.email.toLowerCase(), ctx);
367
+ const client = createGmailClient(ctx.account, ctx.cfg);
368
+ const emailKey = ctx.account.email?.toLowerCase();
369
+
370
+ if (emailKey) {
371
+ activeAccounts.set(emailKey, ctx);
372
+ activeClients.set(emailKey, client);
345
373
  }
346
374
 
347
375
  ctx.setStatus({ accountId: ctx.accountId, running: true, connected: true });
@@ -355,11 +383,12 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
355
383
  await monitorGmail({
356
384
  account: ctx.account,
357
385
  onMessage: async (msg) => {
358
- await dispatchGmailMessage(ctx, msg);
386
+ await dispatchGmailMessage(ctx, msg, client);
359
387
  },
360
388
  signal,
361
389
  log: ctx.log,
362
390
  setStatus: ctx.setStatus,
391
+ client,
363
392
  }).catch((err) => {
364
393
  if (!signal.aborted) {
365
394
  ctx.log?.error(`[gmail] Monitor error: ${String(err)}`);
@@ -367,8 +396,9 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
367
396
  });
368
397
 
369
398
  // Cleanup after monitor exits
370
- if (ctx.account.email) {
371
- activeAccounts.delete(ctx.account.email.toLowerCase());
399
+ if (emailKey) {
400
+ activeAccounts.delete(emailKey);
401
+ activeClients.delete(emailKey);
372
402
  }
373
403
  ctx.setStatus({ accountId: ctx.accountId, running: false, connected: false });
374
404
  },
package/src/config.ts CHANGED
@@ -16,6 +16,12 @@ export const GmailAccountSchema = z.object({
16
16
  allowOutboundTo: z.array(z.string()).optional(), // Who we can SEND to (if not set, falls back to allowFrom)
17
17
  threadReplyPolicy: z.enum(["open", "allowlist", "sender-only"]).optional(), // Default: "open" for backwards compat
18
18
  archiveOnReply: z.boolean().optional(), // Archive thread after reply (default: true)
19
+ backend: z.enum(["gog", "api"]).optional(), // Gmail backend: gog CLI (default) or googleapis
20
+ oauth: z.object({
21
+ clientId: z.string(),
22
+ clientSecret: z.string(),
23
+ refreshToken: z.string(),
24
+ }).optional(), // OAuth2 credentials for API backend
19
25
  });
20
26
 
21
27
  export const GmailConfigSchema = z.object({
@@ -0,0 +1,50 @@
1
+ import type { ResolvedGmailAccount } from "./accounts.js";
2
+ import type { ThreadResponse } from "./quoting.js";
3
+ import type { GogSearchMessage } from "./inbound.js";
4
+ import { GogGmailClient } from "./gog-client.js";
5
+ import { ApiGmailClient } from "./api-client.js";
6
+ import { resolveOAuthCredentials, createOAuth2Client } from "./auth.js";
7
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
+
9
+ export interface GmailClient {
10
+ send(opts: {
11
+ account?: string;
12
+ to?: string;
13
+ subject: string;
14
+ textBody: string;
15
+ htmlBody?: string;
16
+ threadId?: string;
17
+ replyToMessageId?: string;
18
+ replyAll?: boolean;
19
+ }): Promise<void>;
20
+
21
+ getThread(threadId: string, opts?: { full?: boolean }): Promise<ThreadResponse | null>;
22
+ getMessage(messageId: string): Promise<Record<string, unknown> | null>;
23
+ searchMessages(query: string, opts?: { maxResults?: number; includeBody?: boolean }): Promise<GogSearchMessage[]>;
24
+ searchThreads(query: string, opts?: { maxResults?: number }): Promise<Record<string, unknown> | null>;
25
+ modifyLabels(id: string, opts: { add?: string[]; remove?: string[] }): Promise<void>;
26
+ modifyThreadLabels(threadId: string, opts: { add?: string[]; remove?: string[] }): Promise<void>;
27
+ listLabels(): Promise<{ id: string; name: string }[]>;
28
+ createLabel(name: string): Promise<void>;
29
+ downloadAttachment(messageId: string, attachmentId: string, outPath: string): Promise<void>;
30
+ getSendAs(): Promise<{ displayName?: string; email: string; isPrimary?: boolean }[]>;
31
+ }
32
+
33
+ export function createGmailClient(account: ResolvedGmailAccount, cfg?: OpenClawConfig): GmailClient {
34
+ if (account.backend === "api") {
35
+ if (!cfg) {
36
+ throw new Error(`API backend requires config to resolve OAuth credentials for ${account.email}`);
37
+ }
38
+ const creds = resolveOAuthCredentials(account.email, cfg);
39
+ if (!creds) {
40
+ throw new Error(
41
+ `No OAuth credentials found for ${account.email}. ` +
42
+ `Run onboarding or set oauth.clientId, oauth.clientSecret, and oauth.refreshToken in config.`,
43
+ );
44
+ }
45
+ const auth = createOAuth2Client(creds);
46
+ return new ApiGmailClient(auth);
47
+ }
48
+
49
+ return new GogGmailClient(account.email);
50
+ }
@@ -0,0 +1,230 @@
1
+ import { spawn } from "node:child_process";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import type { GmailClient } from "./gmail-client.js";
5
+ import type { ThreadResponse } from "./quoting.js";
6
+ import { parseGogThreadOutput } from "./quoting.js";
7
+ import type { GogSearchMessage } from "./inbound.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ const GOG_TIMEOUT_MS = 30_000;
12
+
13
+ interface CircuitState {
14
+ consecutiveFailures: number;
15
+ lastFailureAt: number;
16
+ backoffUntil: number;
17
+ }
18
+
19
+ const CIRCUIT_CONFIG = {
20
+ maxFailures: 3,
21
+ initialBackoffMs: 60_000,
22
+ maxBackoffMs: 15 * 60_000,
23
+ };
24
+
25
+ export class GogGmailClient implements GmailClient {
26
+ private circuit: CircuitState = { consecutiveFailures: 0, lastFailureAt: 0, backoffUntil: 0 };
27
+
28
+ constructor(private accountEmail: string) {}
29
+
30
+ /**
31
+ * Check if the gog CLI is available on PATH.
32
+ */
33
+ static async checkExists(): Promise<boolean> {
34
+ try {
35
+ await execFileAsync("gog", ["--version"]);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Write operation via spawn() — fire-and-forget style with retries.
44
+ * Mirrors outbound.ts:17-44.
45
+ */
46
+ private async execGog(args: string[], retries = 3): Promise<void> {
47
+ for (let i = 0; i < retries; i++) {
48
+ try {
49
+ await new Promise<void>((resolve, reject) => {
50
+ const proc = spawn("gog", args, { stdio: "pipe" });
51
+ let err = "";
52
+ let out = "";
53
+ proc.stderr.on("data", (d) => (err += d.toString()));
54
+ proc.stdout.on("data", (d) => (out += d.toString()));
55
+ proc.on("error", (e) => reject(new Error(`gog failed to spawn: ${e.message}`)));
56
+ proc.on("close", (code) => {
57
+ if (code === 0) resolve();
58
+ else reject(new Error(`gog failed (code ${code}): ${err || out}`));
59
+ });
60
+ setTimeout(() => {
61
+ proc.kill();
62
+ reject(new Error("gog timed out after 30s"));
63
+ }, GOG_TIMEOUT_MS);
64
+ });
65
+ return;
66
+ } catch (err) {
67
+ if (i === retries - 1) throw err;
68
+ await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Read operation via execFileAsync() with circuit breaker.
75
+ * Mirrors monitor.ts:70-125.
76
+ */
77
+ private async runGog(args: string[], retries = 3): Promise<Record<string, unknown> | null> {
78
+ const circuit = this.circuit;
79
+ const now = Date.now();
80
+
81
+ if (now < circuit.backoffUntil) {
82
+ throw new Error(
83
+ `Circuit breaker active for ${this.accountEmail}. Backing off until ${new Date(circuit.backoffUntil).toISOString()}`,
84
+ );
85
+ }
86
+
87
+ const allArgs = ["--json", "--account", this.accountEmail, ...args];
88
+ let lastErr: any;
89
+
90
+ for (let i = 0; i < retries; i++) {
91
+ try {
92
+ const { stdout } = await execFileAsync("gog", allArgs, {
93
+ maxBuffer: 10 * 1024 * 1024,
94
+ timeout: GOG_TIMEOUT_MS,
95
+ });
96
+
97
+ // Success — reset circuit
98
+ circuit.consecutiveFailures = 0;
99
+ circuit.backoffUntil = 0;
100
+
101
+ if (!stdout.trim()) return null;
102
+ return JSON.parse(stdout) as Record<string, unknown>;
103
+ } catch (err: unknown) {
104
+ lastErr = err;
105
+ const msg = String(err);
106
+
107
+ // 404 → not found, not a failure
108
+ if (msg.includes("404")) return null;
109
+
110
+ // Trip circuit breaker on auth / server errors
111
+ if (msg.includes("403") || msg.includes("invalid_grant") || msg.includes("ETIMEDOUT") || msg.includes("500")) {
112
+ circuit.consecutiveFailures++;
113
+ circuit.lastFailureAt = Date.now();
114
+
115
+ if (circuit.consecutiveFailures >= CIRCUIT_CONFIG.maxFailures) {
116
+ const backoff = Math.min(
117
+ CIRCUIT_CONFIG.initialBackoffMs * Math.pow(2, circuit.consecutiveFailures - CIRCUIT_CONFIG.maxFailures),
118
+ CIRCUIT_CONFIG.maxBackoffMs,
119
+ );
120
+ circuit.backoffUntil = Date.now() + backoff;
121
+ }
122
+ break;
123
+ }
124
+
125
+ if (i < retries - 1) {
126
+ await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
127
+ }
128
+ }
129
+ }
130
+
131
+ const error = lastErr as { stderr?: string; message?: string };
132
+ throw new Error(`gog failed: ${error.stderr || error.message || String(lastErr)}`);
133
+ }
134
+
135
+ // ── GmailClient interface ──────────────────────────────────────────
136
+
137
+ async send(opts: {
138
+ account?: string;
139
+ to?: string;
140
+ subject: string;
141
+ textBody: string;
142
+ htmlBody?: string;
143
+ threadId?: string;
144
+ replyToMessageId?: string;
145
+ replyAll?: boolean;
146
+ }): Promise<void> {
147
+ const args = ["gmail", "send"];
148
+ if (this.accountEmail) args.push("--account", this.accountEmail);
149
+ if (opts.to) args.push("--to", opts.to);
150
+ if (opts.subject) args.push("--subject", opts.subject);
151
+ if (opts.threadId) args.push("--thread-id", opts.threadId);
152
+ if (opts.replyToMessageId) args.push("--reply-to-message-id", opts.replyToMessageId);
153
+ if (opts.replyAll) args.push("--reply-all");
154
+ if (opts.htmlBody) args.push("--body-html", opts.htmlBody);
155
+ args.push("--body", opts.textBody);
156
+ await this.execGog(args);
157
+ }
158
+
159
+ async getThread(threadId: string, opts?: { full?: boolean }): Promise<ThreadResponse | null> {
160
+ const args = ["gmail", "thread", "get", threadId];
161
+ if (opts?.full !== false) args.push("--full");
162
+ const data = await this.runGog(args);
163
+ if (!data) return null;
164
+ return parseGogThreadOutput(data);
165
+ }
166
+
167
+ async getMessage(messageId: string): Promise<Record<string, unknown> | null> {
168
+ return this.runGog(["gmail", "get", messageId]);
169
+ }
170
+
171
+ async searchMessages(
172
+ query: string,
173
+ opts?: { maxResults?: number; includeBody?: boolean },
174
+ ): Promise<GogSearchMessage[]> {
175
+ const args = ["gmail", "messages", "search", query];
176
+ if (opts?.includeBody !== false) args.push("--include-body");
177
+ args.push("--max", String(opts?.maxResults ?? 50));
178
+ const res = await this.runGog(args);
179
+ if (!res || !Array.isArray((res as any).messages)) return [];
180
+ return (res as any).messages as GogSearchMessage[];
181
+ }
182
+
183
+ async searchThreads(query: string, opts?: { maxResults?: number }): Promise<Record<string, unknown> | null> {
184
+ const args = ["gmail", "search", query];
185
+ args.push("--max", String(opts?.maxResults ?? 50));
186
+ return this.runGog(args);
187
+ }
188
+
189
+ async modifyLabels(id: string, opts: { add?: string[]; remove?: string[] }): Promise<void> {
190
+ const args = ["gmail", "labels", "modify", id];
191
+ if (opts.add) for (const l of opts.add) args.push("--add", l);
192
+ if (opts.remove) for (const l of opts.remove) args.push("--remove", l);
193
+ await this.runGog(args);
194
+ }
195
+
196
+ async modifyThreadLabels(threadId: string, opts: { add?: string[]; remove?: string[] }): Promise<void> {
197
+ const args = ["gmail", "thread", "modify", threadId];
198
+ if (opts.add) for (const l of opts.add) args.push("--add", l);
199
+ if (opts.remove) for (const l of opts.remove) args.push("--remove", l);
200
+ await this.runGog(args);
201
+ }
202
+
203
+ async listLabels(): Promise<{ id: string; name: string }[]> {
204
+ const res = await this.runGog(["gmail", "labels", "list"]);
205
+ return ((res as any)?.labels || []) as { id: string; name: string }[];
206
+ }
207
+
208
+ async createLabel(name: string): Promise<void> {
209
+ await this.runGog(["gmail", "labels", "create", name]);
210
+ }
211
+
212
+ async downloadAttachment(messageId: string, attachmentId: string, outPath: string): Promise<void> {
213
+ // No circuit breaker for attachment downloads
214
+ await execFileAsync("gog", [
215
+ "gmail",
216
+ "attachment",
217
+ messageId,
218
+ attachmentId,
219
+ "--account",
220
+ this.accountEmail,
221
+ "--out",
222
+ outPath,
223
+ ]);
224
+ }
225
+
226
+ async getSendAs(): Promise<{ displayName?: string; email: string; isPrimary?: boolean }[]> {
227
+ const res = await this.runGog(["gmail", "settings", "sendas", "list"]);
228
+ return ((res as any)?.sendAs || []) as { displayName?: string; email: string; isPrimary?: boolean }[];
229
+ }
230
+ }
package/src/mime.ts ADDED
@@ -0,0 +1,37 @@
1
+ import MailComposer from "nodemailer/lib/mail-composer";
2
+
3
+ export interface MimeMessageOpts {
4
+ from: string;
5
+ to: string;
6
+ cc?: string;
7
+ subject: string;
8
+ text: string;
9
+ html?: string;
10
+ inReplyTo?: string;
11
+ references?: string;
12
+ }
13
+
14
+ /**
15
+ * Build an RFC 2822 MIME message using nodemailer's MailComposer.
16
+ * Returns a Buffer suitable for base64url encoding and passing to
17
+ * gmail.users.messages.send.
18
+ */
19
+ export function buildMimeMessage(opts: MimeMessageOpts): Promise<Buffer> {
20
+ const mail = new MailComposer({
21
+ from: opts.from,
22
+ to: opts.to,
23
+ cc: opts.cc,
24
+ subject: opts.subject,
25
+ text: opts.text,
26
+ html: opts.html,
27
+ inReplyTo: opts.inReplyTo,
28
+ references: opts.references,
29
+ });
30
+
31
+ return new Promise((resolve, reject) => {
32
+ mail.compile().build((err: Error | null, message: Buffer) => {
33
+ if (err) reject(err);
34
+ else resolve(message);
35
+ });
36
+ });
37
+ }