diff --git a/app/(app)/projects/[id]/leads/page.tsx b/app/(app)/projects/[id]/leads/page.tsx index a123cb08..000ce683 100644 --- a/app/(app)/projects/[id]/leads/page.tsx +++ b/app/(app)/projects/[id]/leads/page.tsx @@ -6,6 +6,8 @@ import { env } from "@/lib/env"; import { LeadFinder } from "@/components/leads/lead-finder"; import { LeadActions } from "@/components/leads/lead-actions"; import { CampaignPanel, type CampaignSummary } from "@/components/leads/campaign-panel"; +import { SenderAddress } from "@/components/leads/sender-address"; +import { loadAddressSettings } from "@/lib/outreach/postalAddress"; export const metadata = { title: "Leads" }; export const dynamic = "force-dynamic"; @@ -92,7 +94,14 @@ export default async function LeadsPage({ const since = Date.now() - 24 * 3600 * 1000; const liveToday = sends.filter((s) => !s.dry_run && new Date(s.sent_at).getTime() >= since).length; - const canSendLive = Boolean(env.outreachPostalAddress); + + // Live sending is gated on having a CAN-SPAM footer address, resolved + // project → org → account → env. + const addressSettings = await loadAddressSettings({ + projectId, + ownerId: access.userId, + }); + const canSendLive = Boolean(addressSettings.address); return (
@@ -104,12 +113,7 @@ export default async function LeadsPage({

- {!canSendLive && ( -

- Live sending is off. Set OUTREACH_POSTAL_ADDRESS — CAN-SPAM - requires a physical postal address in commercial email. Dry runs work without it. -

- )} + diff --git a/app/actions/leads.ts b/app/actions/leads.ts index 46b74a63..c607cb9e 100644 --- a/app/actions/leads.ts +++ b/app/actions/leads.ts @@ -13,6 +13,7 @@ import { type ProspectRow, } from "@/lib/outreach/pipeline"; import { addSuppression } from "@/lib/outreach/suppress"; +import { loadAddressSettings } from "@/lib/outreach/postalAddress"; import { discoverProspects } from "@/lib/outreach/discover"; import { runEmailCampaignTick, CAMPAIGN_COLUMNS, summarize, type CampaignRow } from "@/lib/outreach/runner"; @@ -234,6 +235,99 @@ export async function suppressLeadAction(input: { return { ok: true, note: `${input.value} will not be contacted again (${scope}).` }; } +/** + * Save the CAN-SPAM postal address at one of the three levels. + * + * `scope: "account"` is the global one — set it once and every project can + * pull it in with a click. Only the org owner may write the org-level + * address, since it signs mail for everybody in it. + */ +export async function savePostalAddressAction(input: { + projectId: string; + scope: "project" | "organization" | "account"; + address: string; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + + // Empty clears the level, which is how you fall back to a broader one. + const address = input.address.trim() || null; + const sb = serviceClient(); + + if (input.scope === "account") { + const { error } = await sb + .from("profiles") + .update({ outreach_postal_address: address }) + .eq("id", auth.userId); + if (error) return { ok: false, error: error.message }; + } else if (input.scope === "organization") { + const { data: project } = await sb + .from("projects") + .select("organization_id") + .eq("id", input.projectId) + .maybeSingle(); + const orgId = (project?.organization_id as string | null) ?? null; + if (!orgId) return { ok: false, error: "This project isn't in an organization." }; + const { data: org } = await sb + .from("organizations") + .select("owner_id") + .eq("id", orgId) + .maybeSingle(); + if ((org?.owner_id as string | null) !== auth.userId) { + return { ok: false, error: "Only the organization owner can set the org-wide address." }; + } + const { error } = await sb + .from("organizations") + .update({ outreach_postal_address: address }) + .eq("id", orgId); + if (error) return { ok: false, error: error.message }; + } else { + const { error } = await sb + .from("projects") + .update({ outreach_postal_address: address }) + .eq("id", input.projectId); + if (error) return { ok: false, error: error.message }; + } + + revalidatePath(leadsPath(input.projectId)); + const where = + input.scope === "account" ? "your account" : input.scope === "organization" ? "the organization" : "this project"; + return { + ok: true, + note: address ? `Saved to ${where}.` : `Cleared the address on ${where}.`, + }; +} + +/** One-click "use my account address here" — copies down a level. */ +export async function importPostalAddressAction(input: { + projectId: string; + from: "account" | "organization"; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + + const settings = await loadAddressSettings({ projectId: input.projectId, ownerId: auth.userId }); + const source = input.from === "account" ? settings.levels.account : settings.levels.organization; + if (!source) { + return { + ok: false, + error: + input.from === "account" + ? "No address saved on your account yet — set one first." + : "No org-wide address saved yet.", + }; + } + + const { error } = await serviceClient() + .from("projects") + .update({ outreach_postal_address: source }) + .eq("id", input.projectId); + if (error) return { ok: false, error: error.message }; + + revalidatePath(leadsPath(input.projectId)); + return { ok: true, address: source, note: `Imported from ${input.from === "account" ? "your account" : "the organization"}.` }; +} + export async function saveCampaignAction(input: { projectId: string; name: string; @@ -256,12 +350,15 @@ export async function saveCampaignAction(input: { if (!queries.length && !seedUrls.length) { return { ok: false, error: "A campaign needs at least one search query or directory URL." }; } - if (input.autoSend && !env.outreachPostalAddress) { - return { - ok: false, - error: - "OUTREACH_POSTAL_ADDRESS is not set. CAN-SPAM requires a physical postal address in commercial email, so live sending stays off until it is.", - }; + if (input.autoSend) { + const postal = await loadAddressSettings({ projectId: input.projectId, ownerId: auth.userId }); + if (!postal.address) { + return { + ok: false, + error: + "No sender postal address is set. CAN-SPAM requires one in commercial email, so live sending stays off until you add it above.", + }; + } } const { error } = await serviceClient() @@ -322,11 +419,11 @@ export async function toggleCampaignAction(input: { }): Promise | Err> { const auth = await requireLeadAccess(input.projectId); if (!auth.ok) return auth; - if (input.field === "auto_send" && input.value && !env.outreachPostalAddress) { - return { - ok: false, - error: "OUTREACH_POSTAL_ADDRESS is not set — live sending is blocked until it is.", - }; + if (input.field === "auto_send" && input.value) { + const postal = await loadAddressSettings({ projectId: input.projectId, ownerId: auth.userId }); + if (!postal.address) { + return { ok: false, error: "No sender postal address is set — live sending is blocked until you add one." }; + } } const { error } = await serviceClient() .from("outreach_campaigns") diff --git a/components/leads/sender-address.tsx b/components/leads/sender-address.tsx new file mode 100644 index 00000000..92e1dd3f --- /dev/null +++ b/components/leads/sender-address.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + importPostalAddressAction, + savePostalAddressAction, +} from "@/app/actions/leads"; +import type { AddressSettings } from "@/lib/outreach/postalAddress"; + +const SOURCE_LABEL: Record = { + project: "this project", + organization: "your organization", + account: "your account", + env: "the server environment", + none: "nowhere", +}; + +/** + * The CAN-SPAM footer address, and the thing that gates live sending. + * + * Three levels — project, org, account — resolved most-specific-first. The + * account one is the "set it once" default; the import buttons copy it down + * so a new project is one click from being able to send, rather than a + * retyped address per client. + */ +export function SenderAddress({ + projectId, + settings, +}: { + projectId: string; + settings: AddressSettings; +}) { + const router = useRouter(); + const [pending, start] = useTransition(); + const [open, setOpen] = useState(!settings.address); + const [projectValue, setProjectValue] = useState(settings.levels.project ?? ""); + const [accountValue, setAccountValue] = useState(settings.levels.account ?? ""); + const [note, setNote] = useState(null); + const [error, setError] = useState(null); + + const run = (fn: () => Promise<{ ok: true; note: string } | { ok: false; error: string }>) => + start(async () => { + setNote(null); + setError(null); + const res = await fn(); + if (res.ok) { + setNote(res.note); + router.refresh(); + } else setError(res.error); + }); + + const importFromAccount = () => + start(async () => { + setNote(null); + setError(null); + const res = await importPostalAddressAction({ projectId, from: "account" }); + if (res.ok) { + setProjectValue(res.address); + setNote(res.note); + router.refresh(); + } else setError(res.error); + }); + + return ( +
+
+
+

Sender address

+ {settings.address ? ( +

+ Live sending is on. Footer address comes from{" "} + {SOURCE_LABEL[settings.source]}: {settings.address} +

+ ) : ( +

+ Live sending is off. Cold email legally needs a physical postal + address in the footer (CAN-SPAM). Add one below — dry runs work without it. +

+ )} +
+ +
+ + {note &&

{note}

} + {error &&

{error}

} + + {open && ( +
+
+ +

+ Set this once. Every project can pull it in with one click. +

+
+ setAccountValue(e.target.value)} + placeholder="Profullstack, Inc., 123 Main St, Austin TX 78701" + /> + +
+
+ +
+ +

+ Overrides the account address — use it when you send on a client's behalf. +

+
+ setProjectValue(e.target.value)} + placeholder="Leave empty to use the account address" + /> + + +
+
+ + {settings.hasOrg && ( +

+ Org-wide address:{" "} + {settings.levels.organization ?? "not set"} — sits between your account and this + project. Only the org owner can change it. +

+ )} +
+ )} +
+ ); +} diff --git a/lib/mcp/leads.ts b/lib/mcp/leads.ts index 0075ebdc..08e0e85e 100644 --- a/lib/mcp/leads.ts +++ b/lib/mcp/leads.ts @@ -43,6 +43,7 @@ import { siteBase, } from "@/lib/outreach/pipeline"; import { addSuppression, isEmailSuppressed, sendsInLast24h } from "@/lib/outreach/suppress"; +import { describeAddressSource, resolvePostalAddress } from "@/lib/outreach/postalAddress"; import { discoverProspects } from "@/lib/outreach/discover"; import { enrichContact, findEmail, leadsToCsv, leadsToJson, type ExportableLead } from "@/lib/outreach/enrich"; import { CAMPAIGN_COLUMNS, runEmailCampaignTick, summarize, type CampaignRow } from "@/lib/outreach/runner"; @@ -580,6 +581,7 @@ export function registerLeadTools(server: McpServer): void { if (!outcome.ok) return errorResult(`Not sent — ${outcome.reason}.`); const unsubscribeUrl = `${siteBase()}/unsubscribe/${prospect.unsubscribe_token}`; + const postal = await resolvePostalAddress({ projectId: project.id, ownerId: userId }); return textResult( outcome.dryRun ? [ @@ -588,9 +590,9 @@ export function registerLeadTools(server: McpServer): void { `Subject: ${args.subject}`, `Step ${step}`, `Unsubscribe link: ${unsubscribeUrl}`, - env.outreachPostalAddress - ? `Postal address in footer: ${env.outreachPostalAddress}` - : "⚠ OUTREACH_POSTAL_ADDRESS unset — live sending will be refused until it is.", + postal.address + ? `Postal address in footer (from ${describeAddressSource(postal.source)}): ${postal.address}` + : "⚠ No sender postal address set — live sending will be refused. Add one on the Leads page or in Settings.", `Sends used today: ${outcome.sentToday}/${env.outreachDailyCap}`, "", "Pass dry_run: false to send it.", diff --git a/lib/outreach/pipeline.ts b/lib/outreach/pipeline.ts index 4ab57f8d..d04a2c5e 100644 --- a/lib/outreach/pipeline.ts +++ b/lib/outreach/pipeline.ts @@ -41,6 +41,7 @@ import { type ProspectFacts, } from "./cold"; import { isEmailSuppressed, marketingUnsubscribedAt, sendsInLast24h } from "./suppress"; +import { resolvePostalAddress } from "./postalAddress"; export type ProspectRow = { id: string; @@ -491,11 +492,19 @@ export async function sendProspectEmail(input: { const claims = unsupportedClaims(input.body, facts); if (claims.length) return { ok: false, reason: `unsupported claims: ${claims.join("; ")}` }; - if (!input.dryRun && !env.outreachPostalAddress) { + // CAN-SPAM requires a physical postal address in commercial email. It is + // resolved per project (project → org → account → env) rather than read + // from one global env var, so an agency signs each client's outreach with + // that client's address. + const postal = await resolvePostalAddress({ + projectId: input.prospect.project_id, + ownerId: input.userId, + }); + if (!input.dryRun && !postal.address) { return { ok: false, reason: - "OUTREACH_POSTAL_ADDRESS is unset — CAN-SPAM requires a physical postal address in commercial email", + "no sender postal address is set — CAN-SPAM requires one in commercial email. Add it on the Leads page or in Settings", }; } @@ -510,7 +519,7 @@ export async function sendProspectEmail(input: { bodyText: input.body, reportUrl: facts.reportUrl, unsubscribeUrl, - postalAddress: env.outreachPostalAddress, + postalAddress: postal.address ?? "", }), unsubscribeUrl, replyTo: input.replyTo ?? undefined, diff --git a/lib/outreach/postalAddress.ts b/lib/outreach/postalAddress.ts new file mode 100644 index 00000000..217fc94f --- /dev/null +++ b/lib/outreach/postalAddress.ts @@ -0,0 +1,145 @@ +// Which postal address goes in the CAN-SPAM footer. +// +// Resolved most-specific-first: the project's own address, then the org's, +// then the sender's personal default, then the legacy env var. An agency +// sending on behalf of three clients needs three different footers, which a +// single env var could never express. +// +// The pure part is separated from the lookup so the precedence — the bit +// that decides what a real recipient sees in a legally-required footer — is +// testable without a database. + +import { serviceClient } from "@/lib/supabase/service"; +import { env } from "@/lib/env"; + +export type AddressSource = "project" | "organization" | "account" | "env" | "none"; + +export type ResolvedAddress = { + address: string | null; + source: AddressSource; +}; + +export type AddressLevels = { + project?: string | null; + organization?: string | null; + account?: string | null; + env?: string | null; +}; + +/** Most specific wins. Whitespace-only is treated as unset, not as an address. */ +export function pickPostalAddress(levels: AddressLevels): ResolvedAddress { + const ordered: Array<[AddressSource, string | null | undefined]> = [ + ["project", levels.project], + ["organization", levels.organization], + ["account", levels.account], + ["env", levels.env], + ]; + for (const [source, value] of ordered) { + const trimmed = value?.trim(); + if (trimmed) return { address: trimmed, source }; + } + return { address: null, source: "none" }; +} + +export function describeAddressSource(source: AddressSource): string { + switch (source) { + case "project": + return "this project"; + case "organization": + return "your organization"; + case "account": + return "your account"; + case "env": + return "the OUTREACH_POSTAL_ADDRESS environment variable"; + case "none": + return "nowhere — no address is set"; + } +} + +/** + * Look up every level for a project and pick one. Called on the send path, + * so it must never throw: a missing row is a missing address, not an error. + */ +export async function resolvePostalAddress(input: { + projectId: string | null; + ownerId: string; +}): Promise { + const sb = serviceClient(); + + let projectAddress: string | null = null; + let orgAddress: string | null = null; + + if (input.projectId) { + const { data: project } = await sb + .from("projects") + .select("outreach_postal_address, organization_id") + .eq("id", input.projectId) + .maybeSingle(); + projectAddress = (project?.outreach_postal_address as string | null) ?? null; + + const orgId = (project?.organization_id as string | null) ?? null; + if (orgId) { + const { data: org } = await sb + .from("organizations") + .select("outreach_postal_address") + .eq("id", orgId) + .maybeSingle(); + orgAddress = (org?.outreach_postal_address as string | null) ?? null; + } + } + + const { data: profile } = await sb + .from("profiles") + .select("outreach_postal_address") + .eq("id", input.ownerId) + .maybeSingle(); + + return pickPostalAddress({ + project: projectAddress, + organization: orgAddress, + account: (profile?.outreach_postal_address as string | null) ?? null, + env: env.outreachPostalAddress, + }); +} + +/** + * What the Leads page shows: the effective address plus each level on its + * own, so the "import from account" button knows whether it has anything to + * import and the user can see which level is actually winning. + */ +export type AddressSettings = ResolvedAddress & { + levels: { project: string | null; organization: string | null; account: string | null }; + hasOrg: boolean; +}; + +export async function loadAddressSettings(input: { + projectId: string; + ownerId: string; +}): Promise { + const sb = serviceClient(); + const { data: project } = await sb + .from("projects") + .select("outreach_postal_address, organization_id") + .eq("id", input.projectId) + .maybeSingle(); + + const orgId = (project?.organization_id as string | null) ?? null; + const [{ data: org }, { data: profile }] = await Promise.all([ + orgId + ? sb.from("organizations").select("outreach_postal_address").eq("id", orgId).maybeSingle() + : Promise.resolve({ data: null }), + sb.from("profiles").select("outreach_postal_address").eq("id", input.ownerId).maybeSingle(), + ]); + + const levels = { + project: (project?.outreach_postal_address as string | null) ?? null, + organization: (org?.outreach_postal_address as string | null) ?? null, + account: (profile?.outreach_postal_address as string | null) ?? null, + }; + + return { + ...pickPostalAddress({ ...levels, env: env.outreachPostalAddress }), + levels, + hasOrg: Boolean(orgId), + }; +} diff --git a/supabase/migrations/20260726170000_outreach_sender_address.sql b/supabase/migrations/20260726170000_outreach_sender_address.sql new file mode 100644 index 00000000..44e01849 --- /dev/null +++ b/supabase/migrations/20260726170000_outreach_sender_address.sql @@ -0,0 +1,37 @@ +-- Sender postal address for cold outreach. +-- +-- CAN-SPAM §7704(a)(5) requires a valid physical postal address in every +-- commercial email. That was an env var (OUTREACH_POSTAL_ADDRESS), which is +-- wrong for two reasons: it takes a redeploy to change, and it forces one +-- address on every user of the instance — an agency sending on behalf of +-- three clients has three different addresses to put in the footer. +-- +-- Three levels, resolved most-specific-first at send time: +-- +-- project this project's outreach signs with its own address +-- organization everything the org sends, unless a project overrides +-- account the personal default, set once in Settings +-- +-- The env var stays as a last-resort fallback so existing deployments keep +-- working, but nothing needs it any more. + +alter table public.profiles + add column if not exists outreach_postal_address text; + +alter table public.organizations + add column if not exists outreach_postal_address text; + +alter table public.projects + add column if not exists outreach_postal_address text; + +comment on column public.profiles.outreach_postal_address is + 'Default physical postal address used in the CAN-SPAM footer of cold ' + 'outreach email. Overridden by the org and then the project.'; + +comment on column public.organizations.outreach_postal_address is + 'Org-wide postal address for cold outreach. Overrides the owner''s ' + 'personal default; overridden by a per-project address.'; + +comment on column public.projects.outreach_postal_address is + 'Per-project postal address for cold outreach. Most specific level — an ' + 'agency sending for several clients signs each with the right address.'; diff --git a/tests/cold-outreach.test.ts b/tests/cold-outreach.test.ts index 96c5be3a..a8517223 100644 --- a/tests/cold-outreach.test.ts +++ b/tests/cold-outreach.test.ts @@ -26,6 +26,7 @@ import { leadsToCsv, } from "@/lib/outreach/enrich"; import { isWeakEnough } from "@/lib/outreach/pipeline"; +import { describeAddressSource, pickPostalAddress } from "@/lib/outreach/postalAddress"; function facts(over: Partial = {}): ProspectFacts { return { @@ -327,3 +328,39 @@ describe("normalizeHost", () => { expect(normalizeHost("example.com")).toBe("example.com"); }); }); + +describe("postal address precedence", () => { + it("prefers the project address over every broader level", () => { + const picked = pickPostalAddress({ + project: "Client Co, 1 High St", + organization: "Agency Ltd, 2 Broad St", + account: "Me, 3 Home Rd", + env: "Env, 4 Server Ln", + }); + expect(picked).toEqual({ address: "Client Co, 1 High St", source: "project" }); + }); + + it("falls through org, then account, then env", () => { + expect(pickPostalAddress({ organization: "Agency", account: "Me", env: "Env" }).source).toBe( + "organization", + ); + expect(pickPostalAddress({ account: "Me", env: "Env" }).source).toBe("account"); + expect(pickPostalAddress({ env: "Env" }).source).toBe("env"); + }); + + it("treats a whitespace-only value as unset rather than as an address", () => { + // A footer containing " " satisfies a truthiness check and violates + // CAN-SPAM, so blank has to fall through to the next level. + const picked = pickPostalAddress({ project: " ", account: "Me, 3 Home Rd" }); + expect(picked).toEqual({ address: "Me, 3 Home Rd", source: "account" }); + }); + + it("reports none when nothing is set anywhere", () => { + expect(pickPostalAddress({})).toEqual({ address: null, source: "none" }); + expect(describeAddressSource("none")).toMatch(/no address/i); + }); + + it("trims the address it returns", () => { + expect(pickPostalAddress({ account: " Me, 3 Home Rd " }).address).toBe("Me, 3 Home Rd"); + }); +});