diff --git a/.env.example b/.env.example
index e54c2ec..e56fbf7 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,10 @@
# ProspectOS host port
HOST_PORT=8000
+# Public origin used in client-facing website concept links. Set this when
+# the app is behind a reverse proxy; otherwise the request Host header is used.
+PUBLIC_BASE_URL=
+
# Maps worker admin/UI port. Keep it private if the server is internet-facing.
GMAPS_HOST_PORT=8081
diff --git a/README.md b/README.md
index f19d0b4..26d11c9 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,16 @@ reviewable homepage text snapshot that is included in AI assessments and email
drafts. The **Promotions** view stores dated, active/inactive offers; an active
promotion can be selected in the email draft dialog and is passed to the AI
with its exact description and terms.
+From a prospect's detail drawer, **Generate modern mockup** creates a
+shareable, mobile-first website concept using the prospect's public details
+and (when available) a fresh snapshot of its current homepage. The preview is
+a standalone HTML document ready to place in a PHP/HTML hosting folder; it
+does not require PHP, JavaScript, forms, tracking, external fonts, or remote
+assets to render. If AI is configured it can produce a tailored design; when
+AI is unavailable the deterministic fallback still produces a polished
+preview. Each tokenised link is read-only, marked as a concept, sent with
+`noindex`, and can be included once in a human-reviewed email draft. It is not
+a production site and should be checked and edited before sending.
The **Logs** view separates API calls, user actions, AI requests, searches,
Browser Use/tool steps, and errors. AI request payloads and responses are
truncated and credentials such as API keys, authorization headers, tokens, and
@@ -79,6 +89,7 @@ error rather than remaining stuck forever.
| Variable | Default | Purpose |
|---|---:|---|
| `HOST_PORT` | `8000` | Host port for ProspectOS |
+| `PUBLIC_BASE_URL` | empty | Public origin used for client-facing mockup share links; set it behind a reverse proxy if forwarded host headers are unavailable |
| `GMAPS_HOST_PORT` | `8081` | Host port for the Maps worker admin UI |
| `GMAPS_API_MODE` | `standard` | `standard` for the bundled worker; `saas` only for the optional SaaS API |
| `GMAPS_API_KEY` | empty | Not required in standard mode; only used in SaaS mode |
@@ -212,6 +223,11 @@ stores a bounded, reviewable public homepage snapshot. Promotions are managed
with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and
`DELETE /api/promotions/{id}`. An active promotion selected in the draft dialog
is sent as `promotion_id` and retained on that prospect's draft.
+Mockups are created with `POST /api/prospects/{id}/mockup`, listed with
+`GET /api/prospects/{id}/mockups`, and opened by their public
+`/mockups/{token}` share URL. Set `PUBLIC_BASE_URL` when the reverse proxy does
+not forward the public host; otherwise the link is built from the request's
+forwarded host or `Host` header.
## Tests
diff --git a/api/server.py b/api/server.py
index 95fe658..7207090 100644
--- a/api/server.py
+++ b/api/server.py
@@ -24,11 +24,12 @@ import urllib.error
import urllib.request
from datetime import datetime, timezone
from html.parser import HTMLParser
+from html import escape as html_escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any, Callable
-from urllib.parse import parse_qs, quote_plus, unquote, urlparse
+from urllib.parse import parse_qs, quote, quote_plus, unquote, urlparse
try:
from intelligence import domain_candidates, scan_website, _safe_url
@@ -65,6 +66,7 @@ AUTH_COOKIE_NAME = "prospect_session"
AUTH_SESSION_TTL_SECONDS = max(3600, min(60 * 60 * 24 * 30, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(60 * 60 * 24 * 7)))))
AUTH_COOKIE_SECURE = os.environ.get("AUTH_COOKIE_SECURE", "false").strip().lower() not in {"0", "false", "no", "off"}
WORKSPACE_NAME = os.environ.get("WORKSPACE_NAME", "ProspectOS workspace").strip() or "ProspectOS workspace"
+PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").strip().rstrip("/")
BACKUP_DIR = os.environ.get("BACKUP_DIR", "").strip()
try:
BACKUP_RETENTION = max(1, min(100, int(os.environ.get("BACKUP_RETENTION", "14"))))
@@ -462,6 +464,7 @@ def db() -> sqlite3.Connection:
ai_unknowns TEXT NOT NULL DEFAULT '[]',
ai_evidence_ids TEXT NOT NULL DEFAULT '[]',
ai_follow_up TEXT NOT NULL DEFAULT '',
+ ai_mockup_id INTEGER,
opportunity_score INTEGER NOT NULL DEFAULT 0,
raw_json TEXT NOT NULL DEFAULT '{}'
)""")
@@ -598,6 +601,22 @@ def db() -> sqlite3.Connection:
model TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)""")
+ conn.execute("""CREATE TABLE IF NOT EXISTS mockups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ organization_id INTEGER NOT NULL DEFAULT 1,
+ prospect_id INTEGER NOT NULL,
+ created_by INTEGER,
+ token TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL DEFAULT '',
+ source_url TEXT NOT NULL DEFAULT '',
+ html TEXT NOT NULL,
+ ai_model TEXT NOT NULL DEFAULT '',
+ generation_source TEXT NOT NULL DEFAULT 'fallback',
+ instructions TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'published',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )""")
conn.execute("""CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id INTEGER NOT NULL DEFAULT 1,
@@ -666,6 +685,7 @@ def db() -> sqlite3.Connection:
"ai_evidence_ids": "TEXT NOT NULL DEFAULT '[]'",
"ai_follow_up": "TEXT NOT NULL DEFAULT ''",
"ai_promotion_id": "INTEGER",
+ "ai_mockup_id": "INTEGER",
"opportunity_score": "INTEGER NOT NULL DEFAULT 0",
"raw_json": "TEXT NOT NULL DEFAULT '{}'",
}
@@ -705,12 +725,13 @@ def db() -> sqlite3.Connection:
"ai_assessment_history": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
"audit_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
"run_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
+ "mockups": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
}.items():
existing_fields = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
for name, ddl in fields.items():
if name not in existing_fields:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
- for table in ("prospects", "runs", "suppressions", "schedules", "website_candidates", "logs", "promotions", "prospect_notes", "prospect_tasks", "score_history", "ai_assessment_history", "audit_events", "run_events"):
+ for table in ("prospects", "runs", "suppressions", "schedules", "website_candidates", "logs", "promotions", "prospect_notes", "prospect_tasks", "score_history", "ai_assessment_history", "audit_events", "run_events", "mockups"):
conn.execute(f"UPDATE {table} SET organization_id=1 WHERE organization_id IS NULL OR organization_id=0")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_review ON prospects(review_status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_score ON prospects(opportunity_score DESC)")
@@ -729,6 +750,8 @@ def db() -> sqlite3.Connection:
conn.execute("CREATE INDEX IF NOT EXISTS idx_ai_history_prospect ON ai_assessment_history(organization_id, prospect_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_org_created ON audit_events(organization_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(organization_id, run_id, id DESC)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_prospect ON mockups(organization_id, prospect_id, id DESC)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_token ON mockups(token)")
conn.commit()
_schema_path = DB_PATH
_schema_lock.release()
@@ -1921,7 +1944,7 @@ def scheduler_loop() -> None:
time.sleep(30)
-def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation", promotion: dict[str, Any] | None = None) -> dict[str, Any]:
+def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation", promotion: dict[str, Any] | None = None, mockup: dict[str, Any] | None = None) -> dict[str, Any]:
"""Generate an evidence-grounded, human-editable outreach draft.
``tone`` and ``goal`` are deliberately small, validated controls rather
@@ -1945,6 +1968,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
evidence_ids.append("public_email_listing")
if prospect.get("phone"):
evidence_ids.append("public_phone_listing")
+ if mockup and mockup.get("share_url"):
+ evidence_ids.append("mockup_preview")
tone_options = {"warm": "warm and approachable", "direct": "direct and concise", "consultative": "consultative and helpful"}
goal_options = {"conversation": "start a low-pressure conversation", "review": "offer a short website/hosting review", "quote": "invite them to request pricing or a quote"}
tone_key = str(tone or "warm").strip().lower()
@@ -1990,6 +2015,10 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
"name": promotion.get("name"), "description": promotion.get("description"),
"terms": promotion.get("terms"), "starts_at": promotion.get("starts_at"), "ends_at": promotion.get("ends_at"),
} if promotion else None,
+ "mockup_preview": {
+ "title": mockup.get("title"), "url": mockup.get("share_url"),
+ "source": mockup.get("generation_source"),
+ } if mockup and mockup.get("share_url") else None,
}
system = (
"You are an expert B2B copywriter for a small South African web-hosting and website-design business. "
@@ -2012,7 +2041,9 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
"unknowns, evidence_used, follow_up. subject and message are plain text. rationale is concise. "
"recommended_offer is a short summary of the user's offer. unknowns is an array of facts to confirm. "
"evidence_used is an array containing only supplied evidence IDs. follow_up is a short human review note, "
- "not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule."
+ "not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule. "
+ "If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. Use the exact URL, "
+ "call it a concept/preview rather than a live or finished site, and never imply that the prospect approved it."
)
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000], "tone": tone_key, "goal": goal_key}, ensure_ascii=False)
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
@@ -2037,14 +2068,23 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
return []
return [str(item).strip()[:size] for item in value if str(item).strip()][:limit]
used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids]
+ message_text = str(parsed["message"]).strip()[:8000]
+ mockup_url = str(mockup.get("share_url") or "").strip() if mockup else ""
+ # The link is an explicit part of the requested workflow. If the model
+ # omits it despite receiving the evidence, append a clear editable line.
+ if mockup_url and mockup_url not in message_text:
+ message_text = (message_text.rstrip() + "\n\nI also prepared a concept preview for you: " + mockup_url).strip()[:8000]
+ if mockup_url and "mockup_preview" not in used:
+ used.append("mockup_preview")
return {
"subject": str(parsed["subject"]).strip()[:200],
- "message": str(parsed["message"]).strip()[:8000],
+ "message": message_text,
"rationale": str(parsed.get("rationale") or "").strip()[:2000],
"recommended_offer": str(parsed.get("recommended_offer") or offer).strip()[:2000],
"unknowns": text_list(parsed.get("unknowns")),
"evidence_used": used,
"follow_up": str(parsed.get("follow_up") or "Review the draft and confirm the offer before sending.").strip()[:1000],
+ "mockup_url": mockup_url,
}
@@ -2131,6 +2171,101 @@ def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]:
}
+def _mockup_text(value: Any, limit: int = 6000) -> str:
+ return re.sub(r"\s+", " ", str(value or "")).strip()[:limit]
+
+
+def sanitize_mockup_html(value: Any) -> str:
+ """Remove active or unsafe markup before a generated preview is published."""
+ text = str(value or "").strip()
+ if text.startswith("```"):
+ text = re.sub(r"^```(?:html)?\s*|\s*```$", "", text, flags=re.I | re.S).strip()
+ text = re.sub(r"", "", text, flags=re.I | re.S)
+ text = re.sub(r"", "", text, flags=re.I | re.S)
+ text = re.sub(r"
", "", text, flags=re.I | re.S)
+ text = re.sub(r"]*>", "", text, flags=re.I | re.S)
+ text = re.sub(r"]*>", "", text, flags=re.I | re.S)
+ text = re.sub(r"]*(?:http-equiv\s*=\s*['\"]?refresh|content\s*=\s*['\"]?\d+\s*;\s*url)[^>]*>", "", text, flags=re.I | re.S)
+ text = re.sub(r"<(?:object|embed|video|audio|base)\b[^>]*>.*?(?:object|embed|video|audio|base)\s*>", "", text, flags=re.I | re.S)
+ text = re.sub(r"\s+on[a-z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", "", text, flags=re.I)
+ text = re.sub(r"\s+(?:href|src)\s*=\s*(['\"])\s*javascript:[^'\"]*\1", "", text, flags=re.I)
+ text = re.sub(r"url\s*\(\s*['\"]?\s*javascript:[^)]+\)", "", text, flags=re.I)
+ if not re.search(r" str:
+ """Produce a polished no-AI fallback so the button always gives a preview."""
+ snapshot = snapshot or {}
+ name = html_escape(_mockup_text(prospect.get("name"), 160) or "Your business")
+ category = html_escape(_mockup_text(prospect.get("category") or prospect.get("search_category"), 120) or "Local business")
+ address = html_escape(_mockup_text(prospect.get("address"), 240))
+ phone = _mockup_text(prospect.get("phone"), 80)
+ email = _mockup_text(prospect.get("emails"), 160).split(",")[0].strip()
+ website = html_escape(_mockup_text(prospect.get("website") or snapshot.get("website_url"), 400))
+ title = html_escape(_mockup_text(snapshot.get("website_title"), 160) or name)
+ description = html_escape(_mockup_text(prospect.get("description"), 500) or f"A clearer, faster online home for {name}.")
+ phone_link = html_escape(re.sub(r"[^0-9+]+", "", phone))
+ email_link = html_escape(email)
+ contact = " · ".join(item for item in [address, html_escape(phone), html_escape(email)] if item)
+ return f'''
+{name} · Website concept
+