Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/api/src/automation/process-ongoing-sequence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,56 @@ describe("processOngoingSequence", () => {
expect(after.nextEmailScheduledTime).toBeGreaterThanOrEqual(before + 15 * 60_000);
});

it("pauses and names the domain when the From domain isn't verified", async () => {
const { team, contact } = await seedTeamAndContact(tdb);
const { sequenceRow } = await seedSequence(tdb, {
teamId: team.id,
emails: [{ emailId: "email_e1" }],
});
const row = await seedOngoingSequence(tdb, {
teamId: team.id,
sequenceId: sequenceRow.id,
contactId: contact.id,
});
const resolve = spyOn(
deliveryQueries,
"resolvePinnedDeliverySource",
).mockRejectedValue(
new PlanGateError("domain_verification_required", { domain: "mail.example.com" }),
);
const notify = spyOn(billingNotifications, "notifyOrganizationOwners")
.mockClear()
.mockResolvedValue(undefined);
try {
await expect(processOngoingSequence(row.id)).resolves.toBeUndefined();
} finally {
resolve.mockRestore();
}

expect(mockedSendMail).not.toHaveBeenCalled();
const [seqAfter] = await tdb
.select()
.from(sequences)
.where(eq(sequences.id, sequenceRow.id));
expect(seqAfter.status).toBe("paused");
expect((seqAfter.report as any).autoPause).toMatchObject({
reason: "sending_blocked",
error: "domain_verification_required",
domain: "mail.example.com",
});
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0][2]).toContain(
"From domain mail.example.com isn't verified",
);
expect(notify.mock.calls[0][2]).toContain("Organizations → Sending domains");
notify.mockRestore();
const [after] = await tdb
.select()
.from(ongoingSequences)
.where(eq(ongoingSequences.id, row.id));
expect(after.retryCount).toBe(0);
});

it("pauses and emails the owners once when sending is blocked until the team fixes it", async () => {
const { team, contact } = await seedTeamAndContact(tdb);
const { sequenceRow } = await seedSequence(tdb, {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/automation/process-ongoing-sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,8 @@ async function attemptMailSending({
const stop = teamFixableSendingStop(err);
if (!stop) throw err;
const paused = await autoPauseSequence(sequence, "sending_blocked", {
error: stop,
error: stop.code,
...(stop.domain ? { domain: stop.domain } : {}),
threshold: testVolumeThreshold(),
});
if (paused || (await isSequencePaused(sequence.id)))
Expand Down
42 changes: 31 additions & 11 deletions apps/api/src/automation/sending-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,23 @@ const TEAM_FIXABLE_SENDING_STOPS = new Set([
"verified_owner_email_required",
]);

/** The team-fixable stop behind a `sending_paused` error, if it is one. */
export function teamFixableSendingStop(error: unknown): string | null {
if (!isPlanGateError(error) || error.code !== "sending_paused") return null;
/** The team-fixable stop behind a send error, if it is one: a
* `sending_paused` reason above, or an unverified From domain. */
export function teamFixableSendingStop(
error: unknown,
): { code: string; domain?: string } | null {
if (!isPlanGateError(error)) return null;
if (error.code === "domain_verification_required") {
const domain = error.details.domain;
return {
code: "domain_verification_required",
...(typeof domain === "string" ? { domain } : {}),
};
}
if (error.code !== "sending_paused") return null;
const reason = error.details.reason;
return typeof reason === "string" && TEAM_FIXABLE_SENDING_STOPS.has(reason)
? reason
? { code: reason }
: null;
}

Expand All @@ -141,8 +152,15 @@ export function sendingBlockedReason(
code: string,
/** The cloud test volume when the pause happened (`testVolumeThreshold`). */
threshold?: number,
/** The unverified From domain, for `domain_verification_required`. */
domain?: string,
): { why: string; next: string } {
switch (code) {
case "domain_verification_required":
return {
why: `its From domain${domain ? ` ${domain}` : ""} isn't verified for your organization, which is required after your organization's first ${threshold ?? "few"} emails`,
next: "Verify the domain in Organizations → Sending domains",
};
case "feedback_connection_required":
return {
why: `its email provider isn't reporting bounces and spam complaints back to SendLit, which is required after your organization's first ${threshold ?? "few"} emails`,
Expand All @@ -161,8 +179,13 @@ export function sendingBlockedReason(
}
}

function blockedThreshold(details: Record<string, unknown>): number | undefined {
return typeof details.threshold === "number" ? details.threshold : undefined;
/** `sendingBlockedReason` for a `sending_blocked` pause's details. */
function blockedReason(details: Record<string, unknown>) {
return sendingBlockedReason(
String(details.error ?? ""),
typeof details.threshold === "number" ? details.threshold : undefined,
typeof details.domain === "string" ? details.domain : undefined,
);
}

export async function autoPauseSequence(
Expand Down Expand Up @@ -212,16 +235,13 @@ export async function autoPauseSequence(
: reason === "render_error"
? renderErrorReason(String(details.error ?? ""))
: reason === "sending_blocked"
? sendingBlockedReason(
String(details.error ?? ""),
blockedThreshold(details),
).why
? blockedReason(details).why
: `your email provider refused to send (${String(details.error ?? "")})`;
const next =
reason === "render_error"
? "Check your mailing address in Settings and the email's content, then resume it from SendLit"
: reason === "sending_blocked"
? `${sendingBlockedReason(String(details.error ?? ""), blockedThreshold(details)).next}, then resume it from SendLit`
? `${blockedReason(details).next}, then resume it from SendLit`
: "Sending more would hurt your sender reputation. Check your list and your email provider, then resume it from SendLit";
await notifyOrganizationOwners(
team.organizationId,
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/delivery-feedback/feedback-connection-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,17 @@ export async function disableOrganizationFeedbackConnection(
* Never promotes a `retiring`/`disabled` connection back to `healthy` — a
* late grace-period event is still accepted and processed, but the
* connection's lifecycle state shouldn't imply it's taking new sends again. */
export async function recordFeedbackConnectionVerified(id: string): Promise<void> {
export async function recordFeedbackConnectionVerified(
id: string,
/** A real event was received, not just the manual test; only then does
* "last event" change. */
{ event = false }: { event?: boolean } = {},
): Promise<void> {
await db
.update(espFeedbackConnections)
.set({
status: "healthy",
lastReceivedAt: new Date(),
...(event ? { lastReceivedAt: new Date() } : {}),
lastVerifiedAt: new Date(),
lastErrorCode: null,
updatedAt: new Date(),
Expand Down
74 changes: 72 additions & 2 deletions apps/api/src/delivery-feedback/feedback-requirement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ async function setup() {
return { organization, team, esp };
}

async function verifyDomain(organizationId: string, domain: string) {
await tdb.insert(schema.sendingDomains).values({
organizationId,
domain,
challengeTokenHash: "0".repeat(64),
status: "verified",
verifiedAt: new Date(),
});
}

async function accept(teamId: string, espConfigId: string, count: number) {
for (let i = 0; i < count; i++) {
const recipient = `r${crypto.randomUUID()}@example.com`;
Expand Down Expand Up @@ -102,6 +112,7 @@ describe("feedback requirement", () => {
expect(notify).toHaveBeenCalledTimes(3);
expect(notify.mock.calls[2][1]).toContain("Sending stopped");
expect(notify.mock.calls[2][2]).toContain(esp.name);
expect(notify.mock.calls[2][2]).toContain("Verify example.com");

await accept(team.id, esp.id, 5);
await notifyFeedbackMilestones(team.id);
Expand Down Expand Up @@ -130,8 +141,9 @@ describe("feedback requirement", () => {
}
});

it("doesn't email when every ESP already has healthy feedback", async () => {
const { team, esp } = await setup();
it("doesn't email when every ESP has healthy feedback and a verified domain", async () => {
const { organization, team, esp } = await setup();
await verifyDomain(organization.id, "example.com");
await tdb.insert(schema.espFeedbackConnections).values({
teamId: team.id,
espConfigId: esp.id,
Expand All @@ -152,6 +164,64 @@ describe("feedback requirement", () => {
}
});

it("warns about an unverified From domain even when feedback is connected", async () => {
const { team, esp } = await setup();
await tdb.insert(schema.espFeedbackConnections).values({
teamId: team.id,
espConfigId: esp.id,
ownerScope: "team",
provider: "postmark",
encryptedCredentials: encryptSecret("webhook-secret"),
status: "healthy",
});
const notify = spyOn(notifications, "notifyOrganizationOwners").mockResolvedValue(
undefined,
);
try {
await accept(team.id, esp.id, 5);
await notifyFeedbackMilestones(team.id);
expect(notify).toHaveBeenCalledTimes(1);
const text = notify.mock.calls[0][2];
expect(text).toContain("Verify example.com in Organizations → Sending domains.");
// Feedback is connected, so it isn't listed as a step.
expect(text).not.toContain("Connect bounce and complaint feedback");
} finally {
notify.mockRestore();
}
});

it("warns about the From domain of a shared organization ESP", async () => {
const { organization, team, esp } = await setup();
await verifyDomain(organization.id, "example.com");
await tdb.insert(schema.espFeedbackConnections).values({
teamId: team.id,
espConfigId: esp.id,
ownerScope: "team",
provider: "postmark",
encryptedCredentials: encryptSecret("webhook-secret"),
status: "healthy",
});
await tdb.insert(schema.espConfigs).values({
ownerScope: "organization",
organizationId: organization.id,
name: "Shared",
host: "smtp.example.net",
fromEmail: "news@shared.example.org",
status: "active",
});
const notify = spyOn(notifications, "notifyOrganizationOwners").mockResolvedValue(
undefined,
);
try {
await accept(team.id, esp.id, 5);
await notifyFeedbackMilestones(team.id);
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0][2]).toContain("Verify shared.example.org");
} finally {
notify.mockRestore();
}
});

it("does nothing on self-hosted SendLit", async () => {
process.env.SENDLIT_DEPLOYMENT_MODE = "oss";
const { team, esp } = await setup();
Expand Down
Loading
Loading