@dench.com/cli 3.11.6 → 3.11.7-staging.488
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/app-sdk.d.ts +6 -0
- package/campaign.ts +157 -43
- package/lib/api-schemas.ts +130 -57
- package/lib/campaign-agent.ts +136 -50
- package/lib/campaign-csv.ts +183 -53
- package/lib/enrichment-gateway.ts +9 -0
- package/lib/references.generated.json +4 -4
- package/package.json +1 -1
package/app-sdk.d.ts
CHANGED
|
@@ -80,6 +80,10 @@ export interface ApiRequests {
|
|
|
80
80
|
name?: string;
|
|
81
81
|
/** IANA timezone, e.g. America/Los_Angeles. Sets a Mon-Fri 9-18 send window in it; without one the draft has no send window. */
|
|
82
82
|
timezone?: string;
|
|
83
|
+
/** Mailboxes to send from instead of your own running ones. Checked as the draft is created: one you can't use creates nothing. */
|
|
84
|
+
emailMailboxAccountIds?: string[];
|
|
85
|
+
/** LinkedIn accounts to send from instead of your own running ones. Checked as the draft is created. */
|
|
86
|
+
linkedinAccountIds?: string[];
|
|
83
87
|
};
|
|
84
88
|
/** Search people by filters without adding CRM records or campaign recipients. Search credits apply; this is not a free provider preview. — `dench campaign search` — POST /api/v1/campaigns/audience/search */
|
|
85
89
|
"campaigns.audience.search": {
|
|
@@ -569,8 +573,10 @@ export interface ApiRequests {
|
|
|
569
573
|
};
|
|
570
574
|
steps?: Array<{
|
|
571
575
|
type: "email" | "wait" | "linkedin_connection" | "linkedin_message";
|
|
576
|
+
/** Hours to wait, 1 to 1440. Required on a wait step. */
|
|
572
577
|
waitHours?: number;
|
|
573
578
|
emailSubject?: string;
|
|
579
|
+
/** Plain text only; draft steps keep no HTML body. */
|
|
574
580
|
emailTextBody?: string;
|
|
575
581
|
emailReplyToPrevious?: boolean;
|
|
576
582
|
onlyIf?: "always" | "connected" | "not_connected";
|
package/campaign.ts
CHANGED
|
@@ -339,6 +339,14 @@ function unknownFlags(args: readonly string[], command: string): void {
|
|
|
339
339
|
if (error instanceof CliArgError) throw new CampaignCliError(error.message);
|
|
340
340
|
throw error;
|
|
341
341
|
}
|
|
342
|
+
// Every caller reads its positionals first, so anything left is a typo or
|
|
343
|
+
// a value missing its flag; refuse it before a write.
|
|
344
|
+
const extra = args.find((token) => token !== "--");
|
|
345
|
+
if (extra !== undefined) {
|
|
346
|
+
throw new CampaignCliError(
|
|
347
|
+
`Unexpected argument for ${command}: ${JSON.stringify(extra)}. Quote a name with spaces, or check for a value missing its flag. Run \`dench campaign help\` for usage.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
342
350
|
}
|
|
343
351
|
|
|
344
352
|
async function callQuery(
|
|
@@ -746,11 +754,13 @@ export function parseStepsJson(
|
|
|
746
754
|
export { stepToInput };
|
|
747
755
|
|
|
748
756
|
/**
|
|
749
|
-
* A `--start`/`--end` value. A
|
|
750
|
-
* date (`2026-09-03`)
|
|
751
|
-
*
|
|
757
|
+
* A `--start`/`--end` value. A timestamp with an offset is an instant
|
|
758
|
+
* already; a bare date (`2026-09-03`) or a date-time without an offset
|
|
759
|
+
* (`2026-09-03T09:00`) stays wall-clock time until the campaign's timezone
|
|
760
|
+
* is known, because 09:00 in Los Angeles is not 09:00 in UTC (nor wherever
|
|
761
|
+
* the CLI happens to run).
|
|
752
762
|
*/
|
|
753
|
-
export type WhenFlag = number | { date: string };
|
|
763
|
+
export type WhenFlag = number | { date: string; time?: string };
|
|
754
764
|
|
|
755
765
|
export type ScheduleFlags = {
|
|
756
766
|
timezone?: string;
|
|
@@ -761,28 +771,54 @@ export type ScheduleFlags = {
|
|
|
761
771
|
windows: string[];
|
|
762
772
|
};
|
|
763
773
|
|
|
764
|
-
|
|
774
|
+
/**
|
|
775
|
+
* ISO 8601 only: a date, optionally `T` + time, optionally an offset. Every
|
|
776
|
+
* part is checked, so February 30 or 25:00 is refused rather than rolled
|
|
777
|
+
* over by `Date.parse`, and other formats (09/03/2026) are refused outright.
|
|
778
|
+
*/
|
|
779
|
+
const ISO_WHEN =
|
|
780
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d{1,3})?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
781
|
+
|
|
782
|
+
function isCalendarDate(year: number, month: number, day: number): boolean {
|
|
783
|
+
const utc = new Date(Date.UTC(year, month - 1, day));
|
|
784
|
+
return (
|
|
785
|
+
utc.getUTCFullYear() === year &&
|
|
786
|
+
utc.getUTCMonth() === month - 1 &&
|
|
787
|
+
utc.getUTCDate() === day
|
|
788
|
+
);
|
|
789
|
+
}
|
|
765
790
|
|
|
766
791
|
function parseWhenFlag(value: string, flag: string): WhenFlag {
|
|
767
792
|
const trimmed = value.trim();
|
|
768
|
-
const
|
|
769
|
-
if (
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
793
|
+
const match = trimmed.match(ISO_WHEN);
|
|
794
|
+
if (!match) {
|
|
795
|
+
throw new CampaignCliError(
|
|
796
|
+
`${flag} must be an ISO 8601 date or date-time, e.g. 2026-09-03, 2026-09-03T09:00 (in the campaign's timezone) or 2026-09-03T09:00:00-07:00 (got "${value}").`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
const [, year, month, day, hour, minute, second, , offset] = match;
|
|
800
|
+
if (!isCalendarDate(Number(year), Number(month), Number(day))) {
|
|
801
|
+
throw new CampaignCliError(`${flag}: "${value}" is not a calendar date.`);
|
|
802
|
+
}
|
|
803
|
+
const date = `${year}-${month}-${day}`;
|
|
804
|
+
if (hour === undefined) return { date };
|
|
805
|
+
if (
|
|
806
|
+
Number(hour) > 23 ||
|
|
807
|
+
Number(minute) > 59 ||
|
|
808
|
+
Number(second ?? 0) > 59 ||
|
|
809
|
+
(offset !== undefined &&
|
|
810
|
+
offset !== "Z" &&
|
|
811
|
+
(Number(offset.slice(1, 3)) > 14 || Number(offset.slice(-2)) > 59))
|
|
812
|
+
) {
|
|
813
|
+
throw new CampaignCliError(`${flag}: "${value}" is not a valid time.`);
|
|
780
814
|
}
|
|
815
|
+
const time = `${hour}:${minute}:${second ?? "00"}`;
|
|
816
|
+
// No offset: the campaign's timezone decides the instant, never the
|
|
817
|
+
// machine the CLI runs on.
|
|
818
|
+
if (offset === undefined) return { date, time };
|
|
781
819
|
const parsed = Date.parse(trimmed);
|
|
782
820
|
if (!Number.isFinite(parsed)) {
|
|
783
|
-
throw new CampaignCliError(
|
|
784
|
-
`${flag} must be an ISO 8601 date or date-time, e.g. 2026-09-03 or 2026-09-03T09:00:00-07:00 (got "${value}").`,
|
|
785
|
-
);
|
|
821
|
+
throw new CampaignCliError(`${flag}: "${value}" is not a valid date-time.`);
|
|
786
822
|
}
|
|
787
823
|
return parsed;
|
|
788
824
|
}
|
|
@@ -812,26 +848,64 @@ function timezoneOffsetMs(ms: number, timeZone: string): number {
|
|
|
812
848
|
return wallClockAsUtc - (ms - (ms % 1000));
|
|
813
849
|
}
|
|
814
850
|
|
|
815
|
-
|
|
816
|
-
|
|
851
|
+
const DAY_MS = 86_400_000;
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* The instant a wall-clock time (`date` YYYY-MM-DD, `time` HH:MM[:SS]) has
|
|
855
|
+
* in `timezone`, as epoch ms. A time a DST change repeats takes its first
|
|
856
|
+
* instant; a time it skips (some zones skip midnight) takes the first instant
|
|
857
|
+
* after the gap, which is still on that date.
|
|
858
|
+
*/
|
|
859
|
+
export function zonedTime(
|
|
860
|
+
date: string,
|
|
861
|
+
timezone: string,
|
|
862
|
+
time = "00:00:00",
|
|
863
|
+
): number {
|
|
817
864
|
const timeZone = assertIanaTimezone(timezone);
|
|
818
865
|
const [year, month, day] = date.split("-").map(Number);
|
|
819
|
-
const
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
//
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
866
|
+
const [hour = 0, minute = 0, second = 0] = time.split(":").map(Number);
|
|
867
|
+
const wall = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
868
|
+
// The offsets in force around that wall time; each one that maps back to
|
|
869
|
+
// it is a reading of it.
|
|
870
|
+
const offsets = [
|
|
871
|
+
...new Set(
|
|
872
|
+
[wall - DAY_MS, wall, wall + DAY_MS].map((ms) =>
|
|
873
|
+
timezoneOffsetMs(ms, timeZone),
|
|
874
|
+
),
|
|
875
|
+
),
|
|
876
|
+
];
|
|
877
|
+
const readings = offsets
|
|
878
|
+
.map((offset) => wall - offset)
|
|
879
|
+
.filter((ms) => timezoneOffsetMs(ms, timeZone) === wall - ms);
|
|
880
|
+
if (readings.length) return Math.min(...readings);
|
|
881
|
+
// Skipped: the clock jumped from the smaller offset to the larger one
|
|
882
|
+
// somewhere in [wall - larger, wall - smaller]. The jump is the answer.
|
|
883
|
+
const before = Math.min(...offsets);
|
|
884
|
+
let lo = wall - Math.max(...offsets);
|
|
885
|
+
let hi = wall - before;
|
|
886
|
+
while (hi - lo > 1000) {
|
|
887
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
888
|
+
if (timezoneOffsetMs(mid, timeZone) === before) lo = mid;
|
|
889
|
+
else hi = mid;
|
|
890
|
+
}
|
|
891
|
+
return hi - (hi % 1000);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Midnight at the start of `date` (YYYY-MM-DD) in `timezone`, as epoch ms. */
|
|
895
|
+
export function zonedMidnight(date: string, timezone: string): number {
|
|
896
|
+
return zonedTime(date, timezone);
|
|
826
897
|
}
|
|
827
898
|
|
|
828
899
|
function resolveWhen(when: WhenFlag | null, timezone: string): number | null {
|
|
829
900
|
if (when === null || typeof when === "number") return when;
|
|
830
|
-
return
|
|
901
|
+
return zonedTime(when.date, timezone, when.time);
|
|
831
902
|
}
|
|
832
903
|
|
|
904
|
+
/** Orders two values of the same kind (both instants, or both wall-clock). */
|
|
833
905
|
function whenOrder(when: WhenFlag): number {
|
|
834
|
-
return typeof when === "number"
|
|
906
|
+
return typeof when === "number"
|
|
907
|
+
? when
|
|
908
|
+
: Date.parse(`${when.date}T${when.time ?? "00:00:00"}Z`);
|
|
835
909
|
}
|
|
836
910
|
|
|
837
911
|
const END_BEFORE_START = "--end must be after --start.";
|
|
@@ -1460,10 +1534,32 @@ async function runCreate(ctx: CampaignCliContext): Promise<void> {
|
|
|
1460
1534
|
...senderPools,
|
|
1461
1535
|
})) as { sequenceId: string; references?: unknown[] };
|
|
1462
1536
|
if (hasScheduleChanges(patch)) {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1537
|
+
try {
|
|
1538
|
+
await callMutation(ctx, `${OUTREACH}:updateDraft`, {
|
|
1539
|
+
sequenceId: created.sequenceId,
|
|
1540
|
+
...patch,
|
|
1541
|
+
});
|
|
1542
|
+
} catch (error) {
|
|
1543
|
+
// The draft exists; say which one, so the schedule can be set on it
|
|
1544
|
+
// rather than creating another.
|
|
1545
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1546
|
+
if (ctx.jsonOutput) {
|
|
1547
|
+
out(
|
|
1548
|
+
ctx,
|
|
1549
|
+
{
|
|
1550
|
+
sequenceId: created.sequenceId,
|
|
1551
|
+
name,
|
|
1552
|
+
status: "draft",
|
|
1553
|
+
scheduleSaved: false,
|
|
1554
|
+
error: reason,
|
|
1555
|
+
},
|
|
1556
|
+
[],
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
throw new CampaignCliError(
|
|
1560
|
+
`Created draft campaign "${name}" [${created.sequenceId}], but its schedule wasn't saved: ${reason}. Set it with: dench campaign set ${created.sequenceId} <the same schedule flags>`,
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1467
1563
|
}
|
|
1468
1564
|
const schedules = patch.sendSchedules ?? [businessHoursSchedule(timezone)];
|
|
1469
1565
|
const payload = {
|
|
@@ -1966,13 +2062,30 @@ async function runStepsEdit(
|
|
|
1966
2062
|
return;
|
|
1967
2063
|
}
|
|
1968
2064
|
if (draft) {
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
2065
|
+
// The versions this edit was planned against: a step another editor
|
|
2066
|
+
// saved since the read makes the replace fail rather than undo it.
|
|
2067
|
+
const expectedSteps = sortedSteps(detail).map((step) => ({
|
|
2068
|
+
id: String(step._id),
|
|
2069
|
+
updatedAt: Number(step.updatedAt),
|
|
2070
|
+
}));
|
|
2071
|
+
try {
|
|
2072
|
+
await callMutation(ctx, `${OUTREACH}:replaceSteps`, {
|
|
2073
|
+
sequenceId: row._id,
|
|
2074
|
+
steps: plan.steps,
|
|
2075
|
+
expectedSteps,
|
|
2076
|
+
...(workflow || plan.steps.some(hasWorkflowShape)
|
|
2077
|
+
? { stepModel: "workflow" }
|
|
2078
|
+
: {}),
|
|
2079
|
+
});
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2082
|
+
if (/changed in another editor/.test(message)) {
|
|
2083
|
+
throw new CampaignCliError(
|
|
2084
|
+
`"${row.name}"'s steps changed since this edit read them, so nothing was saved. Read them again with dench campaign steps get ${row._id} --with-ids, redo the edit, and run it again.`,
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
throw error;
|
|
2088
|
+
}
|
|
1976
2089
|
} else {
|
|
1977
2090
|
await callMutation(ctx, `${OUTREACH}:updateSteps`, {
|
|
1978
2091
|
sequenceId: row._id,
|
|
@@ -4454,7 +4567,8 @@ Send window spec (--window, repeatable, all in --timezone):
|
|
|
4454
4567
|
"Mon-Fri 9-18" "Mon,Wed,Fri 10-16" "Weekdays 9am-5pm" "Sat-Sun 10-14"
|
|
4455
4568
|
|
|
4456
4569
|
--start / --end take ISO 8601. A bare date (2026-10-01) is midnight at the start
|
|
4457
|
-
of that day in the campaign's timezone
|
|
4570
|
+
of that day in the campaign's timezone, and a time without an offset
|
|
4571
|
+
(2026-10-01T09:00) is that time there too; add an offset to pin the instant.
|
|
4458
4572
|
|
|
4459
4573
|
steps.json: a JSON array of steps, in order. Types: email, wait, linkedin_visit,
|
|
4460
4574
|
linkedin_connection, linkedin_message, linkedin_like_post, linkedin_inmail,
|
package/lib/api-schemas.ts
CHANGED
|
@@ -2310,22 +2310,48 @@ const composioToolRun = z.object({
|
|
|
2310
2310
|
* One list-filter entry: a plain string includes the value; the object
|
|
2311
2311
|
* form supports per-value `exclude` / `exactMatch`.
|
|
2312
2312
|
*/
|
|
2313
|
-
const searchFilterItem = z.union(
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2313
|
+
const searchFilterItem = z.union(
|
|
2314
|
+
[
|
|
2315
|
+
z.string(),
|
|
2316
|
+
// Strict: a misspelled control (`excluded`) must fail, not be stripped
|
|
2317
|
+
// into an include.
|
|
2318
|
+
z
|
|
2319
|
+
.object({
|
|
2320
|
+
value: z.string(),
|
|
2321
|
+
exclude: z.boolean().optional(),
|
|
2322
|
+
exactMatch: z.boolean().optional(),
|
|
2323
|
+
})
|
|
2324
|
+
.strict(),
|
|
2325
|
+
],
|
|
2326
|
+
{
|
|
2327
|
+
error: (issue) => {
|
|
2328
|
+
// Name what was wrong with the object form ("Unrecognized key:
|
|
2329
|
+
// excluded"); the default union message is only "Invalid input".
|
|
2330
|
+
const branches = (issue as { errors?: { message: string }[][] }).errors;
|
|
2331
|
+
const detail = branches?.[1]?.map((entry) => entry.message).join("; ");
|
|
2332
|
+
return `Use a string or {"value": "…", "exclude": true, "exactMatch": true}${detail ? ` (${detail})` : ""}`;
|
|
2333
|
+
},
|
|
2334
|
+
},
|
|
2335
|
+
);
|
|
2321
2336
|
|
|
2322
2337
|
const FILTER_OPTIONS_NOTE =
|
|
2323
2338
|
"Accepted values: see `dench leads filters` or https://dench.com/references/find-filter-options.json.";
|
|
2324
2339
|
|
|
2325
|
-
const searchRange = z
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2340
|
+
const searchRange = z
|
|
2341
|
+
.object({
|
|
2342
|
+
min: z.number().int().nonnegative().optional(),
|
|
2343
|
+
max: z.number().int().nonnegative().optional(),
|
|
2344
|
+
})
|
|
2345
|
+
// A range has no exclude: an extra key would silently turn into a
|
|
2346
|
+
// positive range.
|
|
2347
|
+
.strict()
|
|
2348
|
+
.refine(
|
|
2349
|
+
(range) =>
|
|
2350
|
+
range.min === undefined ||
|
|
2351
|
+
range.max === undefined ||
|
|
2352
|
+
range.min <= range.max,
|
|
2353
|
+
{ message: "Provide a range with min no greater than max." },
|
|
2354
|
+
);
|
|
2329
2355
|
|
|
2330
2356
|
const peopleSearch = z.object({
|
|
2331
2357
|
linkedinUrl: z.string().optional(),
|
|
@@ -2508,34 +2534,43 @@ const campaignTargeting = z.object({
|
|
|
2508
2534
|
companies: z.array(z.string()).optional(),
|
|
2509
2535
|
companyTypes: z.array(z.string()).optional(),
|
|
2510
2536
|
});
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
.array(z.string())
|
|
2526
|
-
.optional()
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2537
|
+
/**
|
|
2538
|
+
* Targeting a request sends. Strict: a key the builder can't represent
|
|
2539
|
+
* (seniorities, companyHeadquarters, companyDomains…) is refused, not
|
|
2540
|
+
* dropped, since dropping it would quietly widen the audience.
|
|
2541
|
+
*/
|
|
2542
|
+
const campaignTargetingInput = campaignTargeting.strict();
|
|
2543
|
+
const campaignSignalTerms = z
|
|
2544
|
+
.object({
|
|
2545
|
+
post_engagement: z
|
|
2546
|
+
.array(z.string())
|
|
2547
|
+
.optional()
|
|
2548
|
+
.describe(
|
|
2549
|
+
'Post topics, phrased as what the buyer does ("using AI for outbound"), not bare product names.',
|
|
2550
|
+
),
|
|
2551
|
+
keyword: z.array(z.string()).optional(),
|
|
2552
|
+
hiring: z.array(z.string()).optional().describe("Roles being hired for."),
|
|
2553
|
+
voice_engagement: z
|
|
2554
|
+
.array(z.string())
|
|
2555
|
+
.optional()
|
|
2556
|
+
.describe("LinkedIn profile URLs of market voices whose posts to watch."),
|
|
2557
|
+
company_engagement: z
|
|
2558
|
+
.array(z.string())
|
|
2559
|
+
.optional()
|
|
2560
|
+
.describe("LinkedIn company page URLs of companies and competitors."),
|
|
2561
|
+
brand_engagement: z
|
|
2562
|
+
.array(z.string())
|
|
2563
|
+
.optional()
|
|
2564
|
+
.describe("The sender's own LinkedIn company page URL."),
|
|
2565
|
+
exact: z
|
|
2566
|
+
.array(z.enum(["post_engagement", "keyword"]))
|
|
2567
|
+
.optional()
|
|
2568
|
+
.describe(
|
|
2569
|
+
"Topic signals searched by their exact keywords only (no drafted phrases).",
|
|
2570
|
+
),
|
|
2571
|
+
})
|
|
2572
|
+
// A term list under a signal it doesn't recognize is refused, not dropped.
|
|
2573
|
+
.strict();
|
|
2539
2574
|
const campaignDraftSource = z
|
|
2540
2575
|
.enum(["signals", "lookalike", "existing", "crm", "search"])
|
|
2541
2576
|
.describe(
|
|
@@ -2575,19 +2610,40 @@ const campaignOutreachBrief = z.object({
|
|
|
2575
2610
|
tone: z.enum(["conversational", "warm", "direct", "formal"]),
|
|
2576
2611
|
channels: z.enum(["both", "email", "linkedin"]),
|
|
2577
2612
|
});
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2613
|
+
// Strict, with the bounds saving enforces: a field or value it can't keep
|
|
2614
|
+
// (an HTML body, a 13th step, a negative wait) is refused, not dropped.
|
|
2615
|
+
const campaignDraftStep = z
|
|
2616
|
+
.object({
|
|
2617
|
+
type: z.enum(["email", "wait", "linkedin_connection", "linkedin_message"]),
|
|
2618
|
+
waitHours: z
|
|
2619
|
+
.number()
|
|
2620
|
+
.min(1)
|
|
2621
|
+
.max(24 * 60)
|
|
2622
|
+
.optional()
|
|
2623
|
+
.describe("Hours to wait, 1 to 1440. Required on a wait step."),
|
|
2624
|
+
emailSubject: z.string().max(200).optional(),
|
|
2625
|
+
emailTextBody: z
|
|
2626
|
+
.string()
|
|
2627
|
+
.max(5000)
|
|
2628
|
+
.optional()
|
|
2629
|
+
.describe("Plain text only; draft steps keep no HTML body."),
|
|
2630
|
+
emailReplyToPrevious: z.boolean().optional(),
|
|
2631
|
+
onlyIf: z.enum(["always", "connected", "not_connected"]).optional(),
|
|
2632
|
+
linkedinMessageBody: z.string().max(5000).optional(),
|
|
2633
|
+
linkedinMessageMode: z
|
|
2634
|
+
.enum(["skip_if_not_connected", "wait_until_connected"])
|
|
2635
|
+
.optional(),
|
|
2636
|
+
connectionTimeoutHours: z
|
|
2637
|
+
.number()
|
|
2638
|
+
.min(1)
|
|
2639
|
+
.max(24 * 60)
|
|
2640
|
+
.optional(),
|
|
2641
|
+
})
|
|
2642
|
+
.strict()
|
|
2643
|
+
.refine((step) => step.type !== "wait" || step.waitHours !== undefined, {
|
|
2644
|
+
message: "A wait step needs waitHours (1 to 1440).",
|
|
2645
|
+
path: ["waitHours"],
|
|
2646
|
+
});
|
|
2591
2647
|
const campaignDraftSending = z.object({
|
|
2592
2648
|
emailMailboxAccountIds: z.array(z.string()),
|
|
2593
2649
|
linkedinAccountIds: z.array(z.string()),
|
|
@@ -3174,6 +3230,20 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
|
|
|
3174
3230
|
.describe(
|
|
3175
3231
|
"IANA timezone, e.g. America/Los_Angeles. Sets a Mon-Fri 9-18 send window in it; without one the draft has no send window.",
|
|
3176
3232
|
),
|
|
3233
|
+
emailMailboxAccountIds: z
|
|
3234
|
+
.array(z.string().min(1))
|
|
3235
|
+
.max(50)
|
|
3236
|
+
.optional()
|
|
3237
|
+
.describe(
|
|
3238
|
+
"Mailboxes to send from instead of your own running ones. Checked as the draft is created: one you can't use creates nothing.",
|
|
3239
|
+
),
|
|
3240
|
+
linkedinAccountIds: z
|
|
3241
|
+
.array(z.string().min(1))
|
|
3242
|
+
.max(50)
|
|
3243
|
+
.optional()
|
|
3244
|
+
.describe(
|
|
3245
|
+
"LinkedIn accounts to send from instead of your own running ones. Checked as the draft is created.",
|
|
3246
|
+
),
|
|
3177
3247
|
})
|
|
3178
3248
|
.strict(),
|
|
3179
3249
|
"campaigns.audience.search": z
|
|
@@ -3210,7 +3280,7 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
|
|
|
3210
3280
|
cadence: z.enum(["daily", "weekly", "monthly"]).optional(),
|
|
3211
3281
|
findMoreMatching: z.boolean().optional(),
|
|
3212
3282
|
approvalMode: campaignApprovalMode.optional(),
|
|
3213
|
-
targeting:
|
|
3283
|
+
targeting: campaignTargetingInput.optional(),
|
|
3214
3284
|
signal: z
|
|
3215
3285
|
.object({ signal: campaignSignal, enabled: z.boolean() })
|
|
3216
3286
|
.optional(),
|
|
@@ -3244,7 +3314,7 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
|
|
|
3244
3314
|
"campaigns.builder.save": z
|
|
3245
3315
|
.object({
|
|
3246
3316
|
clientRequestId: campaignClientRequestId,
|
|
3247
|
-
targeting:
|
|
3317
|
+
targeting: campaignTargetingInput,
|
|
3248
3318
|
signals: z.array(campaignSignal),
|
|
3249
3319
|
signalTerms: campaignSignalTerms.optional(),
|
|
3250
3320
|
findMoreMatching: z
|
|
@@ -3287,7 +3357,10 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
|
|
|
3287
3357
|
"ai: generate the sequence from the brief. manual: write steps.",
|
|
3288
3358
|
),
|
|
3289
3359
|
brief: campaignOutreachBrief.optional(),
|
|
3290
|
-
steps: z
|
|
3360
|
+
steps: z
|
|
3361
|
+
.array(campaignDraftStep)
|
|
3362
|
+
.max(12, "A sequence can have up to 12 steps.")
|
|
3363
|
+
.optional(),
|
|
3291
3364
|
sending: campaignDraftSending.optional(),
|
|
3292
3365
|
approvalMode: campaignApprovalMode.optional(),
|
|
3293
3366
|
})
|
|
@@ -3332,7 +3405,7 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
|
|
|
3332
3405
|
"campaigns.builder.estimate": z
|
|
3333
3406
|
.object({
|
|
3334
3407
|
kind: z.enum(["people", "week"]),
|
|
3335
|
-
targeting:
|
|
3408
|
+
targeting: campaignTargetingInput,
|
|
3336
3409
|
signals: z.array(campaignSignal).optional(),
|
|
3337
3410
|
signalTerms: campaignSignalTerms.optional(),
|
|
3338
3411
|
})
|