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
18 changes: 11 additions & 7 deletions app/(app)/projects/[id]/leads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="space-y-6">
Expand All @@ -104,12 +113,7 @@ export default async function LeadsPage({
</p>
</div>

{!canSendLive && (
<p className="card border-[var(--color-warn,#facc15)] p-3 text-sm">
<strong>Live sending is off.</strong> Set <code>OUTREACH_POSTAL_ADDRESS</code> — CAN-SPAM
requires a physical postal address in commercial email. Dry runs work without it.
</p>
)}
<SenderAddress projectId={projectId} settings={addressSettings} />

<LeadFinder projectId={projectId} />

Expand Down
119 changes: 108 additions & 11 deletions app/actions/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<Ok<{ note: string }> | 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<Ok<{ note: string; address: string }> | 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;
Expand All @@ -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()
Expand Down Expand Up @@ -322,11 +419,11 @@ export async function toggleCampaignAction(input: {
}): Promise<Ok<{ note: string }> | 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")
Expand Down
173 changes: 173 additions & 0 deletions components/leads/sender-address.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<section className={`card p-4 ${settings.address ? "" : "border-[var(--color-warn,#facc15)]"}`}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h2 className="text-lg font-semibold">Sender address</h2>
{settings.address ? (
<p className="mt-1 text-sm text-[var(--color-muted)]">
Live sending is on. Footer address comes from{" "}
<strong>{SOURCE_LABEL[settings.source]}</strong>: {settings.address}
</p>
) : (
<p className="mt-1 text-sm">
<strong>Live sending is off.</strong> Cold email legally needs a physical postal
address in the footer (CAN-SPAM). Add one below — dry runs work without it.
</p>
)}
</div>
<button onClick={() => setOpen(!open)} className="btn text-sm">
{open ? "Close" : settings.address ? "Change" : "Add address"}
</button>
</div>

{note && <p className="mt-3 text-sm">{note}</p>}
{error && <p className="mt-3 text-sm text-[var(--color-danger,#f87171)]">{error}</p>}

{open && (
<div className="mt-4 grid gap-4 border-t border-[var(--color-border)] pt-4">
<div>
<label className="text-sm font-medium" htmlFor="account-address">
Your account address
</label>
<p className="text-xs text-[var(--color-muted)]">
Set this once. Every project can pull it in with one click.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<input
id="account-address"
className="input min-w-[18rem] flex-1"
value={accountValue}
onChange={(e) => setAccountValue(e.target.value)}
placeholder="Profullstack, Inc., 123 Main St, Austin TX 78701"
/>
<button
onClick={() =>
run(async () =>
savePostalAddressAction({ projectId, scope: "account", address: accountValue }),
)
}
disabled={pending}
className="btn"
>
Save to account
</button>
</div>
</div>

<div>
<label className="text-sm font-medium" htmlFor="project-address">
This project&apos;s address <span className="font-normal text-[var(--color-muted)]">(optional)</span>
</label>
<p className="text-xs text-[var(--color-muted)]">
Overrides the account address — use it when you send on a client&apos;s behalf.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<input
id="project-address"
className="input min-w-[18rem] flex-1"
value={projectValue}
onChange={(e) => setProjectValue(e.target.value)}
placeholder="Leave empty to use the account address"
/>
<button
onClick={importFromAccount}
disabled={pending || !settings.levels.account}
className="btn"
title={
settings.levels.account
? "Copy your account address into this project"
: "Save an account address first"
}
>
Import from account
</button>
<button
onClick={() =>
run(async () =>
savePostalAddressAction({ projectId, scope: "project", address: projectValue }),
)
}
disabled={pending}
className="btn btn-primary"
>
Save
</button>
</div>
</div>

{settings.hasOrg && (
<p className="text-xs text-[var(--color-muted)]">
Org-wide address:{" "}
{settings.levels.organization ?? "not set"} — sits between your account and this
project. Only the org owner can change it.
</p>
)}
</div>
)}
</section>
);
}
8 changes: 5 additions & 3 deletions lib/mcp/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
? [
Expand All @@ -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.",
Expand Down
Loading
Loading