@dench.com/cli 2.1.3 → 2.1.4

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 (2) hide show
  1. package/email.ts +424 -0
  2. package/package.json +2 -1
package/email.ts ADDED
@@ -0,0 +1,424 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { ConvexHttpClient } from "convex/browser";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { getFlag, parseJson } from "./lib/cli-args";
5
+
6
+ type EmailCliContext = {
7
+ convex: ConvexHttpClient;
8
+ args: string[];
9
+ jsonOutput: boolean;
10
+ sessionToken?: string;
11
+ bearerToken?: string;
12
+ gatewayBaseUrl?: string;
13
+ gatewayHeaders?: Record<string, string>;
14
+ };
15
+
16
+ class EmailCliError extends Error {}
17
+
18
+ type JsonRecord = Record<string, unknown>;
19
+
20
+ function normalizeGatewayBaseUrl(value: string): string {
21
+ return value.replace(/\/+$/, "").replace(/\/v1$/, "");
22
+ }
23
+
24
+ function resolveGatewayBaseUrl(ctx: EmailCliContext): string {
25
+ const explicit =
26
+ process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
27
+ if (explicit) return normalizeGatewayBaseUrl(explicit);
28
+ if (ctx.gatewayBaseUrl?.trim()) {
29
+ return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim());
30
+ }
31
+ const host = process.env.DENCH_HOST?.trim();
32
+ if (host && /localhost|127\.0\.0\.1/.test(host)) {
33
+ return "http://localhost:8787";
34
+ }
35
+ return "https://gateway.merseoriginals.com";
36
+ }
37
+
38
+ function requireBearerToken(ctx: EmailCliContext): string {
39
+ const token = ctx.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim();
40
+ if (!token) {
41
+ throw new EmailCliError(
42
+ "No Dench credential found. Set DENCH_API_KEY or run `dench signin`.",
43
+ );
44
+ }
45
+ return token;
46
+ }
47
+
48
+ async function callGateway(
49
+ ctx: EmailCliContext,
50
+ method: "GET" | "POST",
51
+ path: string,
52
+ body?: JsonRecord,
53
+ ) {
54
+ const baseUrl = resolveGatewayBaseUrl(ctx);
55
+ const response = await fetch(`${baseUrl}${path}`, {
56
+ method,
57
+ headers: {
58
+ accept: "application/json",
59
+ authorization: `Bearer ${requireBearerToken(ctx)}`,
60
+ ...(ctx.gatewayHeaders ?? {}),
61
+ ...(method === "POST" ? { "content-type": "application/json" } : {}),
62
+ },
63
+ ...(method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}),
64
+ });
65
+ const text = await response.text();
66
+ if (!response.ok) {
67
+ throw new EmailCliError(
68
+ `Email service returned ${response.status}: ${text.slice(0, 500)}`,
69
+ );
70
+ }
71
+ const payload = text ? JSON.parse(text) : {};
72
+ return payload;
73
+ }
74
+
75
+ async function callMutation(
76
+ ctx: EmailCliContext,
77
+ name: string,
78
+ args: JsonRecord,
79
+ ) {
80
+ const ref = makeFunctionReference<"mutation">(name);
81
+ return await ctx.convex.mutation(ref, {
82
+ ...args,
83
+ sessionToken: ctx.sessionToken,
84
+ } as never);
85
+ }
86
+
87
+ async function callQuery(ctx: EmailCliContext, name: string, args: JsonRecord) {
88
+ const ref = makeFunctionReference<"query">(name);
89
+ return await ctx.convex.query(ref, {
90
+ ...args,
91
+ sessionToken: ctx.sessionToken,
92
+ } as never);
93
+ }
94
+
95
+ async function callAction(
96
+ ctx: EmailCliContext,
97
+ name: string,
98
+ args: JsonRecord,
99
+ ) {
100
+ const ref = makeFunctionReference<"action">(name);
101
+ return await ctx.convex.action(ref, {
102
+ ...args,
103
+ sessionToken: ctx.sessionToken,
104
+ } as never);
105
+ }
106
+
107
+ function out(ctx: EmailCliContext, payload: unknown, text: string) {
108
+ if (ctx.jsonOutput) {
109
+ console.log(JSON.stringify(payload, null, 2));
110
+ return;
111
+ }
112
+ console.log(text);
113
+ }
114
+
115
+ function requiredFlag(args: string[], name: string): string {
116
+ const value = getFlag(args, name)?.trim();
117
+ if (!value) throw new EmailCliError(`Missing required ${name}`);
118
+ return value;
119
+ }
120
+
121
+ function optionalJsonFlag(args: string[], name: string): unknown | undefined {
122
+ const raw = getFlag(args, name);
123
+ return raw === undefined ? undefined : parseJson(raw);
124
+ }
125
+
126
+ async function readTextFlag(
127
+ args: string[],
128
+ inlineFlag: string,
129
+ fileFlag: string,
130
+ ) {
131
+ const inline = getFlag(args, inlineFlag);
132
+ if (inline !== undefined) return inline;
133
+ const path = getFlag(args, fileFlag);
134
+ if (!path) return undefined;
135
+ return await readFile(path, "utf8");
136
+ }
137
+
138
+ async function readRecipients(path: string): Promise<JsonRecord[]> {
139
+ const content = await readFile(path, "utf8");
140
+ const trimmed = content.trim();
141
+ if (!trimmed) return [];
142
+ if (trimmed.startsWith("[")) {
143
+ const parsed = JSON.parse(trimmed);
144
+ if (!Array.isArray(parsed))
145
+ throw new EmailCliError("Recipients file must be an array or JSONL");
146
+ return parsed.map((row) =>
147
+ row && typeof row === "object"
148
+ ? (row as JsonRecord)
149
+ : { email: String(row) },
150
+ );
151
+ }
152
+ return trimmed
153
+ .split(/\r?\n/)
154
+ .filter(Boolean)
155
+ .map((line) => {
156
+ const parsed = JSON.parse(line);
157
+ return parsed && typeof parsed === "object"
158
+ ? (parsed as JsonRecord)
159
+ : { email: String(parsed) };
160
+ });
161
+ }
162
+
163
+ function toRecipients(rows: JsonRecord[]) {
164
+ return rows.map((row) => {
165
+ const email =
166
+ row.email ?? row.Email ?? row["Email Address"] ?? row.emailAddress;
167
+ if (typeof email !== "string") {
168
+ throw new EmailCliError("Each recipient row must include an email field");
169
+ }
170
+ return {
171
+ email,
172
+ displayName:
173
+ typeof row.name === "string"
174
+ ? row.name
175
+ : typeof row.displayName === "string"
176
+ ? row.displayName
177
+ : undefined,
178
+ personalization: row.personalization ?? row,
179
+ personEntryId:
180
+ typeof row.personEntryId === "string" ? row.personEntryId : undefined,
181
+ companyEntryId:
182
+ typeof row.companyEntryId === "string" ? row.companyEntryId : undefined,
183
+ };
184
+ });
185
+ }
186
+
187
+ export function emailHelp() {
188
+ console.log(`dench email — Dench Emailing Service campaign commands
189
+
190
+ Usage:
191
+ dench email identity verify --from founder@example.com [--json]
192
+ dench email identity verify --domain example.com --from founder@example.com [--json]
193
+ dench email identity status --identity founder@example.com [--json]
194
+ dench email identity status --identity-id <identityId> [--json]
195
+ dench email template create --name "Intro" --subject "Hi {{firstName}}" --html-file body.html [--text-file body.txt] [--json]
196
+ dench email template preview --template <id> --data '{"firstName":"Ada"}' [--json]
197
+ dench email campaign create --name "Q2 outreach" --identity <id> --template <id> [--json]
198
+ dench email campaign recipients add --campaign <id> --file recipients.jsonl [--json]
199
+ dench email campaign preview --campaign <id> [--sample 5] [--json]
200
+ dench email campaign submit --campaign <id> [--json]
201
+ dench email campaign status --campaign <id> [--json]
202
+ dench email campaign pause|resume|cancel --campaign <id> [--json]`);
203
+ }
204
+
205
+ export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
206
+ const group = ctx.args.shift();
207
+ if (!group || group === "help" || group === "--help" || group === "-h") {
208
+ emailHelp();
209
+ return;
210
+ }
211
+ if (group === "identity") {
212
+ await runIdentity(ctx);
213
+ return;
214
+ }
215
+ if (group === "template") {
216
+ await runTemplate(ctx);
217
+ return;
218
+ }
219
+ if (group === "campaign") {
220
+ await runCampaign(ctx);
221
+ return;
222
+ }
223
+ throw new EmailCliError(`Unknown dench email subcommand: ${group}`);
224
+ }
225
+
226
+ async function runIdentity(ctx: EmailCliContext) {
227
+ const sub = ctx.args.shift();
228
+ if (sub === "verify") {
229
+ const domain = getFlag(ctx.args, "--domain");
230
+ const from = requiredFlag(ctx.args, "--from");
231
+ const type = domain ? "domain" : "email";
232
+ const identity = domain ?? from;
233
+ const configurationSetName = getFlag(ctx.args, "--configuration-set");
234
+ const created = await callMutation(
235
+ ctx,
236
+ "functions/emailCampaigns:createIdentity",
237
+ {
238
+ type,
239
+ domain,
240
+ fromEmail: from,
241
+ fromName: getFlag(ctx.args, "--from-name"),
242
+ replyToEmail: getFlag(ctx.args, "--reply-to"),
243
+ configurationSetName,
244
+ },
245
+ );
246
+ const verification = await callAction(
247
+ ctx,
248
+ "functions/emailCampaignsNode:beginSenderVerification",
249
+ { identityId: (created as JsonRecord).identityId },
250
+ );
251
+ out(
252
+ ctx,
253
+ { ok: true, created, verification },
254
+ `Verification started for ${identity}`,
255
+ );
256
+ return;
257
+ }
258
+ if (sub === "status") {
259
+ const identityId = getFlag(ctx.args, "--identity-id");
260
+ if (identityId) {
261
+ const payload = await callAction(
262
+ ctx,
263
+ "functions/emailCampaignsNode:refreshIdentityStatus",
264
+ { identityId },
265
+ );
266
+ out(ctx, payload, JSON.stringify(payload, null, 2));
267
+ return;
268
+ }
269
+ const identity = requiredFlag(ctx.args, "--identity");
270
+ const payload = await callGateway(
271
+ ctx,
272
+ "GET",
273
+ `/v1/email/identities/${encodeURIComponent(identity)}/status`,
274
+ );
275
+ const status = payload as JsonRecord;
276
+ const shaped = {
277
+ ok: status.ok === true,
278
+ identity: status.identity,
279
+ deliveryVerified: status.verifiedForSendingStatus === true,
280
+ deliveryVerificationStatus: status.verificationStatus,
281
+ };
282
+ out(ctx, shaped, JSON.stringify(shaped, null, 2));
283
+ return;
284
+ }
285
+ throw new EmailCliError("Usage: dench email identity verify|status");
286
+ }
287
+
288
+ async function runTemplate(ctx: EmailCliContext) {
289
+ const sub = ctx.args.shift();
290
+ if (sub === "create") {
291
+ const htmlBody = await readTextFlag(ctx.args, "--html", "--html-file");
292
+ if (!htmlBody) throw new EmailCliError("Missing --html or --html-file");
293
+ const textBody = await readTextFlag(ctx.args, "--text", "--text-file");
294
+ const payload = await callMutation(
295
+ ctx,
296
+ "functions/emailCampaigns:createTemplate",
297
+ {
298
+ name: requiredFlag(ctx.args, "--name"),
299
+ subject: requiredFlag(ctx.args, "--subject"),
300
+ htmlBody,
301
+ textBody,
302
+ },
303
+ );
304
+ out(
305
+ ctx,
306
+ payload,
307
+ `Template created: ${(payload as JsonRecord).templateId}`,
308
+ );
309
+ return;
310
+ }
311
+ if (sub === "preview") {
312
+ const templateId = requiredFlag(ctx.args, "--template");
313
+ const data = optionalJsonFlag(ctx.args, "--data");
314
+ const payload = await callQuery(
315
+ ctx,
316
+ "functions/emailCampaigns:previewTemplate",
317
+ { templateId, data },
318
+ );
319
+ out(ctx, payload, JSON.stringify(payload, null, 2));
320
+ return;
321
+ }
322
+ throw new EmailCliError("Usage: dench email template create|preview");
323
+ }
324
+
325
+ async function runCampaign(ctx: EmailCliContext) {
326
+ const sub = ctx.args.shift();
327
+ if (sub === "create") {
328
+ const payload = await callMutation(
329
+ ctx,
330
+ "functions/emailCampaigns:createCampaign",
331
+ {
332
+ name: requiredFlag(ctx.args, "--name"),
333
+ identityId: requiredFlag(ctx.args, "--identity"),
334
+ templateId: requiredFlag(ctx.args, "--template"),
335
+ scheduledAt: getFlag(ctx.args, "--scheduled-at")
336
+ ? Date.parse(requiredFlag(ctx.args, "--scheduled-at"))
337
+ : undefined,
338
+ maxRecipients: getFlag(ctx.args, "--max-recipients")
339
+ ? Number(requiredFlag(ctx.args, "--max-recipients"))
340
+ : undefined,
341
+ },
342
+ );
343
+ out(
344
+ ctx,
345
+ payload,
346
+ `Campaign created: ${(payload as JsonRecord).campaignId}`,
347
+ );
348
+ return;
349
+ }
350
+ if (sub === "recipients") {
351
+ const nested = ctx.args.shift();
352
+ if (nested !== "add") {
353
+ throw new EmailCliError(
354
+ "Usage: dench email campaign recipients add --campaign <id> --file recipients.jsonl",
355
+ );
356
+ }
357
+ const campaignId = requiredFlag(ctx.args, "--campaign");
358
+ const file = getFlag(ctx.args, "--file");
359
+ const rows = file
360
+ ? await readRecipients(file)
361
+ : ((optionalJsonFlag(ctx.args, "--data") as JsonRecord[] | undefined) ??
362
+ []);
363
+ if (rows.length === 0) throw new EmailCliError("No recipients provided");
364
+ const payload = await callMutation(
365
+ ctx,
366
+ "functions/emailCampaigns:addRecipients",
367
+ { campaignId, recipients: toRecipients(rows) },
368
+ );
369
+ out(ctx, payload, JSON.stringify(payload, null, 2));
370
+ return;
371
+ }
372
+ if (sub === "preview") {
373
+ const sampleRaw = getFlag(ctx.args, "--sample");
374
+ const payload = await callQuery(
375
+ ctx,
376
+ "functions/emailCampaigns:previewCampaign",
377
+ {
378
+ campaignId: requiredFlag(ctx.args, "--campaign"),
379
+ sample: sampleRaw ? Number(sampleRaw) : undefined,
380
+ },
381
+ );
382
+ out(ctx, payload, JSON.stringify(payload, null, 2));
383
+ return;
384
+ }
385
+ if (sub === "submit") {
386
+ const payload = await callMutation(
387
+ ctx,
388
+ "functions/emailCampaigns:submitCampaign",
389
+ {
390
+ campaignId: requiredFlag(ctx.args, "--campaign"),
391
+ approvalId: getFlag(ctx.args, "--approval"),
392
+ },
393
+ );
394
+ out(ctx, payload, JSON.stringify(payload, null, 2));
395
+ return;
396
+ }
397
+ if (sub === "status") {
398
+ const payload = await callQuery(
399
+ ctx,
400
+ "functions/emailCampaigns:getCampaign",
401
+ {
402
+ campaignId: requiredFlag(ctx.args, "--campaign"),
403
+ },
404
+ );
405
+ out(ctx, payload, JSON.stringify(payload, null, 2));
406
+ return;
407
+ }
408
+ if (sub === "pause" || sub === "resume" || sub === "cancel") {
409
+ const fn =
410
+ sub === "pause"
411
+ ? "pauseCampaign"
412
+ : sub === "resume"
413
+ ? "resumeCampaign"
414
+ : "cancelCampaign";
415
+ const payload = await callMutation(ctx, `functions/emailCampaigns:${fn}`, {
416
+ campaignId: requiredFlag(ctx.args, "--campaign"),
417
+ });
418
+ out(ctx, payload, JSON.stringify(payload, null, 2));
419
+ return;
420
+ }
421
+ throw new EmailCliError(
422
+ "Usage: dench email campaign create|recipients|preview|submit|status|pause|resume|cancel",
423
+ );
424
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "Dench agent workspace CLI. v2 unifies auth behind `dench signin`; the legacy `dench login` / `dench onboard` / `dench setup` / `dench what-can-i-do` / `dench register` commands now error with a redirect.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "chat.ts",
25
25
  "chat-spawn.ts",
26
26
  "crm.ts",
27
+ "email.ts",
27
28
  "members.ts",
28
29
  "cron.ts",
29
30
  "browser.ts",