diff --git a/app/(marketing)/recent/outreach-form.tsx b/app/(marketing)/recent/outreach-form.tsx
index c1475b02..4c2c70d0 100644
--- a/app/(marketing)/recent/outreach-form.tsx
+++ b/app/(marketing)/recent/outreach-form.tsx
@@ -1,8 +1,12 @@
"use client";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import { FormEvent, useMemo, useState, useTransition } from "react";
-import { sendRecentAuditOutreach } from "@/app/actions/recent-outreach";
+import {
+ retryRecentOutreach,
+ sendRecentAuditOutreach,
+} from "@/app/actions/recent-outreach";
import { OUTREACH_CREDITS } from "@/lib/credits";
export type OutreachHistoryItem = {
@@ -125,6 +129,9 @@ export function RecentOutreachForm({
view post ↗
)}
+ {(h.status === "failed" || h.status === "timed_out") && (
+
+ )}
{(h.status === "failed" || h.status === "timed_out") && h.error && (
{h.error}
@@ -279,6 +286,40 @@ export function RecentOutreachForm({
);
}
+function RetryOutreachButton({ messageId }: { messageId: string }) {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+ const [err, setErr] = useState(null);
+
+ return (
+
+
+ {err && (
+
+ {err}
+
+ )}
+
+ );
+}
+
function defaultBody(host: string) {
return `I saw your CrawlProof audit for ${host}. There are a few concrete fixes that would improve how AI engines understand the site.`;
}
diff --git a/app/actions/recent-outreach.ts b/app/actions/recent-outreach.ts
index 30b46977..67201438 100644
--- a/app/actions/recent-outreach.ts
+++ b/app/actions/recent-outreach.ts
@@ -236,6 +236,70 @@ export async function sendRecentAuditOutreach(input: {
return { ok: true, provider: result.provider };
}
+// Re-attempt a previously failed (or timed-out) outreach message. We don't
+// mutate the old row — we reconstruct the original request from it and run it
+// back through sendRecentAuditOutreach, which re-charges credits, re-records a
+// fresh history entry, and refunds on failure exactly like a first attempt.
+// Retrying one row at a time also avoids the parallel browser-launch storm that
+// makes bulk "post to all" runs fail on the Chromium-based platforms.
+export async function retryRecentOutreach(input: {
+ messageId: string;
+}): Promise {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Not authenticated." };
+
+ const svc = serviceClient();
+ const { data: msg } = await svc
+ .from("recent_outreach_messages")
+ .select(
+ "id, organization_id, audit_id, channel, provider, subject, body, status, visibility",
+ )
+ .eq("id", input.messageId)
+ .maybeSingle();
+ if (!msg) return { ok: false, error: "Outreach message not found." };
+
+ const access = await orgOwner(svc, msg.organization_id, user.id);
+ if (!access.ok) return { ok: false, error: access.error };
+
+ if (msg.status !== "failed" && msg.status !== "timed_out") {
+ return { ok: false, error: "Only failed outreach can be retried." };
+ }
+
+ const channel = msg.channel as OutreachChannel;
+
+ // Social rows only record the platform (provider), not the specific account,
+ // so resolve the user's active account(s) on that platform to retry against.
+ let socialAccountIds: string[] = [];
+ if (channel === "social") {
+ const { data: accts } = await svc
+ .from("sp_account")
+ .select("id")
+ .eq("user_id", user.id)
+ .eq("status", "active")
+ .eq("platform", msg.provider);
+ socialAccountIds = ((accts ?? []) as Array<{ id: string }>).map((a) => a.id);
+ if (socialAccountIds.length === 0) {
+ return {
+ ok: false,
+ error: `No active ${msg.provider} account to retry with — reconnect it first.`,
+ };
+ }
+ }
+
+ return sendRecentAuditOutreach({
+ auditId: msg.audit_id,
+ organizationId: msg.organization_id,
+ channel,
+ visibility: (msg.visibility as OutreachVisibility) ?? "private",
+ socialAccountIds,
+ subject: msg.subject ?? undefined,
+ body: msg.body,
+ });
+}
+
async function loadOutreachConfig(
svc: ReturnType,
organizationId: string,