@dench.com/cli 2.1.5 → 2.2.1
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/crm.ts +128 -1
- package/dench.ts +26 -35
- package/email.ts +327 -12
- package/lib/api-schemas.ts +2903 -62
- package/lib/command-registry.ts +142 -0
- package/package.json +1 -2
- package/linkedin.ts +0 -210
package/crm.ts
CHANGED
|
@@ -509,6 +509,23 @@ const api = {
|
|
|
509
509
|
members: {
|
|
510
510
|
list: makeFunctionReference<"query">("functions/crm/members:list"),
|
|
511
511
|
},
|
|
512
|
+
automations: {
|
|
513
|
+
list: makeFunctionReference<"query">("functions/crm/automations:list"),
|
|
514
|
+
setEnabled: makeFunctionReference<"mutation">(
|
|
515
|
+
"functions/crm/automations:setEnabled",
|
|
516
|
+
),
|
|
517
|
+
},
|
|
518
|
+
templates: {
|
|
519
|
+
listAvailable: makeFunctionReference<"query">(
|
|
520
|
+
"functions/crm/templates:listAvailable",
|
|
521
|
+
),
|
|
522
|
+
install: makeFunctionReference<"mutation">(
|
|
523
|
+
"functions/crm/templates:install",
|
|
524
|
+
),
|
|
525
|
+
removeSampleData: makeFunctionReference<"mutation">(
|
|
526
|
+
"functions/crm/templates:removeSampleData",
|
|
527
|
+
),
|
|
528
|
+
},
|
|
512
529
|
};
|
|
513
530
|
|
|
514
531
|
export async function runCrmCommand(opts: {
|
|
@@ -573,6 +590,10 @@ export async function runCrmCommand(opts: {
|
|
|
573
590
|
return await runDocsCommand(ctx);
|
|
574
591
|
case "actions":
|
|
575
592
|
return await runActionsCommand(ctx);
|
|
593
|
+
case "automations":
|
|
594
|
+
return await runAutomationsCommand(ctx);
|
|
595
|
+
case "templates":
|
|
596
|
+
return await runTemplatesCommand(ctx);
|
|
576
597
|
case "members":
|
|
577
598
|
// Alias for `dench members` so the AI agent finds the roster
|
|
578
599
|
// surface from inside the `dench crm` namespace it's primed
|
|
@@ -584,6 +605,81 @@ export async function runCrmCommand(opts: {
|
|
|
584
605
|
}
|
|
585
606
|
}
|
|
586
607
|
|
|
608
|
+
/**
|
|
609
|
+
* `dench crm automations …` — deterministic CRM rules (installed by
|
|
610
|
+
* templates, e.g. "qualified prospect → opportunity"). These fire inside
|
|
611
|
+
* entry writes automatically; this surface is for visibility and the
|
|
612
|
+
* enable/disable kill switch. There is no create verb here — rules are
|
|
613
|
+
* installed by `crm templates install` (or programmatically).
|
|
614
|
+
*/
|
|
615
|
+
async function runAutomationsCommand(ctx: CrmCliContext): Promise<void> {
|
|
616
|
+
const verb = ctx.args.shift();
|
|
617
|
+
switch (verb) {
|
|
618
|
+
case "list": {
|
|
619
|
+
assertNoUnknownFlags(ctx.args, "crm automations list");
|
|
620
|
+
out(ctx, await callQuery(ctx, api.automations.list, {}));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
case "enable":
|
|
624
|
+
case "disable": {
|
|
625
|
+
const automationId = shift(ctx.args, "automation id");
|
|
626
|
+
assertNoUnknownFlags(ctx.args, `crm automations ${verb}`);
|
|
627
|
+
out(
|
|
628
|
+
ctx,
|
|
629
|
+
await callMutation(ctx, api.automations.setEnabled, {
|
|
630
|
+
automationId,
|
|
631
|
+
enabled: verb === "enable",
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
default:
|
|
637
|
+
throw new CrmCliError(
|
|
638
|
+
`Unknown automations verb: ${verb ?? "(none)"}. Use list | enable <id> | disable <id>.`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* `dench crm templates …` — one-click CRM setups (objects + fields +
|
|
645
|
+
* kanban statuses + pinned views + automations + sample rows tagged
|
|
646
|
+
* "Sample"). Installs are idempotent and compose across templates.
|
|
647
|
+
*/
|
|
648
|
+
async function runTemplatesCommand(ctx: CrmCliContext): Promise<void> {
|
|
649
|
+
const verb = ctx.args.shift();
|
|
650
|
+
switch (verb) {
|
|
651
|
+
case "list": {
|
|
652
|
+
assertNoUnknownFlags(ctx.args, "crm templates list");
|
|
653
|
+
out(ctx, await callQuery(ctx, api.templates.listAvailable, {}));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
case "install": {
|
|
657
|
+
const templateId = shift(ctx.args, "template id");
|
|
658
|
+
assertNoUnknownFlags(ctx.args, "crm templates install");
|
|
659
|
+
out(
|
|
660
|
+
ctx,
|
|
661
|
+
await callMutation(ctx, api.templates.install, { templateId }),
|
|
662
|
+
);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
case "remove-sample-data": {
|
|
666
|
+
const templateId = shift(ctx.args, "template id");
|
|
667
|
+
assertNoUnknownFlags(ctx.args, "crm templates remove-sample-data");
|
|
668
|
+
out(
|
|
669
|
+
ctx,
|
|
670
|
+
await callMutation(ctx, api.templates.removeSampleData, {
|
|
671
|
+
templateId,
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
default:
|
|
677
|
+
throw new CrmCliError(
|
|
678
|
+
`Unknown templates verb: ${verb ?? "(none)"}. Use list | install <id> | remove-sample-data <id>.`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
587
683
|
async function runCrmMembersCommand(ctx: CrmCliContext): Promise<void> {
|
|
588
684
|
const { runMembersCommand } = await import("./members");
|
|
589
685
|
// Re-prepend `--json` so the members dispatcher's own `hasFlag`
|
|
@@ -634,6 +730,11 @@ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
634
730
|
const description = getFlag(ctx.args, "--description");
|
|
635
731
|
const defaultView = getFlag(ctx.args, "--default-view") as any;
|
|
636
732
|
const icon = getFlag(ctx.args, "--icon");
|
|
733
|
+
// Cosmetic relabels (UI only; canonical `name` is unchanged, so
|
|
734
|
+
// protected objects like `people` can be shown as e.g. "Clients").
|
|
735
|
+
// Pass an empty string to clear back to the default label.
|
|
736
|
+
const displayName = getFlag(ctx.args, "--display-name");
|
|
737
|
+
const displayNameSingular = getFlag(ctx.args, "--display-name-singular");
|
|
637
738
|
assertNoUnknownFlags(ctx.args, "crm objects update");
|
|
638
739
|
out(
|
|
639
740
|
ctx,
|
|
@@ -642,6 +743,8 @@ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
642
743
|
description,
|
|
643
744
|
defaultView,
|
|
644
745
|
icon,
|
|
746
|
+
displayName,
|
|
747
|
+
displayNameSingular,
|
|
645
748
|
}),
|
|
646
749
|
);
|
|
647
750
|
return;
|
|
@@ -2618,7 +2721,9 @@ Objects (CRM tables):
|
|
|
2618
2721
|
dench crm objects list [--json]
|
|
2619
2722
|
dench crm objects get <name>
|
|
2620
2723
|
dench crm objects create <name> [--description ...] [--default-view kanban] [--icon ...]
|
|
2621
|
-
dench crm objects update <name> [--default-view ...] [--icon ...]
|
|
2724
|
+
dench crm objects update <name> [--default-view ...] [--icon ...] [--display-name "Clients"] [--display-name-singular "Client"]
|
|
2725
|
+
--display-name relabels the UI only; the canonical <name> stays the
|
|
2726
|
+
command/API identifier (works on protected objects too).
|
|
2622
2727
|
dench crm objects rename <from> <to>
|
|
2623
2728
|
dench crm objects delete <name>
|
|
2624
2729
|
|
|
@@ -2743,10 +2848,32 @@ Docs:
|
|
|
2743
2848
|
dench crm docs create <path> [--title ...] [--icon ...] [--link-entry <id>]
|
|
2744
2849
|
dench crm docs link <path> --object <name> --entry <id>
|
|
2745
2850
|
|
|
2851
|
+
Templates (one-click CRM setups: objects + automations + sample rows):
|
|
2852
|
+
dench crm templates list [--json]
|
|
2853
|
+
Catalog with per-org installed flags. Installs are idempotent and
|
|
2854
|
+
compose (e.g. sales + fundraising share the company Type enum).
|
|
2855
|
+
dench crm templates install <templateId>
|
|
2856
|
+
dench crm templates remove-sample-data <templateId>
|
|
2857
|
+
Deletes only the template's sample rows (tagged "Sample"); schema,
|
|
2858
|
+
views, and automations stay.
|
|
2859
|
+
|
|
2860
|
+
Automations (deterministic rules, fire inside entry writes):
|
|
2861
|
+
dench crm automations list [--json]
|
|
2862
|
+
e.g. "prospect Status -> Qualified creates an opportunity". Writes
|
|
2863
|
+
that trigger one return an automationEffects array describing what
|
|
2864
|
+
was created/updated — expected behavior, not an error.
|
|
2865
|
+
dench crm automations enable <automationId>
|
|
2866
|
+
dench crm automations disable <automationId>
|
|
2867
|
+
|
|
2746
2868
|
Actions:
|
|
2747
2869
|
dench crm actions list <entryId>
|
|
2748
2870
|
dench crm actions run <actionId> --field-id <id> --entry-id <id>
|
|
2749
2871
|
|
|
2872
|
+
Object names: commands take the canonical object name (singular lowercase,
|
|
2873
|
+
e.g. people, company, prospect). Display labels shown in the UI (a template
|
|
2874
|
+
may relabel people as "Clients") and plural forms also resolve when
|
|
2875
|
+
unambiguous, but prefer canonical names from 'crm objects list'.
|
|
2876
|
+
|
|
2750
2877
|
Global flags:
|
|
2751
2878
|
--json Output raw JSON instead of pretty-printed text.
|
|
2752
2879
|
`);
|
package/dench.ts
CHANGED
|
@@ -296,9 +296,34 @@ function throwIfCliError(value: unknown) {
|
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Production Convex deployments mask `error.message` ("[Request ID: …]
|
|
301
|
+
* Server Error") but forward `ConvexError.data` verbatim. Prefer the data
|
|
302
|
+
* payload so agents see the actionable message instead of the mask.
|
|
303
|
+
*/
|
|
304
|
+
function convexErrorData(error: unknown): string | undefined {
|
|
305
|
+
if (!error || typeof error !== "object" || !("data" in error)) {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
const data = (error as { data?: unknown }).data;
|
|
309
|
+
if (typeof data === "string" && data.trim()) return data;
|
|
310
|
+
if (data && typeof data === "object") {
|
|
311
|
+
const message = (data as { message?: unknown }).message;
|
|
312
|
+
if (typeof message === "string" && message.trim()) return message;
|
|
313
|
+
try {
|
|
314
|
+
return JSON.stringify(data);
|
|
315
|
+
} catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
299
322
|
function normalizeCliError(error: unknown): CliError {
|
|
300
323
|
if (error instanceof CliError) return error;
|
|
301
|
-
const message =
|
|
324
|
+
const message =
|
|
325
|
+
convexErrorData(error) ??
|
|
326
|
+
(error instanceof Error ? error.message : String(error));
|
|
302
327
|
if (message.includes("Invalid dev agent key")) {
|
|
303
328
|
return new CliError("Invalid dev agent key", {
|
|
304
329
|
code: "invalid_dev_agent_key",
|
|
@@ -486,10 +511,6 @@ Cloud browser (Browserbase, runs entirely in Dench Cloud):
|
|
|
486
511
|
dench browser stop
|
|
487
512
|
Help: dench browser help
|
|
488
513
|
|
|
489
|
-
LinkedIn outreach (Browserbase, verified before CRM update):
|
|
490
|
-
dench linkedin connect <profileUrl> [--entry people:<entryId>] [--json]
|
|
491
|
-
Help: dench linkedin help
|
|
492
|
-
|
|
493
514
|
Managed email campaigns (Dench Emailing Service):
|
|
494
515
|
dench email identity verify --from founder@example.com [--json]
|
|
495
516
|
dench email template create --name "Intro" --subject "Hi {{firstName}}" --html-file body.html [--json]
|
|
@@ -3713,18 +3734,6 @@ async function main() {
|
|
|
3713
3734
|
return;
|
|
3714
3735
|
}
|
|
3715
3736
|
|
|
3716
|
-
if (
|
|
3717
|
-
command === "linkedin" &&
|
|
3718
|
-
(!subcommand || subcommand === "help" || hasFlag("--help"))
|
|
3719
|
-
) {
|
|
3720
|
-
const { runLinkedInCommand } = await import("./linkedin");
|
|
3721
|
-
await runLinkedInCommand({
|
|
3722
|
-
args: subcommand ? [subcommand] : ["help"],
|
|
3723
|
-
runtime: { host: "", bearerToken: "" },
|
|
3724
|
-
});
|
|
3725
|
-
return;
|
|
3726
|
-
}
|
|
3727
|
-
|
|
3728
3737
|
if (
|
|
3729
3738
|
command === "email" &&
|
|
3730
3739
|
(!subcommand || subcommand === "help" || hasFlag("--help"))
|
|
@@ -4043,24 +4052,6 @@ async function main() {
|
|
|
4043
4052
|
return;
|
|
4044
4053
|
}
|
|
4045
4054
|
|
|
4046
|
-
if (command === "linkedin") {
|
|
4047
|
-
const subArgs = args.slice(args.indexOf("linkedin") + 1);
|
|
4048
|
-
const { runLinkedInCommand } = await import("./linkedin");
|
|
4049
|
-
const runtime = await getRuntime();
|
|
4050
|
-
const authedRuntime = requireAuthenticatedRuntime(
|
|
4051
|
-
runtime,
|
|
4052
|
-
"dench linkedin",
|
|
4053
|
-
);
|
|
4054
|
-
await runLinkedInCommand({
|
|
4055
|
-
args: subArgs,
|
|
4056
|
-
runtime: {
|
|
4057
|
-
host: authedRuntime.host,
|
|
4058
|
-
bearerToken: authedRuntime.sessionToken,
|
|
4059
|
-
},
|
|
4060
|
-
});
|
|
4061
|
-
return;
|
|
4062
|
-
}
|
|
4063
|
-
|
|
4064
4055
|
if (command === "chat") {
|
|
4065
4056
|
const subArgs = args.slice(args.indexOf("chat") + 1);
|
|
4066
4057
|
const sub = subArgs[0];
|
package/email.ts
CHANGED
|
@@ -189,12 +189,19 @@ export function emailHelp() {
|
|
|
189
189
|
|
|
190
190
|
Usage:
|
|
191
191
|
dench email identity verify --from founder@example.com [--json]
|
|
192
|
-
dench email identity verify --domain example.com --from founder@example.com [--json]
|
|
192
|
+
dench email identity verify --domain example.com [--from founder@example.com] [--json]
|
|
193
|
+
dench email identity list [--domain example.com] [--json]
|
|
194
|
+
dench email identity dns --identity <identityId|domain|email> [--json]
|
|
193
195
|
dench email identity status --identity founder@example.com [--json]
|
|
194
196
|
dench email identity status --identity-id <identityId> [--json]
|
|
197
|
+
dench email identity disable --identity <identityId|email|domain> [--json]
|
|
198
|
+
dench email identity restore --identity <identityId|email|domain> [--json]
|
|
199
|
+
dench email send --from founder@example.com --to "Ada <ada@x.com>,bob@y.com" --subject "Hi" (--html-file body.html | --text "...") [--cc ...] [--bcc ...] [--from-name "Ada"] [--reply-to support@x.com] [--reply-message-id <messageId|rfc-id>] [--json]
|
|
200
|
+
dench email message status --message <messageId> [--json]
|
|
201
|
+
dench email message list [--limit 25] [--json]
|
|
195
202
|
dench email template create --name "Intro" --subject "Hi {{firstName}}" --html-file body.html [--text-file body.txt] [--json]
|
|
196
203
|
dench email template preview --template <id> --data '{"firstName":"Ada"}' [--json]
|
|
197
|
-
dench email campaign create --name "Q2 outreach" --identity <
|
|
204
|
+
dench email campaign create --name "Q2 outreach" --identity <identityId|fromEmail> --template <id> [--json]
|
|
198
205
|
dench email campaign recipients add --campaign <id> --file recipients.jsonl [--json]
|
|
199
206
|
dench email campaign preview --campaign <id> [--sample 5] [--json]
|
|
200
207
|
dench email campaign submit --campaign <id> [--json]
|
|
@@ -212,6 +219,14 @@ export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
|
|
|
212
219
|
await runIdentity(ctx);
|
|
213
220
|
return;
|
|
214
221
|
}
|
|
222
|
+
if (group === "send") {
|
|
223
|
+
await runSend(ctx);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (group === "message") {
|
|
227
|
+
await runMessage(ctx);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
215
230
|
if (group === "template") {
|
|
216
231
|
await runTemplate(ctx);
|
|
217
232
|
return;
|
|
@@ -223,11 +238,193 @@ export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
|
|
|
223
238
|
throw new EmailCliError(`Unknown dench email subcommand: ${group}`);
|
|
224
239
|
}
|
|
225
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Parse a comma-separated address list where each entry is either a bare
|
|
243
|
+
* email or `Name <email>`.
|
|
244
|
+
*/
|
|
245
|
+
function parseAddressList(
|
|
246
|
+
value: string | undefined,
|
|
247
|
+
): Array<{ email: string; name?: string }> {
|
|
248
|
+
if (!value?.trim()) return [];
|
|
249
|
+
return value
|
|
250
|
+
.split(",")
|
|
251
|
+
.map((entry) => entry.trim())
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.map((entry) => {
|
|
254
|
+
const match = entry.match(/^(.*)<([^<>\s]+@[^<>\s]+)>$/);
|
|
255
|
+
if (match) {
|
|
256
|
+
const name = match[1].trim().replace(/^"|"$/g, "").trim();
|
|
257
|
+
return { email: match[2].trim(), name: name || undefined };
|
|
258
|
+
}
|
|
259
|
+
return { email: entry };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function runSend(ctx: EmailCliContext) {
|
|
264
|
+
const from = requiredFlag(ctx.args, "--from");
|
|
265
|
+
const to = parseAddressList(requiredFlag(ctx.args, "--to"));
|
|
266
|
+
if (to.length === 0) {
|
|
267
|
+
throw new EmailCliError(
|
|
268
|
+
'No valid --to recipients. Use --to "Ada <ada@x.com>,bob@y.com"',
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const cc = parseAddressList(getFlag(ctx.args, "--cc"));
|
|
272
|
+
const bcc = parseAddressList(getFlag(ctx.args, "--bcc"));
|
|
273
|
+
const htmlBody = await readTextFlag(ctx.args, "--html", "--html-file");
|
|
274
|
+
const textBody = await readTextFlag(ctx.args, "--text", "--text-file");
|
|
275
|
+
if (!htmlBody && !textBody) {
|
|
276
|
+
throw new EmailCliError(
|
|
277
|
+
"Provide a body: --html/--html-file and/or --text/--text-file",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const payload = (await callAction(
|
|
281
|
+
ctx,
|
|
282
|
+
"functions/emailCampaignsNode:sendEmail",
|
|
283
|
+
{
|
|
284
|
+
fromEmail: from,
|
|
285
|
+
fromName: getFlag(ctx.args, "--from-name"),
|
|
286
|
+
to,
|
|
287
|
+
cc: cc.length > 0 ? cc : undefined,
|
|
288
|
+
bcc: bcc.length > 0 ? bcc : undefined,
|
|
289
|
+
replyToEmail: getFlag(ctx.args, "--reply-to"),
|
|
290
|
+
subject: requiredFlag(ctx.args, "--subject"),
|
|
291
|
+
htmlBody,
|
|
292
|
+
textBody,
|
|
293
|
+
replyToMessageId: getFlag(ctx.args, "--reply-message-id"),
|
|
294
|
+
configurationSetName: getFlag(ctx.args, "--configuration-set"),
|
|
295
|
+
},
|
|
296
|
+
)) as JsonRecord;
|
|
297
|
+
out(
|
|
298
|
+
ctx,
|
|
299
|
+
payload,
|
|
300
|
+
`Sent to ${to.length + cc.length + bcc.length} recipient(s). Message: ${payload.messageId}\nCheck opens: dench email message status --message ${payload.messageId}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function runMessage(ctx: EmailCliContext) {
|
|
305
|
+
const sub = ctx.args.shift();
|
|
306
|
+
if (sub === "status") {
|
|
307
|
+
const messageId = requiredFlag(ctx.args, "--message");
|
|
308
|
+
const payload = (await callQuery(
|
|
309
|
+
ctx,
|
|
310
|
+
"functions/emailCampaigns:getMessage",
|
|
311
|
+
{ messageId },
|
|
312
|
+
)) as JsonRecord;
|
|
313
|
+
out(ctx, payload, JSON.stringify(payload, null, 2));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (sub === "list") {
|
|
317
|
+
const limitRaw = getFlag(ctx.args, "--limit");
|
|
318
|
+
const status = getFlag(ctx.args, "--status");
|
|
319
|
+
const payload = (await callQuery(
|
|
320
|
+
ctx,
|
|
321
|
+
"functions/emailCampaigns:listMessages",
|
|
322
|
+
{
|
|
323
|
+
limit: limitRaw ? Number(limitRaw) : undefined,
|
|
324
|
+
status,
|
|
325
|
+
},
|
|
326
|
+
)) as JsonRecord[];
|
|
327
|
+
const text =
|
|
328
|
+
payload.length === 0
|
|
329
|
+
? "No raw messages sent yet. Use `dench email send`."
|
|
330
|
+
: payload
|
|
331
|
+
.map(
|
|
332
|
+
(message) =>
|
|
333
|
+
`${message.messageId} ${message.status} from=${message.fromEmail} subject=${JSON.stringify(message.subject)} opens=${message.openCount} clicks=${message.clickCount}`,
|
|
334
|
+
)
|
|
335
|
+
.join("\n");
|
|
336
|
+
out(ctx, { ok: true, messages: payload }, text);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
throw new EmailCliError("Usage: dench email message status|list");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
type DnsRecord = {
|
|
343
|
+
type: string;
|
|
344
|
+
name: string;
|
|
345
|
+
value: string;
|
|
346
|
+
purpose: string;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
function buildDnsRecords(domain: string, dkimTokens: string[]): DnsRecord[] {
|
|
350
|
+
return [
|
|
351
|
+
...dkimTokens.map((token) => ({
|
|
352
|
+
type: "CNAME",
|
|
353
|
+
name: `${token}._domainkey.${domain}`,
|
|
354
|
+
value: `${token}.dkim.amazonses.com`,
|
|
355
|
+
purpose: "DKIM (required for domain verification)",
|
|
356
|
+
})),
|
|
357
|
+
{
|
|
358
|
+
type: "TXT",
|
|
359
|
+
name: domain,
|
|
360
|
+
value: "v=spf1 include:amazonses.com ~all",
|
|
361
|
+
purpose: "SPF (recommended)",
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
type: "TXT",
|
|
365
|
+
name: `_dmarc.${domain}`,
|
|
366
|
+
value: `v=DMARC1; p=none; rua=mailto:postmaster@${domain}`,
|
|
367
|
+
purpose: "DMARC (recommended)",
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function asDnsRecords(value: unknown): DnsRecord[] {
|
|
373
|
+
if (!Array.isArray(value)) return [];
|
|
374
|
+
return value.filter(
|
|
375
|
+
(record): record is DnsRecord =>
|
|
376
|
+
Boolean(record) &&
|
|
377
|
+
typeof record === "object" &&
|
|
378
|
+
typeof (record as DnsRecord).type === "string" &&
|
|
379
|
+
typeof (record as DnsRecord).name === "string" &&
|
|
380
|
+
typeof (record as DnsRecord).value === "string",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function formatDnsRecords(records: DnsRecord[]): string {
|
|
385
|
+
if (records.length === 0) return "";
|
|
386
|
+
const lines = ["DNS records to add:"];
|
|
387
|
+
for (const record of records) {
|
|
388
|
+
lines.push(` ${record.type} ${record.name}`);
|
|
389
|
+
lines.push(` value: ${record.value}`);
|
|
390
|
+
if (record.purpose) lines.push(` (${record.purpose})`);
|
|
391
|
+
}
|
|
392
|
+
return lines.join("\n");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function listIdentitiesQuery(
|
|
396
|
+
ctx: EmailCliContext,
|
|
397
|
+
domain?: string,
|
|
398
|
+
): Promise<JsonRecord[]> {
|
|
399
|
+
const payload = await callQuery(
|
|
400
|
+
ctx,
|
|
401
|
+
"functions/emailCampaigns:listIdentities",
|
|
402
|
+
domain ? { domain } : {},
|
|
403
|
+
);
|
|
404
|
+
return Array.isArray(payload) ? (payload as JsonRecord[]) : [];
|
|
405
|
+
}
|
|
406
|
+
|
|
226
407
|
async function runIdentity(ctx: EmailCliContext) {
|
|
227
408
|
const sub = ctx.args.shift();
|
|
228
409
|
if (sub === "verify") {
|
|
229
|
-
const domain = getFlag(ctx.args, "--domain");
|
|
230
|
-
|
|
410
|
+
const domain = getFlag(ctx.args, "--domain")?.trim().toLowerCase();
|
|
411
|
+
let from = getFlag(ctx.args, "--from")?.trim();
|
|
412
|
+
if (!from) {
|
|
413
|
+
if (!domain) throw new EmailCliError("Missing required --from");
|
|
414
|
+
// Domain-only verify: reuse the sender mailbox already registered
|
|
415
|
+
// on this domain so the user doesn't have to repeat --from.
|
|
416
|
+
const existing = await listIdentitiesQuery(ctx, domain);
|
|
417
|
+
const preferred =
|
|
418
|
+
existing.find(
|
|
419
|
+
(identity) => identity.denchVerificationStatus === "verified",
|
|
420
|
+
) ?? existing[0];
|
|
421
|
+
if (!preferred || typeof preferred.fromEmail !== "string") {
|
|
422
|
+
throw new EmailCliError(
|
|
423
|
+
`No sender registered on ${domain} yet. Pass --from <mailbox@${domain}> to register one.`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
from = preferred.fromEmail;
|
|
427
|
+
}
|
|
231
428
|
const type = domain ? "domain" : "email";
|
|
232
429
|
const identity = domain ?? from;
|
|
233
430
|
const configurationSetName = getFlag(ctx.args, "--configuration-set");
|
|
@@ -243,16 +440,123 @@ async function runIdentity(ctx: EmailCliContext) {
|
|
|
243
440
|
configurationSetName,
|
|
244
441
|
},
|
|
245
442
|
);
|
|
246
|
-
const verification = await callAction(
|
|
443
|
+
const verification = (await callAction(
|
|
247
444
|
ctx,
|
|
248
445
|
"functions/emailCampaignsNode:beginSenderVerification",
|
|
249
446
|
{ identityId: (created as JsonRecord).identityId },
|
|
250
|
-
);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
{ ok: true, created, verification },
|
|
447
|
+
)) as JsonRecord;
|
|
448
|
+
const dnsRecords = asDnsRecords(verification.dnsRecords);
|
|
449
|
+
const text = [
|
|
254
450
|
`Verification started for ${identity}`,
|
|
255
|
-
|
|
451
|
+
typeof verification.message === "string" ? verification.message : "",
|
|
452
|
+
formatDnsRecords(dnsRecords),
|
|
453
|
+
]
|
|
454
|
+
.filter(Boolean)
|
|
455
|
+
.join("\n");
|
|
456
|
+
out(ctx, { ok: true, created, verification }, text);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (sub === "list") {
|
|
460
|
+
const domain = getFlag(ctx.args, "--domain")?.trim().toLowerCase();
|
|
461
|
+
const identities = await listIdentitiesQuery(ctx, domain);
|
|
462
|
+
const text =
|
|
463
|
+
identities.length === 0
|
|
464
|
+
? "No sending identities yet. Run `dench email identity verify --from you@example.com`."
|
|
465
|
+
: identities
|
|
466
|
+
.map(
|
|
467
|
+
(identity) =>
|
|
468
|
+
`${identity.identityId} ${identity.fromEmail} type=${identity.type}` +
|
|
469
|
+
`${identity.domain ? ` domain=${identity.domain}` : ""}` +
|
|
470
|
+
` delivery=${identity.deliveryVerificationStatus} workspace=${identity.denchVerificationStatus}` +
|
|
471
|
+
`${identity.disabled ? " DISABLED" : ""}`,
|
|
472
|
+
)
|
|
473
|
+
.join("\n");
|
|
474
|
+
out(ctx, { ok: true, identities }, text);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (sub === "disable" || sub === "restore") {
|
|
478
|
+
const identity = requiredFlag(ctx.args, "--identity");
|
|
479
|
+
const payload = (await callMutation(
|
|
480
|
+
ctx,
|
|
481
|
+
sub === "disable"
|
|
482
|
+
? "functions/emailCampaigns:disableIdentity"
|
|
483
|
+
: "functions/emailCampaigns:restoreIdentity",
|
|
484
|
+
{ identity },
|
|
485
|
+
)) as JsonRecord;
|
|
486
|
+
const rows = (
|
|
487
|
+
Array.isArray(payload.disabled)
|
|
488
|
+
? payload.disabled
|
|
489
|
+
: Array.isArray(payload.restored)
|
|
490
|
+
? payload.restored
|
|
491
|
+
: []
|
|
492
|
+
) as JsonRecord[];
|
|
493
|
+
const verb = sub === "disable" ? "Disabled" : "Restored";
|
|
494
|
+
const text =
|
|
495
|
+
rows.length === 0
|
|
496
|
+
? `${verb} 0 identities.`
|
|
497
|
+
: `${verb} ${rows.length} sender(s):\n${rows
|
|
498
|
+
.map((row) => ` ${row.fromEmail} (${row.identityId})`)
|
|
499
|
+
.join("\n")}`;
|
|
500
|
+
out(ctx, payload, text);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (sub === "dns") {
|
|
504
|
+
const identity = requiredFlag(ctx.args, "--identity");
|
|
505
|
+
if (!identity.includes("@") && !identity.includes(".")) {
|
|
506
|
+
// Identity id: the refresh action returns formatted dnsRecords.
|
|
507
|
+
const payload = (await callAction(
|
|
508
|
+
ctx,
|
|
509
|
+
"functions/emailCampaignsNode:refreshIdentityStatus",
|
|
510
|
+
{ identityId: identity },
|
|
511
|
+
)) as JsonRecord;
|
|
512
|
+
const records = asDnsRecords(payload.dnsRecords);
|
|
513
|
+
const text =
|
|
514
|
+
records.length > 0
|
|
515
|
+
? formatDnsRecords(records)
|
|
516
|
+
: payload.domainVerified === true
|
|
517
|
+
? `Domain ${payload.domain} is verified — no DNS records pending.`
|
|
518
|
+
: "No DNS records available yet. Run `dench email identity verify --domain <domain>` first.";
|
|
519
|
+
out(ctx, { ok: true, ...payload }, text);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const domain = identity.includes("@")
|
|
523
|
+
? identity.slice(identity.lastIndexOf("@") + 1).toLowerCase()
|
|
524
|
+
: identity.toLowerCase();
|
|
525
|
+
const status = (await callGateway(
|
|
526
|
+
ctx,
|
|
527
|
+
"GET",
|
|
528
|
+
`/v1/email/identities/${encodeURIComponent(domain)}/status`,
|
|
529
|
+
)) as JsonRecord;
|
|
530
|
+
const dkim =
|
|
531
|
+
status.dkimAttributes && typeof status.dkimAttributes === "object"
|
|
532
|
+
? (status.dkimAttributes as JsonRecord)
|
|
533
|
+
: {};
|
|
534
|
+
const tokens = Array.isArray(dkim.Tokens)
|
|
535
|
+
? dkim.Tokens.filter(
|
|
536
|
+
(token): token is string => typeof token === "string",
|
|
537
|
+
)
|
|
538
|
+
: [];
|
|
539
|
+
const records = buildDnsRecords(domain, tokens);
|
|
540
|
+
const verified = status.verifiedForSendingStatus === true;
|
|
541
|
+
const payload: JsonRecord = {
|
|
542
|
+
ok: true,
|
|
543
|
+
domain,
|
|
544
|
+
domainVerified: verified,
|
|
545
|
+
dnsRecords: records,
|
|
546
|
+
};
|
|
547
|
+
if (
|
|
548
|
+
!verified &&
|
|
549
|
+
status.verificationInfo &&
|
|
550
|
+
typeof status.verificationInfo === "object"
|
|
551
|
+
) {
|
|
552
|
+
payload.verificationInfo = status.verificationInfo;
|
|
553
|
+
}
|
|
554
|
+
const text = verified
|
|
555
|
+
? `Domain ${domain} is verified — DNS records below are already in place.\n${formatDnsRecords(records)}`
|
|
556
|
+
: tokens.length > 0
|
|
557
|
+
? formatDnsRecords(records)
|
|
558
|
+
: `Domain ${domain} has no verification records yet. Run \`dench email identity verify --domain ${domain}\` first.`;
|
|
559
|
+
out(ctx, payload, text);
|
|
256
560
|
return;
|
|
257
561
|
}
|
|
258
562
|
if (sub === "status") {
|
|
@@ -273,16 +577,27 @@ async function runIdentity(ctx: EmailCliContext) {
|
|
|
273
577
|
`/v1/email/identities/${encodeURIComponent(identity)}/status`,
|
|
274
578
|
);
|
|
275
579
|
const status = payload as JsonRecord;
|
|
276
|
-
const shaped = {
|
|
580
|
+
const shaped: JsonRecord = {
|
|
277
581
|
ok: status.ok === true,
|
|
278
582
|
identity: status.identity,
|
|
279
583
|
deliveryVerified: status.verifiedForSendingStatus === true,
|
|
280
584
|
deliveryVerificationStatus: status.verificationStatus,
|
|
281
585
|
};
|
|
586
|
+
// Pending domains: surface when the provider last polled DNS and
|
|
587
|
+
// why it failed, so callers know whether to fix records or wait.
|
|
588
|
+
if (
|
|
589
|
+
status.verifiedForSendingStatus !== true &&
|
|
590
|
+
status.verificationInfo &&
|
|
591
|
+
typeof status.verificationInfo === "object"
|
|
592
|
+
) {
|
|
593
|
+
shaped.verificationInfo = status.verificationInfo;
|
|
594
|
+
}
|
|
282
595
|
out(ctx, shaped, JSON.stringify(shaped, null, 2));
|
|
283
596
|
return;
|
|
284
597
|
}
|
|
285
|
-
throw new EmailCliError(
|
|
598
|
+
throw new EmailCliError(
|
|
599
|
+
"Usage: dench email identity verify|list|dns|status|disable|restore",
|
|
600
|
+
);
|
|
286
601
|
}
|
|
287
602
|
|
|
288
603
|
async function runTemplate(ctx: EmailCliContext) {
|