+ 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");
+ });
+});