diff --git a/apps/api/src/automation/process-ongoing-sequence.test.ts b/apps/api/src/automation/process-ongoing-sequence.test.ts index 32eb077..3090459 100644 --- a/apps/api/src/automation/process-ongoing-sequence.test.ts +++ b/apps/api/src/automation/process-ongoing-sequence.test.ts @@ -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, { diff --git a/apps/api/src/automation/process-ongoing-sequence.ts b/apps/api/src/automation/process-ongoing-sequence.ts index 708b497..6c4c4ec 100644 --- a/apps/api/src/automation/process-ongoing-sequence.ts +++ b/apps/api/src/automation/process-ongoing-sequence.ts @@ -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))) diff --git a/apps/api/src/automation/sending-health.ts b/apps/api/src/automation/sending-health.ts index 74812fc..6097475 100644 --- a/apps/api/src/automation/sending-health.ts +++ b/apps/api/src/automation/sending-health.ts @@ -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; } @@ -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`, @@ -161,8 +179,13 @@ export function sendingBlockedReason( } } -function blockedThreshold(details: Record): number | undefined { - return typeof details.threshold === "number" ? details.threshold : undefined; +/** `sendingBlockedReason` for a `sending_blocked` pause's details. */ +function blockedReason(details: Record) { + return sendingBlockedReason( + String(details.error ?? ""), + typeof details.threshold === "number" ? details.threshold : undefined, + typeof details.domain === "string" ? details.domain : undefined, + ); } export async function autoPauseSequence( @@ -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, diff --git a/apps/api/src/delivery-feedback/feedback-connection-queries.ts b/apps/api/src/delivery-feedback/feedback-connection-queries.ts index e4daf5e..58a602d 100644 --- a/apps/api/src/delivery-feedback/feedback-connection-queries.ts +++ b/apps/api/src/delivery-feedback/feedback-connection-queries.ts @@ -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 { +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 { await db .update(espFeedbackConnections) .set({ status: "healthy", - lastReceivedAt: new Date(), + ...(event ? { lastReceivedAt: new Date() } : {}), lastVerifiedAt: new Date(), lastErrorCode: null, updatedAt: new Date(), diff --git a/apps/api/src/delivery-feedback/feedback-requirement.test.ts b/apps/api/src/delivery-feedback/feedback-requirement.test.ts index 31c44bb..abef778 100644 --- a/apps/api/src/delivery-feedback/feedback-requirement.test.ts +++ b/apps/api/src/delivery-feedback/feedback-requirement.test.ts @@ -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`; @@ -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); @@ -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, @@ -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(); diff --git a/apps/api/src/delivery-feedback/feedback-requirement.ts b/apps/api/src/delivery-feedback/feedback-requirement.ts index 5874110..64957b2 100644 --- a/apps/api/src/delivery-feedback/feedback-requirement.ts +++ b/apps/api/src/delivery-feedback/feedback-requirement.ts @@ -1,17 +1,25 @@ -import { and, eq, inArray, lt } from "drizzle-orm"; +import { and, eq, inArray, lt, or } from "drizzle-orm"; import { feedbackRequirementApplies, + normalizeSendingDomain, organizationAcceptedSends, testVolumeThreshold, } from "../billing/domains"; import { notifyOrganizationOwners } from "../billing/notifications"; import { db } from "../db/client"; -import { espConfigs, espFeedbackConnections, organizations, teams } from "../db/schema"; +import { + espConfigs, + espFeedbackConnections, + organizations, + sendingDomains, + teams, +} from "../db/schema"; import { captureError, captureEvent } from "../observability"; import logger from "../services/log"; /** Warnings, in percent of the cloud test volume, emailed to the owners - * while an ESP still has no feedback connection. */ + * while an ESP still has no feedback connection or a From domain isn't + * verified. */ const NOTICE_PERCENTS = [50, 90, 100] as const; export type FeedbackRequirement = { @@ -47,37 +55,89 @@ export async function getFeedbackRequirementForTeam( return team ? getFeedbackRequirement(team.organizationId) : null; } -/** The organization's active team ESPs without a healthy feedback - * connection: the ones `assertSendingEligibility` stops past the test - * volume. */ -async function espsWithoutFeedback(organizationId: string) { - const esps = await db - .select({ id: espConfigs.id, teamId: espConfigs.teamId, name: espConfigs.name }) - .from(espConfigs) - .innerJoin(teams, eq(teams.id, espConfigs.teamId)) - .where( - and(eq(teams.organizationId, organizationId), eq(espConfigs.status, "active")), - ); - if (esps.length === 0) return []; - const healthy = await db +/** What `assertSendingEligibility` will stop past the test volume: the + * organization's active team ESPs without a healthy feedback connection, and + * the From domains of its active ESPs (team and organization-owned) that it + * hasn't verified. */ +async function unmetSendingRequirements(organizationId: string) { + const all = await db .select({ - teamId: espFeedbackConnections.teamId, - espConfigId: espFeedbackConnections.espConfigId, + id: espConfigs.id, + teamId: espConfigs.teamId, + ownerScope: espConfigs.ownerScope, + name: espConfigs.name, + fromEmail: espConfigs.fromEmail, }) - .from(espFeedbackConnections) + .from(espConfigs) + .leftJoin(teams, eq(teams.id, espConfigs.teamId)) .where( and( - inArray( - espFeedbackConnections.espConfigId, - esps.map((esp) => esp.id), + eq(espConfigs.status, "active"), + or( + eq(teams.organizationId, organizationId), + and( + eq(espConfigs.ownerScope, "organization"), + eq(espConfigs.organizationId, organizationId), + ), ), - eq(espFeedbackConnections.status, "healthy"), ), ); - return esps.filter( - (esp) => - !healthy.some((row) => row.espConfigId === esp.id && row.teamId === esp.teamId), - ); + if (all.length === 0) return { esps: [], domains: [] }; + // Feedback for a shared organization ESP is checked per sending team, so + // only team ESPs are listed for feedback here. + const esps = all.filter((esp) => esp.ownerScope === "team"); + const [healthy, verified] = await Promise.all([ + db + .select({ + teamId: espFeedbackConnections.teamId, + espConfigId: espFeedbackConnections.espConfigId, + }) + .from(espFeedbackConnections) + .where( + and( + inArray( + espFeedbackConnections.espConfigId, + all.map((esp) => esp.id), + ), + eq(espFeedbackConnections.status, "healthy"), + ), + ), + db + .select({ domain: sendingDomains.domain }) + .from(sendingDomains) + .where( + and( + eq(sendingDomains.organizationId, organizationId), + eq(sendingDomains.status, "verified"), + ), + ), + ]); + const verifiedDomains = new Set(verified.map((row) => row.domain)); + const domains = new Set(); + for (const esp of all) { + const domain = fromDomain(esp.fromEmail); + if (domain && !verifiedDomains.has(domain)) domains.add(domain); + } + return { + esps: esps + .filter( + (esp) => + !healthy.some( + (row) => row.espConfigId === esp.id && row.teamId === esp.teamId, + ), + ) + .map((esp) => esp.name), + domains: [...domains], + }; +} + +function fromDomain(fromEmail: string | null): string | null { + if (!fromEmail) return null; + try { + return normalizeSendingDomain(fromEmail.slice(fromEmail.lastIndexOf("@") + 1)); + } catch { + return null; + } } function noticeText( @@ -85,27 +145,37 @@ function noticeText( organizationName: string, sent: number, threshold: number, - espNames: string[], + unmet: { esps: string[]; domains: string[] }, ): { subject: string; text: string } { - const esps = espNames.map((name) => `"${name}"`).join(", "); - const how = - "Connect bounce and complaint feedback for it in Settings → Email service providers (ESP), using the feedback button next to the provider."; + const quoted = (names: string[]) => names.map((name) => `"${name}"`).join(", "); + const todo = [ + ...(unmet.esps.length + ? [ + `Connect bounce and complaint feedback for ${quoted(unmet.esps)} in Settings → Email service providers (ESP), using the feedback button next to each provider.`, + ] + : []), + ...(unmet.domains.length + ? [`Verify ${unmet.domains.join(", ")} in Organizations → Sending domains.`] + : []), + ].join(" "); + const rule = + "SendLit only sends through email providers that report bounces and spam complaints back, and from From domains your organization has verified, so it can protect your sender reputation."; if (percent === 100) { return { - subject: `Sending stopped for ${organizationName}: connect bounce and complaint feedback`, - text: `${organizationName} has sent ${sent} emails, so SendLit now needs your email provider to report bounces and spam complaints back before it sends more. Sending through ${esps} is stopped until it does; running sequences and broadcasts are paused and keep their queued emails. ${how} Then resume them.`, + subject: `Sending stopped for ${organizationName}: finish setting up sending`, + text: `${organizationName} has sent ${sent} emails. From here, ${rule} Sending is stopped until that's done; running sequences and broadcasts are paused and keep their queued emails. ${todo} Then resume them.`, }; } return { - subject: `${organizationName} has sent ${sent} of ${threshold} emails: connect bounce and complaint feedback`, - text: `${organizationName} has sent ${sent} of its first ${threshold} emails. After ${threshold}, SendLit only sends through an email provider that reports bounces and spam complaints back, so it can protect your sender reputation, and ${esps} doesn't yet. ${how} Without it, sending through it stops after ${threshold} emails.`, + subject: `${organizationName} has sent ${sent} of ${threshold} emails: finish setting up sending`, + text: `${organizationName} has sent ${sent} of its first ${threshold} emails. After ${threshold}, ${rule} Still to do: ${todo} Without this, sending stops after ${threshold} emails.`, }; } /** * Emails the organization's owners when it reaches 50%, 90% and 100% of the - * cloud test volume while one of its ESPs has no feedback connection, so - * they can connect it before sending stops. Each warning goes out once; it + * cloud test volume while one of its ESPs has no feedback connection or a + * From domain isn't verified, so they can finish before sending stops. Each warning goes out once; it * runs after every accepted send, and costs nothing once all three are past. * Never throws: the email it follows has already gone out. */ @@ -141,15 +211,9 @@ export async function notifyFeedbackMilestones(teamId: string): Promise { ) .returning({ id: organizations.id }); if (!claimed) return; - const missing = await espsWithoutFeedback(org.id); - if (missing.length === 0) return; - const { subject, text } = noticeText( - reached, - org.name, - sent, - threshold, - missing.map((esp) => esp.name), - ); + const unmet = await unmetSendingRequirements(org.id); + if (unmet.esps.length === 0 && unmet.domains.length === 0) return; + const { subject, text } = noticeText(reached, org.name, sent, threshold, unmet); try { await notifyOrganizationOwners(org.id, subject, text); } catch (error) { @@ -173,7 +237,11 @@ export async function notifyFeedbackMilestones(teamId: string): Promise { event: "feedback_connection_reminder_sent", source: "delivery_feedback.requirement", teamId, - properties: { notice_percent: reached, esp_count: missing.length }, + properties: { + notice_percent: reached, + esp_count: unmet.esps.length, + domain_count: unmet.domains.length, + }, }); } catch (error) { captureError({ diff --git a/apps/api/src/delivery-feedback/process-receipt.ts b/apps/api/src/delivery-feedback/process-receipt.ts index a06b3c6..dec4e0c 100644 --- a/apps/api/src/delivery-feedback/process-receipt.ts +++ b/apps/api/src/delivery-feedback/process-receipt.ts @@ -108,7 +108,7 @@ export async function processWebhookReceipt(receiptId: string): Promise { }); // A fully-processed authenticated receipt is the PRD's definition // of a healthy connection — not just the manual "test" action. - await recordFeedbackConnectionVerified(receipt.connectionId); + await recordFeedbackConnectionVerified(receipt.connectionId, { event: true }); } } diff --git a/apps/api/src/delivery-feedback/webhook-and-connection.test.ts b/apps/api/src/delivery-feedback/webhook-and-connection.test.ts index 10137c1..171d5d5 100644 --- a/apps/api/src/delivery-feedback/webhook-and-connection.test.ts +++ b/apps/api/src/delivery-feedback/webhook-and-connection.test.ts @@ -15,6 +15,7 @@ const { feedbackConnectionReadinessError, getActiveFeedbackConnectionForEspConfig, getFeedbackConnectionByConnectionId, + recordFeedbackConnectionVerified, recordFeedbackControlOutcome, retireActiveFeedbackConnection, upsertFeedbackConnection, @@ -286,6 +287,36 @@ describe("SES feedback connections", () => { expect(moved.subscriptionConfirmedAt).toBeNull(); }); + it("goes healthy on a manual test without counting it as an event", async () => { + const { team } = await seedTeamAndContact(tdb); + const [esp] = await tdb + .select() + .from(espConfigs) + .where(eq(espConfigs.teamId, team.id)); + const created = await upsertFeedbackConnection({ + teamId: team.id, + espConfigId: esp.id, + provider: "ses", + expectedTopicArn: topicArn, + }); + + await recordFeedbackConnectionVerified(created.id); + const tested = await getFeedbackConnectionByConnectionId( + created.connectionId, + "ses", + ); + expect(tested?.status).toBe("healthy"); + expect(tested?.lastVerifiedAt).toBeTruthy(); + expect(tested?.lastReceivedAt).toBeNull(); + + await recordFeedbackConnectionVerified(created.id, { event: true }); + const received = await getFeedbackConnectionByConnectionId( + created.connectionId, + "ses", + ); + expect(received?.lastReceivedAt).toBeTruthy(); + }); + it("flags an unsubscribed connection", async () => { const { team } = await seedTeamAndContact(tdb); const [esp] = await tdb diff --git a/apps/api/src/observability/instance.ts b/apps/api/src/observability/instance.ts index 71acf3e..83d4d00 100644 --- a/apps/api/src/observability/instance.ts +++ b/apps/api/src/observability/instance.ts @@ -69,6 +69,7 @@ const PROPERTY_ALLOWLIST = new Set([ // Feedback connection reminders (delivery-feedback/feedback-requirement.ts). "notice_percent", "esp_count", + "domain_count", // Custom events (docs/custom_events.md#observability). Event names and // properties are customer-defined and never sent. "duplicate", diff --git a/apps/web/components/dashboard/auto-pause-banner.tsx b/apps/web/components/dashboard/auto-pause-banner.tsx index 94a0a9c..8ee4792 100644 --- a/apps/web/components/dashboard/auto-pause-banner.tsx +++ b/apps/web/components/dashboard/auto-pause-banner.tsx @@ -27,8 +27,14 @@ function renderErrorReason(code: string): string { function sendingBlockedReason( code: string, threshold?: number, + 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: `Your email provider isn't reporting bounces and spam complaints back to SendLit, which is required after your organization's first ${threshold ?? "few"} emails.`, @@ -70,13 +76,14 @@ export function AutoPauseBanner({ : pause.reason === "render_error" ? renderErrorReason(pause.error ?? "") : pause.reason === "sending_blocked" - ? sendingBlockedReason(pause.error ?? "", pause.threshold).why + ? sendingBlockedReason(pause.error ?? "", pause.threshold, pause.domain) + .why : `Your email provider refused to send: ${pause.error ?? "unknown error"}.`; const next = pause.reason === "render_error" ? "Check your mailing address in Settings and the email's content" : pause.reason === "sending_blocked" - ? sendingBlockedReason(pause.error ?? "", pause.threshold).next + ? sendingBlockedReason(pause.error ?? "", pause.threshold, pause.domain).next : "Clean your list or fix your email provider"; return ( diff --git a/apps/web/components/dashboard/esp-feedback-dialog.tsx b/apps/web/components/dashboard/esp-feedback-dialog.tsx index 1342483..34d7ed8 100644 --- a/apps/web/components/dashboard/esp-feedback-dialog.tsx +++ b/apps/web/components/dashboard/esp-feedback-dialog.tsx @@ -200,6 +200,14 @@ export function EspFeedbackDialog({ ? await testOrganizationEspFeedback(organizationId, esp.espId) : await testEspFeedback(esp.espId); setTestResult(result); + // A passing test makes the connection healthy: show its new status. + if (result.success) { + setConnection( + await (organizationId + ? getOrganizationEspFeedback(organizationId, esp.espId) + : getEspFeedback(esp.espId)), + ); + } } catch (err) { setTestResult({ success: false, @@ -318,6 +326,11 @@ export function EspFeedbackDialog({ Last event: {new Date(connection.lastReceivedAt).toLocaleString()} )} + {!connection.lastReceivedAt && connection.lastVerifiedAt && ( + + Tested: {new Date(connection.lastVerifiedAt).toLocaleString()} + + )} {connection.lastErrorCode && ( Last error: {connection.lastErrorCode} diff --git a/apps/web/components/dashboard/feedback-requirement-notice.tsx b/apps/web/components/dashboard/feedback-requirement-notice.tsx index 4ec85be..6fed791 100644 --- a/apps/web/components/dashboard/feedback-requirement-notice.tsx +++ b/apps/web/components/dashboard/feedback-requirement-notice.tsx @@ -1,9 +1,9 @@ import { Banner } from "@/components/dashboard/banner"; import type { FeedbackRequirement } from "@/lib/api"; -/** Explains SendLit Cloud's rule that each ESP needs bounce and complaint - * feedback once the organization has sent its first emails, with how close - * it is. Owners are also emailed at 50%, 90% and 100% (see the API's +/** Explains SendLit Cloud's rule that, once the organization has sent its + * first emails, each ESP needs bounce and complaint feedback and a verified + * From domain, with how close it is. Owners are also emailed at 50%, 90% and 100% (see the API's * delivery-feedback/feedback-requirement.ts). Renders nothing when the rule * doesn't apply. */ export function FeedbackRequirementNotice({ @@ -28,11 +28,12 @@ export function FeedbackRequirementNotice({ className={className} > After your organization's first {limit} emails, SendLit only sends through - providers that report bounces and spam complaints back, to protect your sender - reputation. {progress}{" "} + providers that report bounces and spam complaints back, and from From domains your + organization has verified, to protect your sender reputation. {progress}{" "} {newEsp - ? "Once you've saved this ESP, set up its delivery feedback from the ESP list." - : "Set up delivery feedback for each ESP with its feedback button below."} + ? "Once you've saved this ESP, set up its delivery feedback from the ESP list" + : "Set up delivery feedback for each ESP with its feedback button below"} + , and verify its From domain in Organizations → Sending domains. ); } diff --git a/packages/email-blocks/src/types.ts b/packages/email-blocks/src/types.ts index 0719130..1601079 100644 --- a/packages/email-blocks/src/types.ts +++ b/packages/email-blocks/src/types.ts @@ -157,6 +157,9 @@ export interface SequenceReport { /** For `sending_blocked`: the emails an organization could send before * its ESPs needed delivery feedback. */ threshold?: number; + /** For `sending_blocked` by `domain_verification_required`: the + * unverified From domain. */ + domain?: string; }; }