@dench.com/cli 2.1.5 → 2.2.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/dench.ts +26 -35
- package/email.ts +279 -11
- package/lib/command-registry.ts +60 -0
- package/package.json +1 -2
- package/linkedin.ts +0 -210
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,17 @@ 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 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]
|
|
198
|
+
dench email message status --message <messageId> [--json]
|
|
199
|
+
dench email message list [--limit 25] [--json]
|
|
195
200
|
dench email template create --name "Intro" --subject "Hi {{firstName}}" --html-file body.html [--text-file body.txt] [--json]
|
|
196
201
|
dench email template preview --template <id> --data '{"firstName":"Ada"}' [--json]
|
|
197
|
-
dench email campaign create --name "Q2 outreach" --identity <
|
|
202
|
+
dench email campaign create --name "Q2 outreach" --identity <identityId|fromEmail> --template <id> [--json]
|
|
198
203
|
dench email campaign recipients add --campaign <id> --file recipients.jsonl [--json]
|
|
199
204
|
dench email campaign preview --campaign <id> [--sample 5] [--json]
|
|
200
205
|
dench email campaign submit --campaign <id> [--json]
|
|
@@ -212,6 +217,14 @@ export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
|
|
|
212
217
|
await runIdentity(ctx);
|
|
213
218
|
return;
|
|
214
219
|
}
|
|
220
|
+
if (group === "send") {
|
|
221
|
+
await runSend(ctx);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (group === "message") {
|
|
225
|
+
await runMessage(ctx);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
215
228
|
if (group === "template") {
|
|
216
229
|
await runTemplate(ctx);
|
|
217
230
|
return;
|
|
@@ -223,11 +236,193 @@ export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
|
|
|
223
236
|
throw new EmailCliError(`Unknown dench email subcommand: ${group}`);
|
|
224
237
|
}
|
|
225
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Parse a comma-separated address list where each entry is either a bare
|
|
241
|
+
* email or `Name <email>`.
|
|
242
|
+
*/
|
|
243
|
+
function parseAddressList(
|
|
244
|
+
value: string | undefined,
|
|
245
|
+
): Array<{ email: string; name?: string }> {
|
|
246
|
+
if (!value?.trim()) return [];
|
|
247
|
+
return value
|
|
248
|
+
.split(",")
|
|
249
|
+
.map((entry) => entry.trim())
|
|
250
|
+
.filter(Boolean)
|
|
251
|
+
.map((entry) => {
|
|
252
|
+
const match = entry.match(/^(.*)<([^<>\s]+@[^<>\s]+)>$/);
|
|
253
|
+
if (match) {
|
|
254
|
+
const name = match[1].trim().replace(/^"|"$/g, "").trim();
|
|
255
|
+
return { email: match[2].trim(), name: name || undefined };
|
|
256
|
+
}
|
|
257
|
+
return { email: entry };
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function runSend(ctx: EmailCliContext) {
|
|
262
|
+
const from = requiredFlag(ctx.args, "--from");
|
|
263
|
+
const to = parseAddressList(requiredFlag(ctx.args, "--to"));
|
|
264
|
+
if (to.length === 0) {
|
|
265
|
+
throw new EmailCliError(
|
|
266
|
+
'No valid --to recipients. Use --to "Ada <ada@x.com>,bob@y.com"',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const cc = parseAddressList(getFlag(ctx.args, "--cc"));
|
|
270
|
+
const bcc = parseAddressList(getFlag(ctx.args, "--bcc"));
|
|
271
|
+
const htmlBody = await readTextFlag(ctx.args, "--html", "--html-file");
|
|
272
|
+
const textBody = await readTextFlag(ctx.args, "--text", "--text-file");
|
|
273
|
+
if (!htmlBody && !textBody) {
|
|
274
|
+
throw new EmailCliError(
|
|
275
|
+
"Provide a body: --html/--html-file and/or --text/--text-file",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const payload = (await callAction(
|
|
279
|
+
ctx,
|
|
280
|
+
"functions/emailCampaignsNode:sendEmail",
|
|
281
|
+
{
|
|
282
|
+
fromEmail: from,
|
|
283
|
+
fromName: getFlag(ctx.args, "--from-name"),
|
|
284
|
+
to,
|
|
285
|
+
cc: cc.length > 0 ? cc : undefined,
|
|
286
|
+
bcc: bcc.length > 0 ? bcc : undefined,
|
|
287
|
+
replyToEmail: getFlag(ctx.args, "--reply-to"),
|
|
288
|
+
subject: requiredFlag(ctx.args, "--subject"),
|
|
289
|
+
htmlBody,
|
|
290
|
+
textBody,
|
|
291
|
+
replyToMessageId: getFlag(ctx.args, "--reply-message-id"),
|
|
292
|
+
configurationSetName: getFlag(ctx.args, "--configuration-set"),
|
|
293
|
+
},
|
|
294
|
+
)) as JsonRecord;
|
|
295
|
+
out(
|
|
296
|
+
ctx,
|
|
297
|
+
payload,
|
|
298
|
+
`Sent to ${to.length + cc.length + bcc.length} recipient(s). Message: ${payload.messageId}\nCheck opens: dench email message status --message ${payload.messageId}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function runMessage(ctx: EmailCliContext) {
|
|
303
|
+
const sub = ctx.args.shift();
|
|
304
|
+
if (sub === "status") {
|
|
305
|
+
const messageId = requiredFlag(ctx.args, "--message");
|
|
306
|
+
const payload = (await callQuery(
|
|
307
|
+
ctx,
|
|
308
|
+
"functions/emailCampaigns:getMessage",
|
|
309
|
+
{ messageId },
|
|
310
|
+
)) as JsonRecord;
|
|
311
|
+
out(ctx, payload, JSON.stringify(payload, null, 2));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (sub === "list") {
|
|
315
|
+
const limitRaw = getFlag(ctx.args, "--limit");
|
|
316
|
+
const status = getFlag(ctx.args, "--status");
|
|
317
|
+
const payload = (await callQuery(
|
|
318
|
+
ctx,
|
|
319
|
+
"functions/emailCampaigns:listMessages",
|
|
320
|
+
{
|
|
321
|
+
limit: limitRaw ? Number(limitRaw) : undefined,
|
|
322
|
+
status,
|
|
323
|
+
},
|
|
324
|
+
)) as JsonRecord[];
|
|
325
|
+
const text =
|
|
326
|
+
payload.length === 0
|
|
327
|
+
? "No raw messages sent yet. Use `dench email send`."
|
|
328
|
+
: payload
|
|
329
|
+
.map(
|
|
330
|
+
(message) =>
|
|
331
|
+
`${message.messageId} ${message.status} from=${message.fromEmail} subject=${JSON.stringify(message.subject)} opens=${message.openCount} clicks=${message.clickCount}`,
|
|
332
|
+
)
|
|
333
|
+
.join("\n");
|
|
334
|
+
out(ctx, { ok: true, messages: payload }, text);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
throw new EmailCliError("Usage: dench email message status|list");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
type DnsRecord = {
|
|
341
|
+
type: string;
|
|
342
|
+
name: string;
|
|
343
|
+
value: string;
|
|
344
|
+
purpose: string;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
function buildDnsRecords(domain: string, dkimTokens: string[]): DnsRecord[] {
|
|
348
|
+
return [
|
|
349
|
+
...dkimTokens.map((token) => ({
|
|
350
|
+
type: "CNAME",
|
|
351
|
+
name: `${token}._domainkey.${domain}`,
|
|
352
|
+
value: `${token}.dkim.amazonses.com`,
|
|
353
|
+
purpose: "DKIM (required for domain verification)",
|
|
354
|
+
})),
|
|
355
|
+
{
|
|
356
|
+
type: "TXT",
|
|
357
|
+
name: domain,
|
|
358
|
+
value: "v=spf1 include:amazonses.com ~all",
|
|
359
|
+
purpose: "SPF (recommended)",
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
type: "TXT",
|
|
363
|
+
name: `_dmarc.${domain}`,
|
|
364
|
+
value: `v=DMARC1; p=none; rua=mailto:postmaster@${domain}`,
|
|
365
|
+
purpose: "DMARC (recommended)",
|
|
366
|
+
},
|
|
367
|
+
];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function asDnsRecords(value: unknown): DnsRecord[] {
|
|
371
|
+
if (!Array.isArray(value)) return [];
|
|
372
|
+
return value.filter(
|
|
373
|
+
(record): record is DnsRecord =>
|
|
374
|
+
Boolean(record) &&
|
|
375
|
+
typeof record === "object" &&
|
|
376
|
+
typeof (record as DnsRecord).type === "string" &&
|
|
377
|
+
typeof (record as DnsRecord).name === "string" &&
|
|
378
|
+
typeof (record as DnsRecord).value === "string",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function formatDnsRecords(records: DnsRecord[]): string {
|
|
383
|
+
if (records.length === 0) return "";
|
|
384
|
+
const lines = ["DNS records to add:"];
|
|
385
|
+
for (const record of records) {
|
|
386
|
+
lines.push(` ${record.type} ${record.name}`);
|
|
387
|
+
lines.push(` value: ${record.value}`);
|
|
388
|
+
if (record.purpose) lines.push(` (${record.purpose})`);
|
|
389
|
+
}
|
|
390
|
+
return lines.join("\n");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function listIdentitiesQuery(
|
|
394
|
+
ctx: EmailCliContext,
|
|
395
|
+
domain?: string,
|
|
396
|
+
): Promise<JsonRecord[]> {
|
|
397
|
+
const payload = await callQuery(
|
|
398
|
+
ctx,
|
|
399
|
+
"functions/emailCampaigns:listIdentities",
|
|
400
|
+
domain ? { domain } : {},
|
|
401
|
+
);
|
|
402
|
+
return Array.isArray(payload) ? (payload as JsonRecord[]) : [];
|
|
403
|
+
}
|
|
404
|
+
|
|
226
405
|
async function runIdentity(ctx: EmailCliContext) {
|
|
227
406
|
const sub = ctx.args.shift();
|
|
228
407
|
if (sub === "verify") {
|
|
229
|
-
const domain = getFlag(ctx.args, "--domain");
|
|
230
|
-
|
|
408
|
+
const domain = getFlag(ctx.args, "--domain")?.trim().toLowerCase();
|
|
409
|
+
let from = getFlag(ctx.args, "--from")?.trim();
|
|
410
|
+
if (!from) {
|
|
411
|
+
if (!domain) throw new EmailCliError("Missing required --from");
|
|
412
|
+
// Domain-only verify: reuse the sender mailbox already registered
|
|
413
|
+
// on this domain so the user doesn't have to repeat --from.
|
|
414
|
+
const existing = await listIdentitiesQuery(ctx, domain);
|
|
415
|
+
const preferred =
|
|
416
|
+
existing.find(
|
|
417
|
+
(identity) => identity.denchVerificationStatus === "verified",
|
|
418
|
+
) ?? existing[0];
|
|
419
|
+
if (!preferred || typeof preferred.fromEmail !== "string") {
|
|
420
|
+
throw new EmailCliError(
|
|
421
|
+
`No sender registered on ${domain} yet. Pass --from <mailbox@${domain}> to register one.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
from = preferred.fromEmail;
|
|
425
|
+
}
|
|
231
426
|
const type = domain ? "domain" : "email";
|
|
232
427
|
const identity = domain ?? from;
|
|
233
428
|
const configurationSetName = getFlag(ctx.args, "--configuration-set");
|
|
@@ -243,16 +438,89 @@ async function runIdentity(ctx: EmailCliContext) {
|
|
|
243
438
|
configurationSetName,
|
|
244
439
|
},
|
|
245
440
|
);
|
|
246
|
-
const verification = await callAction(
|
|
441
|
+
const verification = (await callAction(
|
|
247
442
|
ctx,
|
|
248
443
|
"functions/emailCampaignsNode:beginSenderVerification",
|
|
249
444
|
{ identityId: (created as JsonRecord).identityId },
|
|
250
|
-
);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
{ ok: true, created, verification },
|
|
445
|
+
)) as JsonRecord;
|
|
446
|
+
const dnsRecords = asDnsRecords(verification.dnsRecords);
|
|
447
|
+
const text = [
|
|
254
448
|
`Verification started for ${identity}`,
|
|
255
|
-
|
|
449
|
+
typeof verification.message === "string" ? verification.message : "",
|
|
450
|
+
formatDnsRecords(dnsRecords),
|
|
451
|
+
]
|
|
452
|
+
.filter(Boolean)
|
|
453
|
+
.join("\n");
|
|
454
|
+
out(ctx, { ok: true, created, verification }, text);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (sub === "list") {
|
|
458
|
+
const domain = getFlag(ctx.args, "--domain")?.trim().toLowerCase();
|
|
459
|
+
const identities = await listIdentitiesQuery(ctx, domain);
|
|
460
|
+
const text =
|
|
461
|
+
identities.length === 0
|
|
462
|
+
? "No sending identities yet. Run `dench email identity verify --from you@example.com`."
|
|
463
|
+
: identities
|
|
464
|
+
.map(
|
|
465
|
+
(identity) =>
|
|
466
|
+
`${identity.identityId} ${identity.fromEmail} type=${identity.type}` +
|
|
467
|
+
`${identity.domain ? ` domain=${identity.domain}` : ""}` +
|
|
468
|
+
` delivery=${identity.deliveryVerificationStatus} workspace=${identity.denchVerificationStatus}`,
|
|
469
|
+
)
|
|
470
|
+
.join("\n");
|
|
471
|
+
out(ctx, { ok: true, identities }, text);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (sub === "dns") {
|
|
475
|
+
const identity = requiredFlag(ctx.args, "--identity");
|
|
476
|
+
if (!identity.includes("@") && !identity.includes(".")) {
|
|
477
|
+
// Identity id: the refresh action returns formatted dnsRecords.
|
|
478
|
+
const payload = (await callAction(
|
|
479
|
+
ctx,
|
|
480
|
+
"functions/emailCampaignsNode:refreshIdentityStatus",
|
|
481
|
+
{ identityId: identity },
|
|
482
|
+
)) as JsonRecord;
|
|
483
|
+
const records = asDnsRecords(payload.dnsRecords);
|
|
484
|
+
const text =
|
|
485
|
+
records.length > 0
|
|
486
|
+
? formatDnsRecords(records)
|
|
487
|
+
: payload.domainVerified === true
|
|
488
|
+
? `Domain ${payload.domain} is verified — no DNS records pending.`
|
|
489
|
+
: "No DNS records available yet. Run `dench email identity verify --domain <domain>` first.";
|
|
490
|
+
out(ctx, { ok: true, ...payload }, text);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const domain = identity.includes("@")
|
|
494
|
+
? identity.slice(identity.lastIndexOf("@") + 1).toLowerCase()
|
|
495
|
+
: identity.toLowerCase();
|
|
496
|
+
const status = (await callGateway(
|
|
497
|
+
ctx,
|
|
498
|
+
"GET",
|
|
499
|
+
`/v1/email/identities/${encodeURIComponent(domain)}/status`,
|
|
500
|
+
)) as JsonRecord;
|
|
501
|
+
const dkim =
|
|
502
|
+
status.dkimAttributes && typeof status.dkimAttributes === "object"
|
|
503
|
+
? (status.dkimAttributes as JsonRecord)
|
|
504
|
+
: {};
|
|
505
|
+
const tokens = Array.isArray(dkim.Tokens)
|
|
506
|
+
? dkim.Tokens.filter(
|
|
507
|
+
(token): token is string => typeof token === "string",
|
|
508
|
+
)
|
|
509
|
+
: [];
|
|
510
|
+
const records = buildDnsRecords(domain, tokens);
|
|
511
|
+
const verified = status.verifiedForSendingStatus === true;
|
|
512
|
+
const payload = {
|
|
513
|
+
ok: true,
|
|
514
|
+
domain,
|
|
515
|
+
domainVerified: verified,
|
|
516
|
+
dnsRecords: records,
|
|
517
|
+
};
|
|
518
|
+
const text = verified
|
|
519
|
+
? `Domain ${domain} is verified — DNS records below are already in place.\n${formatDnsRecords(records)}`
|
|
520
|
+
: tokens.length > 0
|
|
521
|
+
? formatDnsRecords(records)
|
|
522
|
+
: `Domain ${domain} has no verification records yet. Run \`dench email identity verify --domain ${domain}\` first.`;
|
|
523
|
+
out(ctx, payload, text);
|
|
256
524
|
return;
|
|
257
525
|
}
|
|
258
526
|
if (sub === "status") {
|
|
@@ -282,7 +550,7 @@ async function runIdentity(ctx: EmailCliContext) {
|
|
|
282
550
|
out(ctx, shaped, JSON.stringify(shaped, null, 2));
|
|
283
551
|
return;
|
|
284
552
|
}
|
|
285
|
-
throw new EmailCliError("Usage: dench email identity verify|status");
|
|
553
|
+
throw new EmailCliError("Usage: dench email identity verify|list|dns|status");
|
|
286
554
|
}
|
|
287
555
|
|
|
288
556
|
async function runTemplate(ctx: EmailCliContext) {
|
package/lib/command-registry.ts
CHANGED
|
@@ -1158,6 +1158,66 @@ const rawApiOperations = [
|
|
|
1158
1158
|
responseSchema: anyResponse,
|
|
1159
1159
|
backend: gateway("GET", "/email/identities/{identity}/status"),
|
|
1160
1160
|
}),
|
|
1161
|
+
op({
|
|
1162
|
+
id: "email.identity.list",
|
|
1163
|
+
group: "email",
|
|
1164
|
+
summary: "List the workspace's email sending identities.",
|
|
1165
|
+
cli: "dench email identity list",
|
|
1166
|
+
method: "GET",
|
|
1167
|
+
path: "/email/sending-identities",
|
|
1168
|
+
requestSchema: anyObject,
|
|
1169
|
+
responseSchema: anyResponse,
|
|
1170
|
+
backend: convex("query", "functions/emailCampaigns:listIdentities"),
|
|
1171
|
+
}),
|
|
1172
|
+
op({
|
|
1173
|
+
id: "email.identity.dns",
|
|
1174
|
+
group: "email",
|
|
1175
|
+
summary:
|
|
1176
|
+
"Fetch DNS records (DKIM/SPF/DMARC) for a pending domain sender verification.",
|
|
1177
|
+
cli: "dench email identity dns",
|
|
1178
|
+
method: "GET",
|
|
1179
|
+
path: "/email/sending-identities/{identityId}/dns",
|
|
1180
|
+
requestSchema: anyObject,
|
|
1181
|
+
responseSchema: anyResponse,
|
|
1182
|
+
backend: convex(
|
|
1183
|
+
"action",
|
|
1184
|
+
"functions/emailCampaignsNode:refreshIdentityStatus",
|
|
1185
|
+
),
|
|
1186
|
+
}),
|
|
1187
|
+
op({
|
|
1188
|
+
id: "email.send",
|
|
1189
|
+
group: "email",
|
|
1190
|
+
summary:
|
|
1191
|
+
"Send one raw email (no campaign): to/cc/bcc, custom from-name and reply-to, optional reply threading via replyToMessageId.",
|
|
1192
|
+
cli: "dench email send",
|
|
1193
|
+
method: "POST",
|
|
1194
|
+
path: "/email/send",
|
|
1195
|
+
requestSchema: anyObject,
|
|
1196
|
+
responseSchema: anyResponse,
|
|
1197
|
+
backend: convex("action", "functions/emailCampaignsNode:sendEmail"),
|
|
1198
|
+
}),
|
|
1199
|
+
op({
|
|
1200
|
+
id: "email.message.get",
|
|
1201
|
+
group: "email",
|
|
1202
|
+
summary: "Read a raw email message's status and open/click counters.",
|
|
1203
|
+
cli: "dench email message status",
|
|
1204
|
+
method: "GET",
|
|
1205
|
+
path: "/email/messages/{messageId}",
|
|
1206
|
+
requestSchema: anyObject,
|
|
1207
|
+
responseSchema: anyResponse,
|
|
1208
|
+
backend: convex("query", "functions/emailCampaigns:getMessage"),
|
|
1209
|
+
}),
|
|
1210
|
+
op({
|
|
1211
|
+
id: "email.message.list",
|
|
1212
|
+
group: "email",
|
|
1213
|
+
summary: "List recent raw email messages for the workspace.",
|
|
1214
|
+
cli: "dench email message list",
|
|
1215
|
+
method: "GET",
|
|
1216
|
+
path: "/email/messages",
|
|
1217
|
+
requestSchema: anyObject,
|
|
1218
|
+
responseSchema: anyResponse,
|
|
1219
|
+
backend: convex("query", "functions/emailCampaigns:listMessages"),
|
|
1220
|
+
}),
|
|
1161
1221
|
op({
|
|
1162
1222
|
id: "email.template.create",
|
|
1163
1223
|
group: "email",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dench.com/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
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": {
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
"members.ts",
|
|
29
29
|
"cron.ts",
|
|
30
30
|
"browser.ts",
|
|
31
|
-
"linkedin.ts",
|
|
32
31
|
"search.ts",
|
|
33
32
|
"image.ts",
|
|
34
33
|
"tools.ts",
|
package/linkedin.ts
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `dench linkedin` — first-class LinkedIn outreach workflows.
|
|
3
|
-
*
|
|
4
|
-
* This is intentionally not a generic browser surface. The CLI sends
|
|
5
|
-
* one high-level command to Dench Cloud, where Browserbase + the
|
|
6
|
-
* LinkedIn-specific verifier do the whole transaction server-side.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { hasFlag } from "./lib/cli-args";
|
|
10
|
-
|
|
11
|
-
type RuntimeBundle = {
|
|
12
|
-
host: string;
|
|
13
|
-
bearerToken: string;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
type LinkedInCliContext = {
|
|
17
|
-
args: string[];
|
|
18
|
-
jsonOutput: boolean;
|
|
19
|
-
runtime: RuntimeBundle;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
type LinkedInResponse = Record<string, unknown> & {
|
|
23
|
-
crmUpdate?: unknown;
|
|
24
|
-
currentUrl?: string | null;
|
|
25
|
-
error?: string;
|
|
26
|
-
message?: string;
|
|
27
|
-
ok?: boolean;
|
|
28
|
-
pageTitle?: string | null;
|
|
29
|
-
profileUrl?: string;
|
|
30
|
-
status?: string;
|
|
31
|
-
threadId?: string;
|
|
32
|
-
threadUrl?: string;
|
|
33
|
-
verified?: boolean;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
class LinkedInCliError extends Error {}
|
|
37
|
-
|
|
38
|
-
function linkedinHelp(): void {
|
|
39
|
-
console.log(`Usage: dench linkedin <subcommand>
|
|
40
|
-
|
|
41
|
-
Connect with a LinkedIn profile using the cloud Browserbase browser:
|
|
42
|
-
dench linkedin connect <profileUrl> [--entry people:<entryId>] [--thread <threadId>] [--json]
|
|
43
|
-
|
|
44
|
-
CRM:
|
|
45
|
-
--entry people:<entryId> Update that CRM row after verification.
|
|
46
|
-
--object <object> --entry-id <entryId>
|
|
47
|
-
Same as --entry, split into two flags.
|
|
48
|
-
|
|
49
|
-
Behavior:
|
|
50
|
-
CRM is marked reached out only when LinkedIn verifies Pending.
|
|
51
|
-
If LinkedIn asks for login, rate-limits, or shows an unclear modal,
|
|
52
|
-
the result is manual_review and the browser thread stays live.
|
|
53
|
-
`);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function consumeFlagValue(args: string[], name: string): string | undefined {
|
|
57
|
-
const idx = args.indexOf(name);
|
|
58
|
-
if (idx === -1) return undefined;
|
|
59
|
-
const value = args[idx + 1];
|
|
60
|
-
args.splice(idx, 2);
|
|
61
|
-
return value;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function normalizeProfileUrl(input: string | undefined): string {
|
|
65
|
-
const value = input?.trim();
|
|
66
|
-
if (!value) {
|
|
67
|
-
throw new LinkedInCliError(
|
|
68
|
-
"Usage: dench linkedin connect <profileUrl> [--entry people:<entryId>]",
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
if (/^https?:\/\//i.test(value)) return value;
|
|
72
|
-
return `https://${value}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function parseEntry(value: string | undefined) {
|
|
76
|
-
if (!value) return null;
|
|
77
|
-
const [objectName, ...rest] = value.split(":");
|
|
78
|
-
const entryId = rest.join(":").trim();
|
|
79
|
-
if (!objectName?.trim() || !entryId) {
|
|
80
|
-
throw new LinkedInCliError("--entry must look like people:<entryId>");
|
|
81
|
-
}
|
|
82
|
-
return { objectName: objectName.trim(), entryId };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function buildLinkedInCliUrl(host: string): string {
|
|
86
|
-
return `${host.replace(/\/+$/, "")}/api/linkedin/cli`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function postJson(
|
|
90
|
-
url: string,
|
|
91
|
-
bearer: string,
|
|
92
|
-
body: unknown,
|
|
93
|
-
): Promise<{ ok: boolean; payload: unknown; status: number }> {
|
|
94
|
-
const response = await fetch(url, {
|
|
95
|
-
method: "POST",
|
|
96
|
-
headers: {
|
|
97
|
-
accept: "application/json",
|
|
98
|
-
authorization: `Bearer ${bearer}`,
|
|
99
|
-
"content-type": "application/json",
|
|
100
|
-
},
|
|
101
|
-
body: JSON.stringify(body),
|
|
102
|
-
});
|
|
103
|
-
const text = await response.text();
|
|
104
|
-
let payload: unknown;
|
|
105
|
-
try {
|
|
106
|
-
payload = text ? JSON.parse(text) : null;
|
|
107
|
-
} catch {
|
|
108
|
-
payload = { raw: text };
|
|
109
|
-
}
|
|
110
|
-
return { ok: response.ok, payload, status: response.status };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function payloadError(payload: unknown): string {
|
|
114
|
-
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
115
|
-
const error = (payload as { error?: unknown }).error;
|
|
116
|
-
if (typeof error === "string" && error.trim()) return error;
|
|
117
|
-
}
|
|
118
|
-
return "LinkedIn request failed";
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function output(ctx: LinkedInCliContext, value: unknown): void {
|
|
122
|
-
if (ctx.jsonOutput) {
|
|
123
|
-
console.log(JSON.stringify(value, null, 2));
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
console.log(String(value));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function formatHuman(response: LinkedInResponse) {
|
|
130
|
-
const lines: string[] = [];
|
|
131
|
-
const status = response.status ?? "unknown";
|
|
132
|
-
if (response.ok === false) {
|
|
133
|
-
lines.push(`LinkedIn connect failed: ${response.error ?? status}`);
|
|
134
|
-
} else {
|
|
135
|
-
lines.push(`LinkedIn connect: ${status}`);
|
|
136
|
-
}
|
|
137
|
-
if (response.verified !== undefined) {
|
|
138
|
-
lines.push(`verified: ${response.verified ? "yes" : "no"}`);
|
|
139
|
-
}
|
|
140
|
-
if (response.message) lines.push(`result: ${response.message}`);
|
|
141
|
-
if (response.profileUrl) lines.push(`profile: ${response.profileUrl}`);
|
|
142
|
-
if (response.currentUrl) lines.push(`url: ${response.currentUrl}`);
|
|
143
|
-
if (response.threadId) lines.push(`threadId: ${response.threadId}`);
|
|
144
|
-
if (response.crmUpdate) lines.push("crm: updated");
|
|
145
|
-
if (response.threadUrl) {
|
|
146
|
-
lines.push("");
|
|
147
|
-
lines.push(`Open in Dench: ${response.threadUrl}`);
|
|
148
|
-
}
|
|
149
|
-
return lines.join("\n");
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function runConnectCommand(ctx: LinkedInCliContext) {
|
|
153
|
-
const threadId = consumeFlagValue(ctx.args, "--thread");
|
|
154
|
-
const entry = parseEntry(consumeFlagValue(ctx.args, "--entry"));
|
|
155
|
-
const objectName =
|
|
156
|
-
consumeFlagValue(ctx.args, "--object") ?? entry?.objectName;
|
|
157
|
-
const entryId = consumeFlagValue(ctx.args, "--entry-id") ?? entry?.entryId;
|
|
158
|
-
const profileUrl = normalizeProfileUrl(ctx.args.shift());
|
|
159
|
-
|
|
160
|
-
const body = {
|
|
161
|
-
action: "connect",
|
|
162
|
-
profileUrl,
|
|
163
|
-
threadId,
|
|
164
|
-
...(objectName ? { crmObjectName: objectName } : {}),
|
|
165
|
-
...(entryId ? { crmEntryId: entryId } : {}),
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const result = await postJson(
|
|
169
|
-
buildLinkedInCliUrl(ctx.runtime.host),
|
|
170
|
-
ctx.runtime.bearerToken,
|
|
171
|
-
body,
|
|
172
|
-
);
|
|
173
|
-
if (!result.ok) {
|
|
174
|
-
throw new LinkedInCliError(
|
|
175
|
-
`${payloadError(result.payload)} (${result.status})`,
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const response =
|
|
180
|
-
result.payload && typeof result.payload === "object"
|
|
181
|
-
? (result.payload as LinkedInResponse)
|
|
182
|
-
: ({} as LinkedInResponse);
|
|
183
|
-
|
|
184
|
-
output(ctx, ctx.jsonOutput ? response : formatHuman(response));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function runLinkedInCommand(opts: {
|
|
188
|
-
args: string[];
|
|
189
|
-
runtime: RuntimeBundle;
|
|
190
|
-
}): Promise<void> {
|
|
191
|
-
const args = [...opts.args];
|
|
192
|
-
const jsonOutput = hasFlag(args, "--json");
|
|
193
|
-
const ctx: LinkedInCliContext = {
|
|
194
|
-
args,
|
|
195
|
-
jsonOutput,
|
|
196
|
-
runtime: opts.runtime,
|
|
197
|
-
};
|
|
198
|
-
const subcommand = args.shift();
|
|
199
|
-
if (!subcommand || subcommand === "help" || subcommand === "--help") {
|
|
200
|
-
linkedinHelp();
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (subcommand === "connect") {
|
|
204
|
-
await runConnectCommand(ctx);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
throw new LinkedInCliError(`Unknown linkedin subcommand: ${subcommand}`);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export { LinkedInCliError };
|