fix: credit tracking, voice clone TTL, avatar upload ui, asset serving fallback, OAuth encryption, free plan video renders, backlink outreach sprint

This commit is contained in:
ajaysi
2026-05-25 17:07:35 +05:30
parent 090d69761f
commit 9b3bec698b
99 changed files with 15892 additions and 1278 deletions

View File

@@ -64,13 +64,18 @@ async def serve_avatar(
filename: str,
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
):
"""Serve avatar images. Supports auth via Authorization header or ?token= query param."""
"""Serve avatar images. Supports auth via Authorization header or ?token= query param.
Falls back to images/ directory for backward compatibility with old asset library entries."""
require_authenticated_user(current_user)
safe_filename = os.path.basename(filename)
file_path = _resolve_asset_path(user_id, "avatars", safe_filename)
if not file_path.exists():
alt_path = _resolve_asset_path(user_id, "images", safe_filename)
if alt_path.exists():
media_type = _get_media_type(safe_filename)
return FileResponse(alt_path, media_type=media_type)
raise HTTPException(status_code=404, detail="Asset not found")
media_type = _get_media_type(safe_filename)
@@ -101,4 +106,23 @@ async def serve_voice_sample(
media_type = _get_media_type(safe_filename)
file_size = file_path.stat().st_size
logger.warning(f"[Assets] Serving voice sample: {safe_filename} ({media_type}, {file_size} bytes)")
return FileResponse(file_path, media_type=media_type)
@router.get("/{user_id}/images/{filename}")
async def serve_image(
user_id: str,
filename: str,
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
):
"""Serve generated/uploaded images. Supports auth via Authorization header or ?token= query param."""
require_authenticated_user(current_user)
safe_filename = os.path.basename(filename)
file_path = _resolve_asset_path(user_id, "images", safe_filename)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Asset not found")
media_type = _get_media_type(safe_filename)
return FileResponse(file_path, media_type=media_type)

View File

@@ -189,44 +189,27 @@ def generate(
billing_period=current_period
)
db_track.add(summary)
db_track.flush() # Ensure summary is persisted before updating
db_track.flush()
# Get "before" state for unified log
current_calls_before = getattr(summary, "stability_calls", 0) or 0
# Update provider-specific counters (stability for image generation)
# Note: All image generation goes through STABILITY provider enum regardless of actual provider
new_calls = current_calls_before + 1
setattr(summary, "stability_calls", new_calls)
logger.debug(f"[images.generate] Updated stability_calls: {current_calls_before} -> {new_calls}")
# Update totals
old_total_calls = summary.total_calls or 0
summary.total_calls = old_total_calls + 1
logger.debug(f"[images.generate] Updated totals: calls {old_total_calls} -> {summary.total_calls}")
# Get plan details for unified log
limits = pricing.get_user_limits(user_id)
plan_name = limits.get('plan_name', 'unknown') if limits else 'unknown'
tier = limits.get('tier', 'unknown') if limits else 'unknown'
call_limit = limits['limits'].get("stability_calls", 0) if limits else 0
# Get image editing stats for unified log
current_image_edit_calls = getattr(summary, "image_edit_calls", 0) or 0
image_edit_limit = limits['limits'].get("image_edit_calls", 0) if limits else 0
# Get video stats for unified log
current_video_calls = getattr(summary, "video_calls", 0) or 0
video_limit = limits['limits'].get("video_calls", 0) if limits else 0
# Get audio stats for unified log
current_audio_calls = getattr(summary, "audio_calls", 0) or 0
audio_limit = limits['limits'].get("audio_calls", 0) if limits else 0
# Only show ∞ for Enterprise tier when limit is 0 (unlimited)
audio_limit_display = audio_limit if (audio_limit > 0 or tier != 'enterprise') else ''
db_track.commit()
logger.info(f"[images.generate] ✅ Successfully tracked usage: user {user_id} -> stability -> {new_calls} calls")
logger.debug(f"[images.generate] Usage snapshot for logging: stability_calls={current_calls_before}, total_calls={summary.total_calls or 0}")
# UNIFIED SUBSCRIPTION LOG - Shows before/after state in one message
print(f"""
@@ -965,32 +948,19 @@ def edit(
billing_period=current_period
)
db_track.add(summary)
db_track.flush() # Ensure summary is persisted before updating
db_track.flush()
# Get "before" state for unified log
current_calls_before = getattr(summary, "image_edit_calls", 0) or 0
# Update image editing counters (separate from image generation)
new_calls = current_calls_before + 1
setattr(summary, "image_edit_calls", new_calls)
logger.debug(f"[images.edit] Updated image_edit_calls: {current_calls_before} -> {new_calls}")
# Update totals
old_total_calls = summary.total_calls or 0
summary.total_calls = old_total_calls + 1
logger.debug(f"[images.edit] Updated totals: calls {old_total_calls} -> {summary.total_calls}")
# Get plan details for unified log
limits = pricing.get_user_limits(user_id)
plan_name = limits.get('plan_name', 'unknown') if limits else 'unknown'
tier = limits.get('tier', 'unknown') if limits else 'unknown'
call_limit = limits['limits'].get("image_edit_calls", 0) if limits else 0
# Get image generation stats for unified log
current_image_gen_calls = getattr(summary, "stability_calls", 0) or 0
image_gen_limit = limits['limits'].get("stability_calls", 0) if limits else 0
# Get video stats for unified log
current_video_calls = getattr(summary, "video_calls", 0) or 0
video_limit = limits['limits'].get("video_calls", 0) if limits else 0
@@ -1000,8 +970,7 @@ def edit(
# Only show ∞ for Enterprise tier when limit is 0 (unlimited)
audio_limit_display = audio_limit if (audio_limit > 0 or tier != 'enterprise') else ''
db_track.commit()
logger.info(f"[images.edit] ✅ Successfully tracked usage: user {user_id} -> image_edit -> {new_calls} calls")
logger.debug(f"[images.edit] Usage snapshot for logging: image_edit_calls={current_calls_before}, total_calls={summary.total_calls or 0}")
# UNIFIED SUBSCRIPTION LOG - Shows before/after state in one message
print(f"""

View File

@@ -9,77 +9,22 @@ from fastapi.responses import HTMLResponse
from typing import Dict, Any, Optional
from loguru import logger
from pydantic import BaseModel
import os
import uuid
import requests
from services.wix_service import WixService
from services.integrations.wix_oauth import WixOAuthService
from services.integrations.oauth_callback_utils import (
build_oauth_callback_html,
sanitize_error,
)
from middleware.auth_middleware import get_current_user
import os
import json
from urllib.parse import urlparse
import requests
router = APIRouter(prefix="/api/wix", tags=["Wix Integration"])
qa_router = APIRouter(prefix="/api/wix/test", tags=["Wix Integration QA"])
def _sanitize_error_message(error: Exception) -> str:
return " ".join(str(error).split())[:500]
def _normalize_origin(url: Optional[str]) -> Optional[str]:
if not url:
return None
parsed = urlparse(url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
return f"{parsed.scheme}://{parsed.netloc}"
def _trusted_frontend_origin() -> Optional[str]:
origins_env = os.getenv("OAUTH_CALLBACK_ALLOWED_ORIGINS", "")
configured_origins = [
_normalize_origin(origin)
for origin in origins_env.split(",")
if origin.strip()
]
configured_origins = [origin for origin in configured_origins if origin]
if configured_origins:
return configured_origins[0]
return _normalize_origin(os.getenv("FRONTEND_URL"))
def _build_oauth_callback_html(payload: Dict[str, Any], title: str, heading: str, message: str) -> str:
trusted_origin = _trusted_frontend_origin()
payload_json = json.dumps(payload)
target_origin_json = json.dumps(trusted_origin or "")
heading_html = heading.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
message_html = message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return f"""
<!DOCTYPE html>
<html>
<head><title>{title}</title></head>
<body>
<h1>{heading_html}</h1>
<p>{message_html}</p>
<script>
(function() {{
var payload = {payload_json};
var targetOrigin = {target_origin_json};
var destination = window.opener || window.parent;
if (destination && targetOrigin) {{
try {{
destination.postMessage(payload, targetOrigin);
window.close();
return;
}} catch (_e) {{}}
}}
}})();
</script>
</body>
</html>
"""
# Initialize Wix service
wix_service = WixService()
@@ -121,34 +66,38 @@ def _resolve_valid_wix_token(current_user: dict) -> Dict[str, Any]:
if not expired_tokens:
raise HTTPException(status_code=401, detail="Wix account not connected")
latest = expired_tokens[0]
refresh_token = latest.get("refresh_token")
if not refresh_token:
raise HTTPException(status_code=401, detail="Wix token expired and cannot be refreshed")
try:
refreshed = wix_service.refresh_access_token(refresh_token)
except Exception as exc:
raise _map_wix_error(exc, "Failed to refresh Wix access token")
for candidate in expired_tokens:
refresh_token = candidate.get("refresh_token")
token_id = candidate.get("id")
if not refresh_token:
continue
try:
refreshed = wix_service.refresh_access_token(refresh_token)
except Exception as exc:
continue
wix_oauth_service.update_tokens(
user_id=user_id,
access_token=refreshed.get("access_token"),
refresh_token=refreshed.get("refresh_token", refresh_token),
expires_in=refreshed.get("expires_in"),
)
wix_oauth_service.update_tokens(
user_id=user_id,
access_token=refreshed.get("access_token"),
refresh_token=refreshed.get("refresh_token", refresh_token),
expires_in=refreshed.get("expires_in"),
token_id=token_id,
)
return {
"access_token": refreshed.get("access_token"),
"refresh_token": refreshed.get("refresh_token", refresh_token),
"member_id": latest.get("member_id"),
"site_id": latest.get("site_id"),
}
return {
"access_token": refreshed.get("access_token"),
"refresh_token": refreshed.get("refresh_token", refresh_token),
"member_id": candidate.get("member_id"),
"site_id": candidate.get("site_id"),
}
raise HTTPException(status_code=401, detail="Wix token expired and cannot be refreshed")
class WixAuthRequest(BaseModel):
"""Request model for Wix authentication"""
code: str
state: Optional[str] = None
state: str
class WixPublishRequest(BaseModel):
@@ -377,7 +326,7 @@ async def handle_oauth_callback_get(code: str, state: Optional[str] = None, requ
"permissions": permissions
}
html = _build_oauth_callback_html(
html = build_oauth_callback_html(
payload=payload,
title="Wix Connected",
heading="Connection Successful",
@@ -389,8 +338,8 @@ async def handle_oauth_callback_get(code: str, state: Optional[str] = None, requ
})
except Exception as e:
logger.error(f"Wix OAuth GET callback failed: {e}")
html = _build_oauth_callback_html(
payload={"type": "WIX_OAUTH_ERROR", "success": False, "error": _sanitize_error_message(e)},
html = build_oauth_callback_html(
payload={"type": "WIX_OAUTH_ERROR", "success": False, "error": sanitize_error(e)},
title="Wix Connection Failed",
heading="Connection Failed",
message="There was an issue connecting your Wix account. You can close this window and try again."
@@ -420,19 +369,17 @@ async def get_connection_status(current_user: dict = Depends(get_current_user))
}
except HTTPException as e:
if e.status_code == 401:
return {"connected": False, "has_permissions": False}
return {"connected": False, "has_permissions": False, "error": "Wix account not connected"}
raise
except Exception as e:
logger.error(f"Failed to check connection status: {e}")
return {"connected": False, "has_permissions": False}
return {"connected": False, "has_permissions": False, "error": "Unable to check Wix connection"}
@router.get("/status")
async def get_wix_status(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
"""
Get Wix connection status (similar to GSC/WordPress pattern)
Note: Wix tokens are stored in frontend sessionStorage, so we can't directly check them here.
The frontend will check sessionStorage and update the UI accordingly.
"""
try:
token_info = _resolve_valid_wix_token(current_user)
@@ -671,8 +618,8 @@ async def get_test_authorization_url(state: Optional[str] = None, _: Dict[str, A
"message": "WIX_CLIENT_ID not configured. Please set it in your .env file to get a real authorization URL."
}
auth_url = wix_service.get_authorization_url(state)
return {"url": auth_url, "state": state or "test_state"}
auth_payload = wix_service.get_authorization_url(state)
return {"url": auth_payload.get("authorization_url", ""), "state": state or "test_state"}
except Exception as e:
logger.error(f"TEST: Failed to generate authorization URL: {e}")
raise HTTPException(status_code=500, detail=str(e))
@@ -699,28 +646,44 @@ async def test_publish_to_wix(request: WixPublishRequest, _: Dict[str, Any] = De
@router.post("/refresh-token")
async def refresh_wix_token(request: Dict[str, Any]) -> Dict[str, Any]:
async def refresh_wix_token(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
"""
Refresh Wix access token using refresh token
Refresh Wix access token using stored refresh token.
Args:
request: Dict containing refresh_token
current_user: Current authenticated user
Returns:
New token information with access_token, refresh_token, expires_in
"""
try:
refresh_token = request.get("refresh_token")
if not refresh_token:
raise HTTPException(status_code=400, detail="Missing refresh_token")
user_id = _get_current_user_id(current_user)
token_status = wix_oauth_service.get_user_token_status(user_id)
all_tokens = token_status.get("active_tokens", []) + token_status.get("expired_tokens", [])
refresh_token = None
token_id = None
for t in all_tokens:
if t.get("refresh_token"):
refresh_token = t["refresh_token"]
token_id = t["id"]
break
if not refresh_token:
raise HTTPException(status_code=400, detail="No refresh token found. Please reconnect your Wix account.")
# Refresh the token
new_tokens = wix_service.refresh_access_token(refresh_token)
wix_oauth_service.update_tokens(
user_id=user_id,
access_token=new_tokens.get("access_token"),
refresh_token=new_tokens.get("refresh_token", refresh_token),
expires_in=new_tokens.get("expires_in"),
token_id=token_id,
)
return {
"success": True,
"access_token": new_tokens.get("access_token"),
"refresh_token": new_tokens.get("refresh_token"),
"expires_in": new_tokens.get("expires_in"),
"token_type": new_tokens.get("token_type", "Bearer")
}
@@ -728,7 +691,7 @@ async def refresh_wix_token(request: Dict[str, Any]) -> Dict[str, Any]:
raise
except Exception as e:
logger.error(f"Failed to refresh Wix token: {e}")
raise HTTPException(status_code=500, detail=f"Failed to refresh token: {str(e)}")
raise _map_wix_error(e, "Failed to refresh token")
@qa_router.post("/publish/real")
@@ -800,7 +763,6 @@ async def test_publish_real(payload: Dict[str, Any], _: Dict[str, Any] = Depends
"post_id": (result.get("draftPost") or result.get("post") or {}).get("id"),
"url": (result.get("draftPost") or result.get("post") or {}).get("url"),
"message": "Blog post published to Wix",
"raw": result,
}
except HTTPException:
raise

View File

@@ -459,20 +459,21 @@ async def start_video_render(
try:
user_id = require_authenticated_user(current_user)
# Validate subscription limits
pricing_service = PricingService(db)
validate_scene_animation_operation(
pricing_service=pricing_service,
user_id=user_id
)
# Filter enabled scenes
# Filter enabled scenes FIRST so we can validate credits for the actual count
enabled_scenes = [s for s in request.scenes if s.get("enabled", True)]
if not enabled_scenes:
return VideoRenderResponse(
success=False,
message="No enabled scenes to render"
)
# Validate subscription limits for ALL scenes in the batch
pricing_service = PricingService(db)
validate_scene_animation_operation(
pricing_service=pricing_service,
user_id=user_id,
scene_count=len(enabled_scenes),
)
# VALIDATION: Pre-validate scenes before creating task to prevent wasted API calls
validation_errors = []

View File

@@ -672,6 +672,9 @@ if _is_full_mode():
# Include Bing Analytics Storage router to expose storage-backed endpoints
from routers.bing_analytics_storage import router as bing_analytics_storage_router
app.include_router(bing_analytics_storage_router)
# Include SEO Tools router with enterprise audit and GSC analysis
if seo_tools_router:
app.include_router(seo_tools_router)
if images_router:
app.include_router(images_router)
if image_studio_router:

View File

@@ -21,6 +21,11 @@ FRONTEND_URL=https://alwrity-ai.vercel.app
# Example: OAUTH_CALLBACK_ALLOWED_ORIGINS=https://alwrity-ai.vercel.app,http://localhost:3000
OAUTH_CALLBACK_ALLOWED_ORIGINS=
# OAuth Token Encryption (Fernet key - generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
# Used by both WordPress and Wix OAuth token encryption at rest.
# WORDPRESS_TOKEN_ENCRYPTION_KEY and WIX_TOKEN_ENCRYPTION_KEY can override per-provider.
OAUTH_TOKEN_ENCRYPTION_KEY=
# OAuth Redirect URIs (Using environment variable for flexibility)
GSC_REDIRECT_URI=${FRONTEND_URL}/gsc/callback
WORDPRESS_REDIRECT_URI=${FRONTEND_URL}/wp/callback

View File

@@ -1,7 +1,7 @@
"""DB models for production backlink outreach tracking."""
from datetime import datetime
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Index, Boolean
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Index, Boolean, Date
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
@@ -39,8 +39,12 @@ class OutreachAttempt(Base):
lead_id = Column(String(64), ForeignKey("backlink_leads.id"), nullable=False, index=True)
campaign_id = Column(String(64), ForeignKey("backlink_campaigns.id"), nullable=False, index=True)
idempotency_key = Column(String(128), nullable=False, unique=True, index=True)
sender_email = Column(String(255), nullable=True)
subject = Column(String(512), nullable=True)
body = Column(Text, nullable=True)
status = Column(String(32), nullable=False, default="queued", index=True)
decision_reason = Column(Text, nullable=True)
sent_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
@@ -48,6 +52,8 @@ class OutreachReply(Base):
__tablename__ = "backlink_replies"
id = Column(String(64), primary_key=True)
attempt_id = Column(String(64), ForeignKey("backlink_outreach_attempts.id"), nullable=False, index=True)
from_email = Column(String(255), nullable=True)
subject = Column(String(512), nullable=True)
received_at = Column(DateTime, default=datetime.utcnow, index=True)
classification = Column(String(32), nullable=False, default="replied")
body = Column(Text, nullable=True)
@@ -57,9 +63,72 @@ class FollowUpSchedule(Base):
__tablename__ = "backlink_followup_schedules"
id = Column(String(64), primary_key=True)
attempt_id = Column(String(64), ForeignKey("backlink_outreach_attempts.id"), nullable=False, index=True)
subject = Column(String(512), nullable=True)
body = Column(Text, nullable=True)
scheduled_for = Column(DateTime, nullable=False, index=True)
sent = Column(Boolean, default=False, index=True)
class EmailTemplate(Base):
__tablename__ = "backlink_email_templates"
id = Column(String(64), primary_key=True)
user_id = Column(String(255), nullable=False, index=True)
name = Column(String(128), nullable=False)
subject_template = Column(String(512), nullable=False)
body_template = Column(Text, nullable=False)
variables = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class SuppressedRecipient(Base):
__tablename__ = "backlink_suppressed_recipients"
id = Column(String(64), primary_key=True)
email = Column(String(255), nullable=False, index=True)
domain = Column(String(255), nullable=True)
reason = Column(String(128), nullable=True)
user_id = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class SentIdempotencyKey(Base):
__tablename__ = "backlink_sent_idempotency_keys"
id = Column(String(64), primary_key=True)
idempotency_key = Column(String(128), nullable=False, unique=True, index=True)
user_id = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
class AuditLogEntry(Base):
__tablename__ = "backlink_audit_logs"
id = Column(String(64), primary_key=True)
user_id = Column(String(255), nullable=False, index=True)
campaign_id = Column(String(64), nullable=True)
event = Column(String(64), nullable=False, index=True)
recipient = Column(String(255), nullable=True)
allowed = Column(Boolean, nullable=True)
reasons = Column(Text, nullable=True)
override = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class SendCounterUser(Base):
__tablename__ = "backlink_send_counters_user"
id = Column(String(64), primary_key=True)
user_id = Column(String(255), nullable=False, index=True)
date = Column(Date, nullable=False, index=True)
count = Column(Integer, default=0)
class SendCounterDomain(Base):
__tablename__ = "backlink_send_counters_domain"
id = Column(String(64), primary_key=True)
domain = Column(String(255), nullable=False, index=True)
date = Column(Date, nullable=False, index=True)
count = Column(Integer, default=0)
Index("idx_backlink_campaign_user_date", BacklinkCampaign.user_id, BacklinkCampaign.created_at)
Index("idx_backlink_attempt_campaign_date", OutreachAttempt.campaign_id, OutreachAttempt.created_at)
Index("idx_backlink_suppressed_email", SuppressedRecipient.email, SuppressedRecipient.user_id)
Index("idx_backlink_counter_user_date", SendCounterUser.user_id, SendCounterUser.date, unique=True)
Index("idx_backlink_counter_domain_date", SendCounterDomain.domain, SendCounterDomain.date, unique=True)

View File

@@ -1,47 +1,97 @@
"""Backlink outreach router."""
"""Backlink outreach router with Clerk auth."""
from fastapi import APIRouter, Query, HTTPException
from typing import Dict, Any
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi.responses import Response
from services.backlink_outreach_models import (
BacklinkDiscoveryResponse, BacklinkKeywordInput, DeepKeywordInput,
LeadCreateRequest, LeadStatusUpdateRequest,
PolicyValidationRequest, PolicyValidationResponse,
SendOutreachRequest, SendOutreachResponse,
OutreachAttemptListResponse, OutreachAttemptRecord,
OutreachReplyListResponse, OutreachReplyRecord,
ScheduleFollowUpRequest, FollowUpScheduleRecord,
EmailTemplateRequest, EmailTemplateRecord,
GenerateEmailRequest, GeneratedEmailResponse,
PersonalizeEmailRequest, SubjectLinesRequest, SubjectLinesResponse,
FollowUpRequest,
BacklinkReportingSnapshot,
CampaignAnalyticsResponse, CampaignVolumeResponse,
ConversionFunnelResponse, BulkStatusUpdateRequest, BulkStatusUpdateResponse,
SuppressionAddRequest,
)
from services.backlink_outreach_service import backlink_outreach_service
from services.backlink_outreach_storage import BacklinkOutreachStorageService
from services.backlink_outreach_sender import backlink_outreach_sender
from services.backlink_outreach_reply_monitor import backlink_outreach_reply_monitor
from services.backlink_outreach_template_generator import (
generate_outreach_email,
generate_personalized_email,
generate_subject_lines,
generate_follow_up,
)
from middleware.auth_middleware import get_current_user
from pydantic import BaseModel, Field
router = APIRouter(prefix="/api/backlink-outreach", tags=["backlink-outreach"])
class BacklinkCampaignCreateRequest(BaseModel):
user_id: str = Field(..., min_length=1)
workspace_id: str = Field(..., min_length=1)
name: str = Field(..., min_length=3)
def _resolve_user_id(current_user: Dict[str, Any]) -> str:
return current_user.get("id") or current_user.get("clerk_user_id") or "default"
# -- Auth-Required Endpoints --
@router.get("/modules")
async def get_backlink_module_registry():
async def get_backlink_module_registry(
current_user: Dict[str, Any] = Depends(get_current_user),
):
return {"feature": "backlink_outreach", "modules": backlink_outreach_service.list_backlink_modules()}
@router.get("/query-templates")
async def get_backlink_query_templates(keyword: str = Query(..., min_length=1)):
async def get_backlink_query_templates(
keyword: str = Query(..., min_length=1),
current_user: Dict[str, Any] = Depends(get_current_user),
):
return {"keyword": keyword, "queries": backlink_outreach_service.generate_guest_post_queries(keyword)}
@router.post("/discover", response_model=BacklinkDiscoveryResponse)
async def discover_backlink_opportunities(payload: BacklinkKeywordInput):
async def discover_backlink_opportunities(
payload: BacklinkKeywordInput,
current_user: Dict[str, Any] = Depends(get_current_user),
):
return backlink_outreach_service.discover_opportunities(payload.keyword, payload.max_results)
@router.get("/migration-coverage")
async def get_backlink_migration_coverage(
current_user: Dict[str, Any] = Depends(get_current_user),
):
return backlink_outreach_service.get_migration_coverage()
# -- Auth-Required Endpoints --
@router.post("/discover/deep")
async def discover_deep_backlink_opportunities(payload: DeepKeywordInput):
async def discover_deep_backlink_opportunities(
payload: DeepKeywordInput,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Enhanced discovery using Exa neural search + DuckDuckGo with full-page scraping."""
user_id = _resolve_user_id(current_user)
result = await backlink_outreach_service.deep_discover(payload.keyword, payload.max_results)
if payload.campaign_id:
storage = BacklinkOutreachStorageService()
user_id = "default"
saved = 0
save_failed = 0
for opp in result.get("opportunities", []):
try:
storage.add_lead(
@@ -55,26 +105,42 @@ async def discover_deep_backlink_opportunities(payload: DeepKeywordInput):
confidence_score=opp.get("confidence_score", 0.0),
discovery_source=opp.get("discovery_source", "duckduckgo"),
)
saved += 1
except Exception:
continue
save_failed += 1
result["saved_to_campaign"] = saved
result["save_failed"] = save_failed
return result
@router.post("/campaigns")
async def create_backlink_campaign(payload: BacklinkCampaignCreateRequest):
async def create_backlink_campaign(
payload: BacklinkCampaignCreateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
return storage.create_campaign(payload.user_id, payload.workspace_id, payload.name)
return storage.create_campaign(user_id, payload.workspace_id, payload.name)
@router.get("/campaigns")
async def list_backlink_campaigns(user_id: str, workspace_id: str, limit: int = 50):
async def list_backlink_campaigns(
workspace_id: str = Query(None),
limit: int = 50,
current_user: Dict[str, Any] = Depends(get_current_user),
):
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
return {"campaigns": storage.list_campaigns(user_id, workspace_id, limit)}
return {"campaigns": storage.list_campaigns(user_id, workspace_id or user_id, limit)}
@router.get("/campaigns/{campaign_id}")
async def get_backlink_campaign(campaign_id: str, user_id: str = Query(...)):
async def get_backlink_campaign(
campaign_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Get campaign detail with leads."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
campaign = storage.get_campaign(campaign_id, user_id)
if not campaign:
@@ -84,22 +150,30 @@ async def get_backlink_campaign(campaign_id: str, user_id: str = Query(...)):
@router.get("/campaigns/{campaign_id}/leads")
async def list_campaign_leads(
campaign_id: str, user_id: str = Query(...), status: str = Query(None)
campaign_id: str,
status: str = Query(None),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""List leads for a campaign, optionally filtered by status."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
leads = storage.list_leads(campaign_id, user_id, status=status or None)
return {"leads": leads, "total": len(leads)}
@router.post("/campaigns/{campaign_id}/leads")
async def add_campaign_lead(campaign_id: str, payload: LeadCreateRequest):
async def add_campaign_lead(
campaign_id: str,
payload: LeadCreateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Add a single lead to a campaign."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
try:
lead = storage.add_lead(
campaign_id=payload.campaign_id,
user_id="default",
campaign_id=campaign_id,
user_id=user_id,
url=payload.url,
domain=payload.domain,
page_title=payload.page_title or "",
@@ -110,29 +184,480 @@ async def add_campaign_lead(campaign_id: str, payload: LeadCreateRequest):
)
return lead
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail="Failed to add lead")
@router.post("/leads/bulk-status", response_model=BulkStatusUpdateResponse)
async def bulk_update_lead_status(
payload: BulkStatusUpdateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Bulk update lead statuses."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
updated = 0
failed: list[str] = []
for lid in payload.lead_ids:
try:
lead = storage.update_lead_status(lid, user_id, payload.status, payload.notes)
if lead:
updated += 1
else:
failed.append(lid)
except Exception:
failed.append(lid)
return BulkStatusUpdateResponse(updated=updated, failed=failed)
@router.patch("/leads/{lead_id}/status")
async def update_lead_status(lead_id: str, payload: LeadStatusUpdateRequest):
async def update_lead_status(
lead_id: str,
payload: LeadStatusUpdateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Update lead status (discovered -> contacted -> replied -> placed)."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
lead = storage.update_lead_status(lead_id, "default", payload.status, payload.notes)
lead = storage.update_lead_status(lead_id, user_id, payload.status, payload.notes)
if not lead:
raise HTTPException(status_code=404, detail="Lead not found")
return lead
@router.post("/policy-validate", response_model=PolicyValidationResponse)
async def validate_outreach_policy(payload: PolicyValidationRequest):
async def validate_outreach_policy(
payload: PolicyValidationRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
return backlink_outreach_service.validate_send_policy(payload)
@router.get("/reporting")
async def get_backlink_reporting_snapshot():
return backlink_outreach_service.get_reporting_snapshot()
@router.get("/reporting", response_model=BacklinkReportingSnapshot)
async def get_backlink_reporting_snapshot(
current_user: Dict[str, Any] = Depends(get_current_user),
):
user_id = _resolve_user_id(current_user)
return backlink_outreach_service.get_reporting_snapshot(user_id=user_id)
@router.get("/migration-coverage")
async def get_backlink_migration_coverage():
return backlink_outreach_service.get_migration_coverage()
# -- Outreach Attempts --
@router.post("/send-outreach", response_model=SendOutreachResponse)
async def send_outreach(
payload: SendOutreachRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Validate policy, record attempt, personalize, and send email."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
subject = payload.subject
body = payload.body
if payload.template_id:
tmpl = storage.get_template(payload.template_id, user_id)
if tmpl:
variables = payload.template_variables or {}
subject = backlink_outreach_sender.personalize(tmpl.get("subject_template", subject), variables)
body = backlink_outreach_sender.personalize(tmpl.get("body_template", body), variables)
result = backlink_outreach_service.send_outreach(
SendOutreachRequest(
lead_id=payload.lead_id,
campaign_id=payload.campaign_id,
user_id=user_id,
workspace_id=payload.workspace_id,
sender_email=payload.sender_email,
subject=subject,
body=body,
idempotency_key=payload.idempotency_key,
)
)
lead_email = ""
if result.attempt_id:
lead = storage.get_lead(payload.lead_id, user_id=user_id)
lead_email = (lead.get("email") or "") if lead else ""
if result.policy_allowed and lead_email:
sent = await backlink_outreach_sender.send_email(
to_email=lead_email,
subject=subject,
body=body,
)
status = "sent" if sent else "failed"
storage.update_attempt_status(result.attempt_id, status, user_id=user_id)
result.status = status
if sent:
storage.mark_idempotency(payload.idempotency_key, user_id)
storage.increment_user_send_counter(user_id)
domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown"
storage.increment_domain_send_counter(domain, user_id=user_id)
elif result.policy_allowed and not lead_email:
storage.update_attempt_status(result.attempt_id, "failed", user_id=user_id)
result.status = "failed"
result.policy_reasons = (result.policy_reasons or []) + ["lead_has_no_email"]
return result
@router.get("/campaigns/{campaign_id}/attempts", response_model=OutreachAttemptListResponse)
async def list_campaign_attempts(
campaign_id: str,
limit: int = Query(50),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""List outreach attempts for a campaign."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
attempts = storage.list_attempts(campaign_id, limit, user_id=user_id)
return {"attempts": attempts, "total": len(attempts)}
# -- Replies --
@router.get("/campaigns/{campaign_id}/replies", response_model=OutreachReplyListResponse)
async def list_campaign_replies(
campaign_id: str,
limit: int = Query(50),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""List received replies for a campaign."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
replies = storage.list_replies(campaign_id, limit, user_id=user_id)
return {"replies": replies, "total": len(replies)}
@router.post("/replies/poll")
async def poll_replies(
sent_from_email: str = Query(..., min_length=3),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Poll IMAP inbox for new replies and store them."""
user_id = _resolve_user_id(current_user)
if not backlink_outreach_reply_monitor.is_configured():
raise HTTPException(status_code=503, detail="IMAP not configured")
storage = BacklinkOutreachStorageService()
raw_replies = await backlink_outreach_reply_monitor.poll_replies(sent_from_email)
stored = []
skipped = 0
failed = 0
for raw in raw_replies:
try:
from_email = raw.get("from_email", "")
subject = raw.get("subject", "")
if storage.reply_exists(from_email, subject, user_id=user_id):
skipped += 1
continue
attempt_id = storage.find_attempt_by_from_email(from_email, user_id=user_id) or ""
reply = storage.add_reply(
attempt_id=attempt_id,
from_email=from_email,
subject=subject,
body=raw.get("body", ""),
classification=raw.get("classification", "replied"),
user_id=user_id,
)
stored.append(reply)
except Exception:
failed += 1
return {"polled": len(raw_replies), "stored": len(stored), "skipped": skipped, "failed": failed, "replies": stored}
# -- Follow-ups --
@router.post("/campaigns/{campaign_id}/schedule-followup")
async def schedule_followup(
campaign_id: str,
payload: ScheduleFollowUpRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Schedule a follow-up for an outreach attempt."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
sched = storage.schedule_followup(
attempt_id=payload.attempt_id,
scheduled_for=payload.scheduled_for,
subject=payload.subject or "",
body=payload.body or "",
user_id=user_id,
)
return {"campaign_id": campaign_id, "schedule": sched}
@router.get("/campaigns/{campaign_id}/followups")
async def list_followups(
campaign_id: str,
limit: int = Query(50),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""List scheduled follow-ups for a campaign."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
followups = storage.list_followups(campaign_id, limit, user_id=user_id)
return {"followups": followups, "total": len(followups)}
# -- Email Templates --
@router.post("/templates")
async def create_template(
payload: EmailTemplateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Create an email template."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
return storage.create_template(
user_id=user_id,
name=payload.name,
subject_template=payload.subject_template,
body_template=payload.body_template,
variables=payload.variables,
)
@router.get("/templates")
async def list_templates(
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""List email templates for the authenticated user."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
return {"templates": storage.list_templates(user_id)}
@router.get("/templates/{template_id}")
async def get_template(
template_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Get a specific email template."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
tmpl = storage.get_template(template_id, user_id)
if not tmpl:
raise HTTPException(status_code=404, detail="Template not found")
return tmpl
@router.delete("/templates/{template_id}")
async def delete_template(
template_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Delete an email template."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
if not storage.delete_template(template_id, user_id):
raise HTTPException(status_code=404, detail="Template not found")
return {"deleted": True}
@router.post("/templates/generate", response_model=GeneratedEmailResponse)
async def generate_email_template(
payload: GenerateEmailRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Generate an outreach email using AI."""
user_id = _resolve_user_id(current_user)
existing_body = None
if payload.existing_template_id:
storage = BacklinkOutreachStorageService()
tmpl = storage.get_template(payload.existing_template_id, user_id)
if tmpl:
existing_body = tmpl.get("body_template")
result = generate_outreach_email(
topic=payload.topic,
target_site=payload.target_site,
tone=payload.tone,
user_id=user_id,
existing_body=existing_body,
)
return result
@router.post("/generate/personalized", response_model=GeneratedEmailResponse)
async def generate_personalized_email_endpoint(
payload: PersonalizeEmailRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Personalize an outreach email for a specific lead."""
user_id = _resolve_user_id(current_user)
result = generate_personalized_email(
lead_name=payload.lead_name,
lead_site=payload.lead_site,
lead_content_topic=payload.lead_content_topic,
pitch_topic=payload.pitch_topic,
existing_body=payload.existing_body,
user_id=user_id,
)
return result
@router.post("/generate/subject-lines", response_model=SubjectLinesResponse)
async def generate_subject_lines_endpoint(
payload: SubjectLinesRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Generate subject line suggestions for an email body."""
user_id = _resolve_user_id(current_user)
subjects = generate_subject_lines(
body=payload.body,
count=payload.count,
user_id=user_id,
)
return {"subjects": subjects}
@router.post("/generate/follow-up", response_model=GeneratedEmailResponse)
async def generate_follow_up_endpoint(
payload: FollowUpRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Generate a follow-up email for an outreach attempt."""
user_id = _resolve_user_id(current_user)
result = generate_follow_up(
original_subject=payload.original_subject,
original_body=payload.original_body,
days_elapsed=payload.days_elapsed,
reply_context=payload.reply_context,
user_id=user_id,
)
return result
# -- Suppression --
@router.get("/suppression")
async def list_suppression(
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""List suppressed recipients."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
return {"suppressed": storage.list_suppressed(user_id)}
@router.post("/suppression")
async def add_suppression(
payload: SuppressionAddRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Add a recipient to the suppression list."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
return storage.add_suppressed(email=payload.email, domain=payload.domain, reason=payload.reason, user_id=user_id)
@router.get("/campaigns/{campaign_id}/analytics/volume", response_model=CampaignVolumeResponse)
async def get_campaign_analytics_volume(
campaign_id: str,
days: int = Query(30, ge=1, le=365),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Get daily send volume for a campaign over the last N days."""
user_id = _resolve_user_id(current_user)
return backlink_outreach_service.get_campaign_volume(campaign_id, days, user_id=user_id)
@router.get("/campaigns/{campaign_id}/analytics/funnel", response_model=ConversionFunnelResponse)
async def get_campaign_analytics_funnel(
campaign_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Get conversion funnel (lead status breakdown) for a campaign."""
user_id = _resolve_user_id(current_user)
return backlink_outreach_service.get_campaign_funnel(campaign_id, user_id=user_id)
@router.get("/campaigns/{campaign_id}/export/leads")
async def export_campaign_leads_csv(
campaign_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Export campaign leads as CSV."""
user_id = _resolve_user_id(current_user)
csv_content = backlink_outreach_service.export_leads_csv(campaign_id, user_id=user_id)
return Response(content=csv_content, media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=leads_{campaign_id}.csv"})
@router.get("/campaigns/{campaign_id}/export/attempts")
async def export_campaign_attempts_csv(
campaign_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Export campaign outreach attempts as CSV."""
user_id = _resolve_user_id(current_user)
csv_content = backlink_outreach_service.export_attempts_csv(campaign_id, user_id=user_id)
return Response(content=csv_content, media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=attempts_{campaign_id}.csv"})
@router.get("/campaigns/{campaign_id}/export/replies")
async def export_campaign_replies_csv(
campaign_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Export campaign replies as CSV."""
user_id = _resolve_user_id(current_user)
csv_content = backlink_outreach_service.export_replies_csv(campaign_id, user_id=user_id)
return Response(content=csv_content, media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=replies_{campaign_id}.csv"})
# -- Audit Log --
@router.get("/audit-logs")
async def list_audit_logs(
campaign_id: str = Query(None),
limit: int = Query(100),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""List audit log entries, optionally filtered by campaign."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
return {"logs": storage.list_audit_logs(campaign_id or None, limit, user_id=user_id)}
# -- Analytics --
@router.get("/campaigns/{campaign_id}/analytics", response_model=CampaignAnalyticsResponse)
async def get_campaign_analytics(
campaign_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Get campaign analytics: send volume, response/placement rates, reply breakdown."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
campaign = storage.get_campaign(campaign_id, user_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
attempts = storage.list_attempts(campaign_id, user_id=user_id)
replies = storage.list_replies(campaign_id, user_id=user_id)
leads = storage.list_leads_all(campaign_id, user_id=user_id)
total_sent = sum(1 for a in attempts if a.get("status") == "sent")
total_blocked = sum(1 for a in attempts if a.get("status") == "blocked")
total_replied = len(replies)
total_placed = sum(1 for l in leads if l.get("status") == "placed")
reply_classification = {}
for r in replies:
cls = r.get("classification", "replied")
reply_classification[cls] = reply_classification.get(cls, 0) + 1
return CampaignAnalyticsResponse(
campaign_id=campaign_id,
lead_count=campaign.get("lead_count", 0),
send_volume=total_sent,
blocked_count=total_blocked,
reply_count=total_replied,
response_rate=round(total_replied / total_sent, 4) if total_sent > 0 else 0.0,
placement_rate=round(total_placed / campaign.get("lead_count", 1), 4) if campaign.get("lead_count", 0) > 0 else 0.0,
reply_classification=reply_classification,
)

View File

@@ -63,8 +63,8 @@ async def save_to_library(
file_path = assets_dir / filename
file_path.write_bytes(image_bytes)
# Build serving URL (assets_serving.py serves /{user_id}/avatars/{filename})
file_url = f"/api/assets/{safe_user}/avatars/{filename}"
# Build serving URL (assets_serving.py serves /{user_id}/images/{filename})
file_url = f"/api/assets/{safe_user}/images/{filename}"
# Save to unified asset library via existing utility
from utils.asset_tracker import save_asset_to_library

View File

@@ -87,7 +87,7 @@ async def get_wordpress_status(user: dict = Depends(get_current_user)):
logger.info(f"Checking WordPress status for user: {user_id}")
# Get user's WordPress sites
sites = wp_service.get_user_sites(user_id)
sites = wp_service.get_user_sites(user_id)
if sites:
site_responses = [

View File

@@ -8,11 +8,12 @@ from fastapi.responses import RedirectResponse, HTMLResponse, JSONResponse
from typing import Dict, Any, Optional
from pydantic import BaseModel
from loguru import logger
import json
import os
from urllib.parse import urlparse
from services.integrations.wordpress_oauth import WordPressOAuthService
from services.integrations.oauth_callback_utils import (
build_oauth_callback_html,
sanitize_string,
)
from middleware.auth_middleware import get_current_user
router = APIRouter(prefix="/wp", tags=["WordPress OAuth"])
@@ -20,65 +21,6 @@ router = APIRouter(prefix="/wp", tags=["WordPress OAuth"])
# Initialize OAuth service
oauth_service = WordPressOAuthService()
def _sanitize_string(value: Any, max_len: int = 500) -> str:
if value is None:
return ""
return " ".join(str(value).split())[:max_len]
def _normalize_origin(url: Optional[str]) -> Optional[str]:
if not url:
return None
parsed = urlparse(url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
return f"{parsed.scheme}://{parsed.netloc}"
def _trusted_frontend_origin() -> Optional[str]:
origins_env = os.getenv("OAUTH_CALLBACK_ALLOWED_ORIGINS", "")
configured_origins = [
_normalize_origin(origin)
for origin in origins_env.split(",")
if origin.strip()
]
configured_origins = [origin for origin in configured_origins if origin]
if configured_origins:
return configured_origins[0]
return _normalize_origin(os.getenv("FRONTEND_URL"))
def _oauth_callback_html(payload: Dict[str, Any], title: str, heading: str, message: str) -> str:
payload_json = json.dumps(payload)
target_origin = json.dumps(_trusted_frontend_origin() or "")
heading_html = heading.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
message_html = message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return f"""
<!DOCTYPE html>
<html>
<head><title>{title}</title></head>
<body>
<h1>{heading_html}</h1>
<p>{message_html}</p>
<script>
(function() {{
var payload = {payload_json};
var targetOrigin = {target_origin};
var destination = window.opener || window.parent;
if (destination && targetOrigin) {{
try {{
destination.postMessage(payload, targetOrigin);
window.close();
return;
}} catch (_e) {{}}
}}
}})();
</script>
</body>
</html>
"""
# Pydantic Models
class WordPressOAuthResponse(BaseModel):
auth_url: str
@@ -140,8 +82,8 @@ async def handle_wordpress_callback(
status_code=status.HTTP_400_BAD_REQUEST,
content={"success": False, "error": error}
)
html_content = _oauth_callback_html(
payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": _sanitize_string(error)},
html_content = build_oauth_callback_html(
payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": sanitize_string(error)},
title="WordPress.com Connection Failed",
heading="Connection Failed",
message="There was an error connecting to WordPress.com. You can close this window and try again."
@@ -158,7 +100,7 @@ async def handle_wordpress_callback(
status_code=status.HTTP_400_BAD_REQUEST,
content={"success": False, "error": "Missing parameters"}
)
html_content = _oauth_callback_html(
html_content = build_oauth_callback_html(
payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": "Missing parameters"},
title="WordPress.com Connection Failed",
heading="Connection Failed",
@@ -179,7 +121,7 @@ async def handle_wordpress_callback(
status_code=status.HTTP_400_BAD_REQUEST,
content={"success": False, "error": "Token exchange failed"}
)
html_content = _oauth_callback_html(
html_content = build_oauth_callback_html(
payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": "Token exchange failed"},
title="WordPress.com Connection Failed",
heading="Connection Failed",
@@ -201,12 +143,12 @@ async def handle_wordpress_callback(
}
)
html_content = _oauth_callback_html(
html_content = build_oauth_callback_html(
payload={
"type": "WPCOM_OAUTH_SUCCESS",
"success": True,
"blogUrl": _sanitize_string(blog_url, 300),
"blogId": _sanitize_string(blog_id, 128)
"blogUrl": sanitize_string(blog_url, 300),
"blogId": sanitize_string(blog_id, 128)
},
title="WordPress.com Connection Successful",
heading="Connection Successful",
@@ -220,7 +162,7 @@ async def handle_wordpress_callback(
except Exception as e:
logger.error(f"Error handling WordPress OAuth callback: {e}")
html_content = _oauth_callback_html(
html_content = build_oauth_callback_html(
payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": "Callback error"},
title="WordPress.com Connection Failed",
heading="Connection Failed",

View File

@@ -43,7 +43,7 @@ def cap_basic_plan_usage():
# New limits
new_call_limit = basic_plan.gemini_calls_limit # Should be 10
new_token_limit = basic_plan.gemini_tokens_limit # Should be 2000
new_image_limit = basic_plan.stability_calls_limit # Should be 5
new_image_limit = basic_plan.stability_calls_limit # 25
logger.info(f"📋 Basic Plan Limits:")
logger.info(f" Calls: {new_call_limit}")

View File

@@ -75,8 +75,14 @@ def update_basic_plan_limits():
basic_plan.anthropic_tokens_limit = 20000
basic_plan.mistral_tokens_limit = 20000
# Update image generation limit to 5
basic_plan.stability_calls_limit = 5
# Update image generation limit to 25 (minimum 10 for podcast workflows)
basic_plan.stability_calls_limit = 25
# Update image edit limit to 25 (podcast episode covers + scene images)
basic_plan.image_edit_calls_limit = 25
# Update audio generation limit to 100 (TTS for podcast narration)
basic_plan.audio_calls_limit = 100
# Update timestamp
basic_plan.updated_at = datetime.now(timezone.utc)
@@ -84,7 +90,9 @@ def update_basic_plan_limits():
logger.info("\n📝 New Basic plan limits:")
logger.info(f" LLM Calls (all providers): 10")
logger.info(f" LLM Tokens (all providers): 20000 (increased from 5000)")
logger.info(f" Images: 5")
logger.info(f" Images (stability): 25")
logger.info(f" Image Edits: 25")
logger.info(f" Audio Calls: 100")
# Count and get affected users
user_subscriptions = db.query(UserSubscription).filter(

View File

@@ -106,22 +106,138 @@ class CampaignDetailResponse(BaseModel):
leads: List[LeadRecord] = Field(default_factory=list)
class GenerateEmailRequest(BaseModel):
topic: str = Field(..., min_length=2, max_length=500)
target_site: Optional[str] = Field(None, description="Target website for guest post pitch")
tone: str = Field(default="professional", pattern="^(professional|friendly|casual|formal)$")
existing_template_id: Optional[str] = None
class GeneratedEmailResponse(BaseModel):
subject: str
body: str
class PersonalizeEmailRequest(BaseModel):
lead_name: str = Field(..., min_length=1, max_length=200)
lead_site: str = Field(..., min_length=1, max_length=500)
lead_content_topic: str = Field(..., min_length=1, max_length=500)
pitch_topic: str = Field(..., min_length=2, max_length=500)
existing_body: str = Field(default="", max_length=10000)
class SubjectLinesRequest(BaseModel):
body: str = Field(..., min_length=10, max_length=10000)
count: int = Field(default=5, ge=1, le=10)
class SubjectLinesResponse(BaseModel):
subjects: list[str]
class FollowUpRequest(BaseModel):
original_subject: str = Field(..., min_length=1, max_length=500)
original_body: str = Field(..., min_length=10, max_length=10000)
days_elapsed: int = Field(default=7, ge=1, le=90)
reply_context: str = Field(default="", max_length=2000)
class OutreachStatusRecord(BaseModel):
opportunity_url: HttpUrl
status: str
notes: Optional[str] = None
class SendOutreachRequest(BaseModel):
lead_id: str = Field(..., min_length=1)
campaign_id: str = Field(..., min_length=1)
user_id: str = Field(..., min_length=1)
workspace_id: str = Field(default="default")
sender_email: str = Field(..., min_length=3)
subject: str = Field(..., min_length=1)
body: str = Field(..., min_length=1)
idempotency_key: str = Field(..., min_length=8)
template_id: Optional[str] = Field(None, description="Optional template ID for personalization")
template_variables: Optional[dict] = Field(None, description="Variable values for template personalization")
class SendOutreachResponse(BaseModel):
attempt_id: str
status: str
policy_allowed: bool
policy_reasons: List[str] = Field(default_factory=list)
class OutreachAttemptRecord(BaseModel):
attempt_id: str
lead_id: str
campaign_id: str
idempotency_key: str
sender_email: Optional[str] = None
subject: Optional[str] = None
status: str = "queued"
decision_reason: Optional[str] = None
sent_at: Optional[str] = None
created_at: Optional[str] = None
class OutreachAttemptListResponse(BaseModel):
attempts: List[OutreachAttemptRecord]
total: int
class OutreachReplyRecord(BaseModel):
reply_id: str
attempt_id: str
from_email: Optional[str] = None
subject: Optional[str] = None
received_at: Optional[str] = None
classification: str = "replied"
body: Optional[str] = None
class OutreachReplyListResponse(BaseModel):
replies: List[OutreachReplyRecord]
total: int
class ScheduleFollowUpRequest(BaseModel):
attempt_id: str = Field(..., min_length=1)
scheduled_for: str = Field(..., min_length=1)
subject: Optional[str] = None
body: Optional[str] = None
class FollowUpScheduleRecord(BaseModel):
schedule_id: str
attempt_id: str
subject: Optional[str] = None
scheduled_for: str
sent: bool = False
class EmailTemplateRequest(BaseModel):
name: str = Field(..., min_length=1)
subject_template: str = Field(..., min_length=1)
body_template: str = Field(..., min_length=1)
variables: Optional[List[str]] = None
class EmailTemplateRecord(BaseModel):
template_id: str
user_id: str
name: str
subject_template: str
body_template: str
variables: Optional[List[str]] = None
created_at: Optional[str] = None
class PolicyValidationRequest(BaseModel):
user_id: str = Field(..., min_length=1)
workspace_id: str = Field(..., min_length=1)
campaign_id: str = Field(..., min_length=1)
recipient_email: EmailStr
recipient_email: str = Field(..., min_length=1)
recipient_domain: str
recipient_region: str = Field(default="unknown")
legal_basis: str = Field(..., min_length=2)
@@ -135,3 +251,61 @@ class PolicyValidationResponse(BaseModel):
allowed: bool
reasons: List[str] = Field(default_factory=list)
final_status: str
# -- Analytics & Reporting Models --
class CampaignAnalyticsResponse(BaseModel):
campaign_id: str
lead_count: int = 0
send_volume: int = 0
blocked_count: int = 0
reply_count: int = 0
response_rate: float = 0.0
placement_rate: float = 0.0
reply_classification: Dict[str, int] = Field(default_factory=dict)
class BacklinkReportingSnapshot(BaseModel):
send_volume: int = 0
decision_events: int = 0
response_rate: float = 0.0
placement_conversion: float = 0.0
class CampaignVolumePoint(BaseModel):
date: str
count: int = 0
class CampaignVolumeResponse(BaseModel):
campaign_id: str
days: int = 30
volume: List[CampaignVolumePoint] = Field(default_factory=list)
class FunnelStage(BaseModel):
status: str
count: int = 0
class ConversionFunnelResponse(BaseModel):
campaign_id: str
stages: List[FunnelStage] = Field(default_factory=list)
class BulkStatusUpdateRequest(BaseModel):
lead_ids: List[str] = Field(..., min_length=1)
status: str = Field(..., min_length=1)
notes: Optional[str] = None
class BulkStatusUpdateResponse(BaseModel):
updated: int = 0
failed: List[str] = Field(default_factory=list)
class SuppressionAddRequest(BaseModel):
email: str = Field(..., min_length=3)
reason: str = Field(default="")
domain: str = Field(default="")

View File

@@ -0,0 +1,164 @@
"""IMAP-based reply monitoring for backlink outreach."""
from __future__ import annotations
import os
import asyncio
import imaplib
import email as email_lib
from email.utils import parsedate_to_datetime
from typing import List, Optional
from loguru import logger
IMAP_HOST = os.getenv("IMAP_HOST", "imap.gmail.com")
IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
IMAP_USERNAME = os.getenv("IMAP_USERNAME", "")
IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "")
IMAP_FOLDER = os.getenv("IMAP_FOLDER", "INBOX")
IMAP_FETCH_LIMIT = int(os.getenv("IMAP_FETCH_LIMIT", "50"))
# Search keywords for auto-classification
INTERESTED_KEYWORDS = [
"interested", "let's discuss", "sounds good", "would love to", "yes",
"sure", "tell me more", "looks good", "happy to", "let's do it",
"sign me up", "count me in", "proceed", "approved",
]
NOT_INTERESTED_KEYWORDS = [
"not interested", "unsubscribe", "no thanks", "remove me", "stop",
"don't contact", "spam", "not relevant", "no longer interested",
"please stop", "do not email",
]
OUT_OF_OFFICE_KEYWORDS = [
"out of office", "vacation", "on leave", "away from", "return on",
"not in the office", "will be back",
]
class BacklinkOutreachReplyMonitor:
def __init__(self):
self._host = IMAP_HOST
self._port = IMAP_PORT
self._username = IMAP_USERNAME
self._password = IMAP_PASSWORD
self._folder = IMAP_FOLDER
self._fetch_limit = IMAP_FETCH_LIMIT
def is_configured(self) -> bool:
return bool(self._username and self._password)
async def poll_replies(self, sent_from_email: str) -> List[dict]:
"""Poll IMAP inbox for replies to a specific sender address."""
if not self.is_configured():
logger.warning("IMAP not configured: set IMAP_USERNAME and IMAP_PASSWORD")
return []
loop = asyncio.get_running_loop()
def _poll() -> List[dict]:
try:
mail = imaplib.IMAP4_SSL(self._host, self._port)
mail.login(self._username, self._password)
mail.select(self._folder)
safe_email = sent_from_email.replace('"', "").replace("\\", "")
search_criteria = f'(TO "{safe_email}")'
status, message_ids = mail.search(None, search_criteria)
if status != "OK":
return []
ids = message_ids[0].split() if message_ids[0] else []
if not ids:
return []
ids = ids[-self._fetch_limit:]
replies = []
for mid in ids:
status, msg_data = mail.fetch(mid, "(RFC822)")
if status != "OK":
continue
raw_email = msg_data[0][1] if msg_data else None
if not raw_email:
continue
parsed = email_lib.message_from_bytes(raw_email)
reply = self._parse_reply(parsed)
if reply:
replies.append(reply)
mail.logout()
return replies
except imaplib.IMAP4.error as e:
logger.error(f"IMAP error: {e}")
return []
except Exception as e:
logger.error(f"Unexpected IMAP error: {e}")
return []
return await loop.run_in_executor(None, _poll)
def _parse_reply(self, parsed_msg) -> Optional[dict]:
try:
from_email = parsed_msg.get("From", "")
subject = parsed_msg.get("Subject", "")
received_at = parsed_msg.get("Date", "")
# Extract body
body = ""
if parsed_msg.is_multipart():
for part in parsed_msg.walk():
content_type = part.get_content_type()
if content_type == "text/plain":
try:
body = part.get_payload(decode=True).decode("utf-8", errors="ignore")
break
except Exception:
continue
else:
try:
body = parsed_msg.get_payload(decode=True).decode("utf-8", errors="ignore")
except Exception:
body = str(parsed_msg.get_payload())
classification = self._classify_reply(body, subject)
# Parse date
try:
dt = parsedate_to_datetime(received_at)
received_at_iso = dt.isoformat() if dt else None
except Exception:
received_at_iso = None
return {
"from_email": from_email,
"subject": subject,
"body": body[:5000],
"classification": classification,
"received_at": received_at_iso,
}
except Exception as e:
logger.error(f"Failed to parse reply: {e}")
return None
@staticmethod
def _classify_reply(body: str, subject: str) -> str:
text = f"{subject} {body}".lower()
for kw in OUT_OF_OFFICE_KEYWORDS:
if kw in text:
return "out_of_office"
for kw in NOT_INTERESTED_KEYWORDS:
if kw in text:
return "not_interested"
for kw in INTERESTED_KEYWORDS:
if kw in text:
return "interested"
return "replied"
backlink_outreach_reply_monitor = BacklinkOutreachReplyMonitor()

View File

@@ -0,0 +1,90 @@
"""Email sender for backlink outreach via SMTP."""
from __future__ import annotations
import os
import ssl
import smtplib
import asyncio
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
from loguru import logger
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USERNAME)
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() in ("true", "1", "yes")
SMTP_VERIFY_TLS = os.getenv("SMTP_VERIFY_TLS", "true").lower() in ("true", "1", "yes")
SMTP_SEND_TIMEOUT = int(os.getenv("SMTP_SEND_TIMEOUT", "30"))
class BacklinkOutreachSender:
def __init__(self):
self._host = SMTP_HOST
self._port = SMTP_PORT
self._username = SMTP_USERNAME
self._password = SMTP_PASSWORD
self._from_email = SMTP_FROM_EMAIL or SMTP_USERNAME
self._use_tls = SMTP_USE_TLS
self._verify_tls = SMTP_VERIFY_TLS
self._timeout = SMTP_SEND_TIMEOUT
def is_configured(self) -> bool:
return bool(self._username and self._password)
async def send_email(
self,
to_email: str,
subject: str,
body: str,
from_email: Optional[str] = None,
) -> bool:
if not self.is_configured():
logger.error("SMTP not configured: set SMTP_USERNAME and SMTP_PASSWORD")
return False
sender = from_email or self._from_email
msg = MIMEMultipart("alternative")
msg["From"] = sender
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
loop = asyncio.get_running_loop()
def _send() -> bool:
try:
tls_context = ssl.create_default_context()
if not self._verify_tls:
tls_context.check_hostname = False
tls_context.verify_mode = ssl.CERT_NONE
with smtplib.SMTP(self._host, self._port, timeout=self._timeout) as server:
if self._use_tls:
server.starttls(context=tls_context)
server.ehlo()
server.login(self._username, self._password)
server.sendmail(sender, [to_email], msg.as_string())
logger.info(f"Email sent to {to_email}: {subject[:60]}")
return True
except smtplib.SMTPException as e:
logger.error(f"SMTP error sending to {to_email}: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error sending to {to_email}: {e}")
return False
return await loop.run_in_executor(None, _send)
def personalize(self, template: str, variables: dict) -> str:
"""Replace {placeholder} variables in a template string."""
for key, value in variables.items():
template = template.replace(f"{{{key}}}", str(value))
return template
backlink_outreach_sender = BacklinkOutreachSender()

View File

@@ -3,24 +3,25 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import re
import time
import requests
from bs4 import BeautifulSoup
from services.backlink_outreach_models import OpportunityContactInfo, OpportunityRecord, PolicyValidationRequest, PolicyValidationResponse
import csv
import io
from services.backlink_outreach_models import (
OpportunityContactInfo, OpportunityRecord,
PolicyValidationRequest, PolicyValidationResponse,
SendOutreachRequest, SendOutreachResponse,
CampaignVolumeResponse, CampaignVolumePoint,
ConversionFunnelResponse, FunnelStage,
)
from services.backlink_outreach_storage import BacklinkOutreachStorageService
# Temporary in-memory control plane until DB wiring is complete
SUPPRESSION_LIST = set()
SENT_IDEMPOTENCY_KEYS = set()
AUDIT_LOGS: list[dict] = []
SEND_COUNTERS_BY_USER: dict[str, int] = {}
SEND_COUNTERS_BY_DOMAIN: dict[str, int] = {}
DEFAULT_USER_DAILY_CAP = 100
DEFAULT_DOMAIN_DAILY_CAP = 20
@@ -140,8 +141,12 @@ class BacklinkOutreachService:
return min(1.0, 0.35 + (0.13 * hits))
def _get_storage(self) -> BacklinkOutreachStorageService:
return BacklinkOutreachStorageService()
def validate_send_policy(self, payload: PolicyValidationRequest) -> PolicyValidationResponse:
reasons: List[str] = []
storage = self._get_storage()
if payload.workspace_id.startswith("new-") and not payload.approved_by_human:
reasons.append("human_review_required_for_new_workspace")
@@ -149,19 +154,17 @@ class BacklinkOutreachService:
reasons.append("invalid_legal_basis")
if payload.recipient_region.lower() in {"eu", "eea"} and payload.legal_basis.lower() != "consent":
reasons.append("region_requires_explicit_consent")
if not payload.unsubscribe_url:
reasons.append("unsubscribe_url_required")
if len(payload.sender_identity.strip()) < 3:
reasons.append("sender_identity_required")
recipient_key = f"{payload.recipient_email.lower()}::{payload.recipient_domain.lower()}"
if recipient_key in SUPPRESSION_LIST:
if storage.is_suppressed(str(payload.recipient_email), payload.recipient_domain, user_id=payload.user_id):
reasons.append("recipient_suppressed")
if payload.idempotency_key in SENT_IDEMPOTENCY_KEYS:
if storage.check_idempotency(payload.idempotency_key, user_id=payload.user_id):
reasons.append("duplicate_idempotency_key")
user_count = SEND_COUNTERS_BY_USER.get(payload.user_id, 0)
domain_count = SEND_COUNTERS_BY_DOMAIN.get(payload.recipient_domain.lower(), 0)
user_count = storage.get_user_send_count(payload.user_id)
domain_count = storage.get_domain_send_count(payload.recipient_domain, user_id=payload.user_id)
if user_count >= DEFAULT_USER_DAILY_CAP:
reasons.append("user_daily_cap_exceeded")
if domain_count >= DEFAULT_DOMAIN_DAILY_CAP:
@@ -170,33 +173,156 @@ class BacklinkOutreachService:
allowed = len(reasons) == 0
final_status = "approved" if allowed else "blocked"
AUDIT_LOGS.append({
"event": "policy_check",
"user_id": payload.user_id,
"campaign_id": payload.campaign_id,
"recipient": str(payload.recipient_email),
"allowed": allowed,
"reasons": reasons,
"override": payload.approved_by_human,
})
if allowed:
SENT_IDEMPOTENCY_KEYS.add(payload.idempotency_key)
SEND_COUNTERS_BY_USER[payload.user_id] = user_count + 1
SEND_COUNTERS_BY_DOMAIN[payload.recipient_domain.lower()] = domain_count + 1
storage.add_audit_log(
event="policy_check",
user_id=payload.user_id,
campaign_id=payload.campaign_id,
recipient=str(payload.recipient_email),
allowed=allowed,
reasons=reasons,
override=payload.approved_by_human,
)
return PolicyValidationResponse(allowed=allowed, reasons=reasons, final_status=final_status)
def get_reporting_snapshot(self) -> Dict[str, Any]:
total_decisions = len(AUDIT_LOGS)
approved = sum(1 for row in AUDIT_LOGS if row.get("allowed"))
EU_DOMAIN_SUFFIXES = (".de", ".fr", ".it", ".es", ".nl", ".be", ".at", ".se", ".dk", ".fi", ".pt", ".ie", ".gr", ".pl", ".cz", ".ro", ".hu", ".bg", ".hr", ".sk", ".si", ".ee", ".lv", ".lt", ".lu", ".mt", ".cy")
def _infer_region(self, domain: str) -> str:
d = domain.lower()
if any(d.endswith(s) or d.endswith(s + "/") for s in self.EU_DOMAIN_SUFFIXES):
return "eu"
if d.endswith(".uk"):
return "uk"
if d.endswith(".ca"):
return "ca"
if d.endswith(".au"):
return "au"
return "unknown"
def send_outreach(self, request: SendOutreachRequest) -> SendOutreachResponse:
storage = self._get_storage()
lead = storage.get_lead(request.lead_id, user_id=request.user_id)
if not lead:
return SendOutreachResponse(attempt_id="", status="failed", policy_allowed=False, policy_reasons=["lead_not_found"])
domain = lead.get("domain", request.sender_email.split("@")[-1] if "@" in request.sender_email else "unknown")
recipient_region = self._infer_region(domain)
legal_basis = "consent" if recipient_region == "eu" else "legitimate_interest"
policy_req = PolicyValidationRequest(
user_id=request.user_id,
workspace_id=request.workspace_id,
campaign_id=request.campaign_id,
recipient_email=lead.get("email", ""),
recipient_domain=domain,
recipient_region=recipient_region,
legal_basis=legal_basis,
approved_by_human=False,
unsubscribe_url=None,
sender_identity=request.sender_email,
idempotency_key=request.idempotency_key,
)
policy = self.validate_send_policy(policy_req)
attempt = storage.add_attempt(
lead_id=request.lead_id,
campaign_id=request.campaign_id,
idempotency_key=request.idempotency_key,
sender_email=request.sender_email,
subject=request.subject,
body=request.body,
status="approved" if policy.allowed else "blocked",
decision_reason="; ".join(policy.reasons) if policy.reasons else None,
user_id=request.user_id,
)
return SendOutreachResponse(
attempt_id=attempt.get("attempt_id", ""),
status=attempt.get("status", "failed"),
policy_allowed=policy.allowed,
policy_reasons=policy.reasons,
)
def get_reporting_snapshot(self, user_id: str = "default") -> Dict[str, Any]:
storage = self._get_storage()
campaigns = storage.list_campaigns(user_id, user_id, limit=100)
total_sent = 0
total_replied = 0
total_placed = 0
total_leads = 0
for c in campaigns:
cid = c["campaign_id"]
attempts = storage.list_attempts(cid, limit=10000, user_id=user_id)
leads = storage.list_leads_all(cid, user_id=user_id)
total_sent += sum(1 for a in attempts if a.get("status") == "sent")
total_replied += storage.count_replies(cid, user_id=user_id)
total_placed += sum(1 for l in leads if l.get("status") == "placed")
total_leads += len(leads)
logs = storage.list_audit_logs("", limit=1000, user_id=user_id)
return {
"send_volume": approved,
"decision_events": total_decisions,
"response_rate": 0.0,
"placement_conversion": 0.0,
"send_volume": total_sent,
"decision_events": len(logs),
"response_rate": round(total_replied / total_sent, 4) if total_sent > 0 else 0.0,
"placement_conversion": round(total_placed / total_leads, 4) if total_leads > 0 else 0.0,
}
def get_campaign_volume(self, campaign_id: str, days: int = 30, user_id: str = "default") -> CampaignVolumeResponse:
storage = self._get_storage()
points = storage.get_send_volume_by_day(campaign_id, days, user_id=user_id)
return CampaignVolumeResponse(
campaign_id=campaign_id, days=days,
volume=[CampaignVolumePoint(**p) for p in points],
)
def get_campaign_funnel(self, campaign_id: str, user_id: str = "default") -> ConversionFunnelResponse:
storage = self._get_storage()
stages = storage.get_lead_status_counts(campaign_id, user_id=user_id)
return ConversionFunnelResponse(
campaign_id=campaign_id,
stages=[FunnelStage(**s) for s in stages],
)
CSV_LEAD_FIELDS = ["lead_id", "campaign_id", "domain", "page_title", "email", "status", "discovery_source", "created_at"]
CSV_ATTEMPT_FIELDS = ["attempt_id", "lead_id", "campaign_id", "sender_email", "subject", "status", "sent_at", "created_at"]
CSV_REPLY_FIELDS = ["reply_id", "attempt_id", "from_email", "subject", "classification", "received_at"]
@staticmethod
def _sanitize_csv_value(value: Any) -> str:
s = str(value) if value is not None else ""
if s and s[0] in ("=", "+", "-", "@", "\t", "\r"):
s = "'" + s
return s
def export_leads_csv(self, campaign_id: str, user_id: str = "default") -> str:
storage = self._get_storage()
leads = storage.list_leads_all(campaign_id, user_id=user_id)
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=self.CSV_LEAD_FIELDS, extrasaction="ignore")
writer.writeheader()
for row in leads:
writer.writerows([{k: self._sanitize_csv_value(v) for k, v in row.items()}])
return output.getvalue()
def export_attempts_csv(self, campaign_id: str, user_id: str = "default") -> str:
storage = self._get_storage()
attempts = storage.list_attempts_all(campaign_id, user_id=user_id)
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=self.CSV_ATTEMPT_FIELDS, extrasaction="ignore")
writer.writeheader()
for row in attempts:
writer.writerows([{k: self._sanitize_csv_value(v) for k, v in row.items()}])
return output.getvalue()
def export_replies_csv(self, campaign_id: str, user_id: str = "default") -> str:
storage = self._get_storage()
replies = storage.list_replies_all(campaign_id, user_id=user_id)
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=self.CSV_REPLY_FIELDS, extrasaction="ignore")
writer.writeheader()
for row in replies:
writer.writerows([{k: self._sanitize_csv_value(v) for k, v in row.items()}])
return output.getvalue()
async def deep_discover(self, keyword: str, max_results: int = 15) -> Dict[str, Any]:
"""Enhanced discovery using Exa neural search + DuckDuckGo with full-page scraping."""
from services.backlink_outreach_scraper import BacklinkOutreachScraper
@@ -212,9 +338,15 @@ class BacklinkOutreachService:
"typed opportunity records and confidence score",
"deep webpage scraping + contact-page extraction via Exa",
"quality scoring and guest-post signal detection",
"DB-backed policy validation with suppression & idempotency",
"outreach attempt recording + status lifecycle",
"SMTP email sending via backlink_outreach_sender",
"IMAP reply polling with auto-classification",
"follow-up scheduling with sent tracking",
"email template CRUD + AI generation (llm_text_gen)",
"personalized send via template variables",
]
planned = [
"email sending automation + response tracking",
"follow-up orchestration and campaign analytics",
]
return {

View File

@@ -2,13 +2,18 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, date
from uuid import uuid4
from typing import List, Optional
from sqlalchemy import text as sql_text
from sqlalchemy import text as sql_text, func as sa_func
from services.database import get_session_for_user
from models.backlink_outreach_models import Base, BacklinkCampaign, BacklinkLead
from models.backlink_outreach_models import (
Base, BacklinkCampaign, BacklinkLead,
OutreachAttempt, OutreachReply, FollowUpSchedule, EmailTemplate,
SuppressedRecipient, SentIdempotencyKey, AuditLogEntry,
SendCounterUser, SendCounterDomain,
)
class BacklinkOutreachStorageService:
@@ -29,11 +34,14 @@ class BacklinkOutreachStorageService:
def _migrate_lead_columns(self, db) -> None:
"""Add new columns to backlink_leads if they don't exist (dev migration)."""
try:
valid_columns = {"url", "page_title", "snippet", "confidence_score", "discovery_source", "notes"}
for col in self._NEW_LEAD_COLUMNS:
if col not in valid_columns:
continue
safe_col = col.replace('"', "").replace(";", "")
db.execute(sql_text(
f"ALTER TABLE backlink_leads ADD COLUMN IF NOT EXISTS {col} TEXT"
f"ALTER TABLE backlink_leads ADD COLUMN IF NOT EXISTS \"{safe_col}\" TEXT"
))
# confidence_score is Float, add separately
db.execute(sql_text(
"ALTER TABLE backlink_leads ADD COLUMN IF NOT EXISTS confidence_score FLOAT DEFAULT 0.0"
))
@@ -198,6 +206,7 @@ class BacklinkOutreachStorageService:
def update_lead_status(
self, lead_id: str, user_id: str, status: str, notes: Optional[str] = None
) -> Optional[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return None
@@ -229,3 +238,696 @@ class BacklinkOutreachStorageService:
"notes": lead.notes,
"created_at": lead.created_at.isoformat() if lead.created_at else None,
}
# -- Outreach Attempt CRUD --
def add_attempt(
self,
lead_id: str,
campaign_id: str,
idempotency_key: str,
sender_email: str = "",
subject: str = "",
body: str = "",
status: str = "queued",
decision_reason: Optional[str] = None,
user_id: str = "default",
) -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable")
try:
attempt = OutreachAttempt(
id=f"att_{uuid4().hex[:16]}",
lead_id=lead_id,
campaign_id=campaign_id,
idempotency_key=idempotency_key,
sender_email=sender_email,
subject=subject,
body=body,
status=status,
decision_reason=decision_reason,
created_at=datetime.utcnow(),
)
db.add(attempt)
db.commit()
return self._attempt_to_dict(attempt)
finally:
db.close()
def list_attempts(self, campaign_id: str, limit: int = 50, user_id: str = "default") -> List[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(OutreachAttempt)
.filter(OutreachAttempt.campaign_id == campaign_id)
.order_by(OutreachAttempt.created_at.desc())
.limit(limit)
.all()
)
return [self._attempt_to_dict(r) for r in rows]
finally:
db.close()
def update_attempt_status(self, attempt_id: str, status: str, decision_reason: Optional[str] = None, user_id: str = "default") -> Optional[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return None
try:
attempt = db.query(OutreachAttempt).filter(OutreachAttempt.id == attempt_id).first()
if not attempt:
return None
attempt.status = status
if decision_reason is not None:
attempt.decision_reason = decision_reason
if status == "sent":
attempt.sent_at = datetime.utcnow()
db.commit()
return self._attempt_to_dict(attempt)
finally:
db.close()
@staticmethod
def _attempt_to_dict(attempt) -> dict:
return {
"attempt_id": attempt.id,
"lead_id": attempt.lead_id,
"campaign_id": attempt.campaign_id,
"idempotency_key": attempt.idempotency_key,
"sender_email": attempt.sender_email or "",
"subject": attempt.subject or "",
"status": attempt.status,
"decision_reason": attempt.decision_reason,
"sent_at": attempt.sent_at.isoformat() if attempt.sent_at else None,
"created_at": attempt.created_at.isoformat() if attempt.created_at else None,
}
def find_attempt_by_from_email(self, from_email: str, user_id: str = "default") -> Optional[str]:
"""Find the most recent attempt_id for a given sender email (lead)."""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return None
try:
from sqlalchemy import desc
attempt = (
db.query(OutreachAttempt)
.join(BacklinkLead, OutreachAttempt.lead_id == BacklinkLead.id)
.filter(BacklinkLead.email == from_email)
.order_by(desc(OutreachAttempt.created_at))
.first()
)
return attempt.id if attempt else None
finally:
db.close()
# -- Outreach Reply CRUD --
def reply_exists(self, from_email: str, subject: str, user_id: str = "default") -> bool:
"""Check if a reply with this from_email+subject already exists."""
db = get_session_for_user(user_id)
if not db:
return False
try:
exists = (
db.query(OutreachReply.id)
.filter(OutreachReply.from_email == from_email, OutreachReply.subject == subject)
.first()
)
return exists is not None
finally:
db.close()
def add_reply(
self,
attempt_id: str,
from_email: str = "",
subject: str = "",
body: str = "",
classification: str = "replied",
user_id: str = "default",
) -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable")
try:
reply = OutreachReply(
id=f"rep_{uuid4().hex[:16]}",
attempt_id=attempt_id,
from_email=from_email,
subject=subject,
body=body,
classification=classification,
received_at=datetime.utcnow(),
)
db.add(reply)
db.commit()
return self._reply_to_dict(reply)
finally:
db.close()
def list_replies(self, campaign_id: str, limit: int = 50, user_id: str = "default") -> List[dict]:
"""List replies by joining through attempts to filter by campaign."""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(OutreachReply)
.join(OutreachAttempt, OutreachReply.attempt_id == OutreachAttempt.id)
.filter(OutreachAttempt.campaign_id == campaign_id)
.order_by(OutreachReply.received_at.desc())
.limit(limit)
.all()
)
return [self._reply_to_dict(r) for r in rows]
finally:
db.close()
@staticmethod
def _reply_to_dict(reply) -> dict:
return {
"reply_id": reply.id,
"attempt_id": reply.attempt_id,
"from_email": reply.from_email or "",
"subject": reply.subject or "",
"received_at": reply.received_at.isoformat() if reply.received_at else None,
"classification": reply.classification,
"body": reply.body or "",
}
# -- Follow-Up Schedule CRUD --
def schedule_followup(
self,
attempt_id: str,
scheduled_for: str,
subject: str = "",
body: str = "",
user_id: str = "default",
) -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable")
try:
sched = FollowUpSchedule(
id=f"fu_{uuid4().hex[:16]}",
attempt_id=attempt_id,
subject=subject or None,
body=body or None,
scheduled_for=datetime.fromisoformat(scheduled_for) if isinstance(scheduled_for, str) else scheduled_for,
sent=False,
)
db.add(sched)
db.commit()
return self._followup_to_dict(sched)
finally:
db.close()
def list_followups(self, campaign_id: str, limit: int = 50, user_id: str = "default") -> List[dict]:
"""List follow-ups by joining through attempts to filter by campaign."""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(FollowUpSchedule)
.join(OutreachAttempt, FollowUpSchedule.attempt_id == OutreachAttempt.id)
.filter(OutreachAttempt.campaign_id == campaign_id)
.order_by(FollowUpSchedule.scheduled_for.asc())
.limit(limit)
.all()
)
return [self._followup_to_dict(r) for r in rows]
finally:
db.close()
def mark_followup_sent(self, schedule_id: str, user_id: str = "default") -> Optional[dict]:
db = get_session_for_user(user_id)
if not db:
return None
try:
sched = db.query(FollowUpSchedule).filter(FollowUpSchedule.id == schedule_id).first()
if not sched:
return None
sched.sent = True
db.commit()
return self._followup_to_dict(sched)
finally:
db.close()
@staticmethod
def _followup_to_dict(sched) -> dict:
return {
"schedule_id": sched.id,
"attempt_id": sched.attempt_id,
"subject": sched.subject or "",
"scheduled_for": sched.scheduled_for.isoformat() if sched.scheduled_for else None,
"sent": sched.sent,
}
# -- Email Template CRUD --
def create_template(
self,
user_id: str,
name: str,
subject_template: str,
body_template: str,
variables: Optional[List[str]] = None,
) -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable")
try:
tmpl = EmailTemplate(
id=f"tpl_{uuid4().hex[:16]}",
user_id=user_id,
name=name,
subject_template=subject_template,
body_template=body_template,
variables=",".join(variables) if variables else None,
created_at=datetime.utcnow(),
)
db.add(tmpl)
db.commit()
return self._template_to_dict(tmpl)
finally:
db.close()
def list_templates(self, user_id: str, limit: int = 50) -> List[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(EmailTemplate)
.filter(EmailTemplate.user_id == user_id)
.order_by(EmailTemplate.created_at.desc())
.limit(limit)
.all()
)
return [self._template_to_dict(r) for r in rows]
finally:
db.close()
def get_template(self, template_id: str, user_id: str) -> Optional[dict]:
db = get_session_for_user(user_id)
if not db:
return None
try:
tmpl = (
db.query(EmailTemplate)
.filter(EmailTemplate.id == template_id, EmailTemplate.user_id == user_id)
.first()
)
if not tmpl:
return None
return self._template_to_dict(tmpl)
finally:
db.close()
def delete_template(self, template_id: str, user_id: str) -> bool:
db = get_session_for_user(user_id)
if not db:
return False
try:
tmpl = (
db.query(EmailTemplate)
.filter(EmailTemplate.id == template_id, EmailTemplate.user_id == user_id)
.first()
)
if not tmpl:
return False
db.delete(tmpl)
db.commit()
return True
finally:
db.close()
@staticmethod
def _template_to_dict(tmpl) -> dict:
return {
"template_id": tmpl.id,
"user_id": tmpl.user_id,
"name": tmpl.name,
"subject_template": tmpl.subject_template,
"body_template": tmpl.body_template,
"variables": tmpl.variables.split(",") if tmpl.variables else [],
"created_at": tmpl.created_at.isoformat() if tmpl.created_at else None,
}
# -- Suppression List --
def add_suppressed(self, email: str, user_id: str = "default", domain: str = "", reason: str = "") -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable")
try:
entry = SuppressedRecipient(
id=f"sup_{uuid4().hex[:16]}",
email=email.lower(),
domain=domain.lower() if domain else email.split("@")[-1].lower(),
reason=reason,
user_id=user_id,
created_at=datetime.utcnow(),
)
db.add(entry)
db.commit()
return {"id": entry.id, "email": entry.email, "reason": entry.reason}
finally:
db.close()
def is_suppressed(self, email: str, domain: str = "", user_id: str = "default") -> bool:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return False
try:
email_lower = email.lower()
domain_lower = domain.lower() if domain else email.split("@")[-1].lower()
exists = (
db.query(SuppressedRecipient.id)
.filter(
(SuppressedRecipient.email == email_lower) |
(SuppressedRecipient.domain == domain_lower)
)
.first()
)
return exists is not None
finally:
db.close()
def list_suppressed(self, user_id: str = "default", limit: int = 100) -> List[dict]:
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(SuppressedRecipient)
.order_by(SuppressedRecipient.created_at.desc())
.limit(limit)
.all()
)
return [{"id": r.id, "email": r.email, "domain": r.domain, "reason": r.reason, "created_at": r.created_at.isoformat() if r.created_at else None} for r in rows]
finally:
db.close()
# -- Idempotency --
def check_idempotency(self, idempotency_key: str, user_id: str = "default") -> bool:
"""Returns True if key already exists (duplicate)."""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return False
try:
exists = (
db.query(SentIdempotencyKey.id)
.filter(SentIdempotencyKey.idempotency_key == idempotency_key)
.first()
)
return exists is not None
finally:
db.close()
def mark_idempotency(self, idempotency_key: str, user_id: str = "default") -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable")
try:
entry = SentIdempotencyKey(
id=f"idm_{uuid4().hex[:16]}",
idempotency_key=idempotency_key,
user_id=user_id,
created_at=datetime.utcnow(),
)
db.add(entry)
db.commit()
return {"idempotency_key": idempotency_key}
finally:
db.close()
# -- Send Counters --
def _today(self) -> date:
return date.today()
def increment_user_send_counter(self, user_id: str) -> int:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return 0
try:
today = self._today()
row_id = f"scu_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_user (id, user_id, date, count) "
"VALUES (:id, :uid, :dt, 1) "
"ON CONFLICT (user_id, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "uid": user_id, "dt": today})
db.commit()
result = db.query(SendCounterUser.count).filter(
SendCounterUser.user_id == user_id, SendCounterUser.date == today
).first()
return result[0] if result else 0
finally:
db.close()
def get_user_send_count(self, user_id: str) -> int:
db = get_session_for_user(user_id)
if not db:
return 0
try:
today = self._today()
row = (
db.query(SendCounterUser.count)
.filter(SendCounterUser.user_id == user_id, SendCounterUser.date == today)
.first()
)
return row[0] if row else 0
finally:
db.close()
def increment_domain_send_counter(self, domain: str, user_id: str = "default") -> int:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return 0
try:
today = self._today()
domain_lower = domain.lower()
row_id = f"scd_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_domain (id, domain, date, count) "
"VALUES (:id, :dom, :dt, 1) "
"ON CONFLICT (domain, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "dom": domain_lower, "dt": today})
db.commit()
result = db.query(SendCounterDomain.count).filter(
SendCounterDomain.domain == domain_lower, SendCounterDomain.date == today
).first()
return result[0] if result else 0
finally:
db.close()
def get_domain_send_count(self, domain: str, user_id: str = "default") -> int:
db = get_session_for_user(user_id)
if not db:
return 0
try:
today = self._today()
row = (
db.query(SendCounterDomain.count)
.filter(SendCounterDomain.domain == domain.lower(), SendCounterDomain.date == today)
.first()
)
return row[0] if row else 0
finally:
db.close()
# -- Audit Log --
def add_audit_log(
self,
event: str,
user_id: str,
campaign_id: str = "",
recipient: str = "",
allowed: bool = False,
reasons: Optional[List[str]] = None,
override: bool = False,
) -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable")
try:
entry = AuditLogEntry(
id=f"aud_{uuid4().hex[:16]}",
user_id=user_id,
campaign_id=campaign_id or None,
event=event,
recipient=recipient or None,
allowed=allowed,
reasons=";".join(reasons) if reasons else None,
override=override,
created_at=datetime.utcnow(),
)
db.add(entry)
db.commit()
return {"id": entry.id, "event": entry.event, "allowed": entry.allowed}
finally:
db.close()
def list_audit_logs(self, campaign_id: Optional[str] = None, limit: int = 100, user_id: str = "default") -> List[dict]:
db = get_session_for_user(user_id)
if not db:
return []
try:
q = db.query(AuditLogEntry)
if campaign_id:
q = q.filter(AuditLogEntry.campaign_id == campaign_id)
rows = q.order_by(AuditLogEntry.created_at.desc()).limit(limit).all()
return [
{
"id": r.id,
"event": r.event,
"recipient": r.recipient,
"allowed": r.allowed,
"reasons": r.reasons.split(";") if r.reasons else [],
"override": r.override,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
finally:
db.close()
# -- Analytics --
def get_send_volume_by_day(self, campaign_id: str, days: int = 30, user_id: str = "default") -> List[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
from datetime import timedelta
cutoff = datetime.utcnow() - timedelta(days=days)
rows = (
db.query(sa_func.date(OutreachAttempt.sent_at).label("date"), sa_func.count(OutreachAttempt.id).label("count"))
.filter(OutreachAttempt.campaign_id == campaign_id, OutreachAttempt.status == "sent", OutreachAttempt.sent_at >= cutoff)
.group_by(sa_func.date(OutreachAttempt.sent_at))
.order_by(sa_func.date(OutreachAttempt.sent_at).asc())
.all()
)
return [{"date": str(r.date), "count": r.count} for r in rows]
finally:
db.close()
def get_lead_status_counts(self, campaign_id: str, user_id: str = "default") -> List[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(BacklinkLead.status, sa_func.count(BacklinkLead.id).label("count"))
.filter(BacklinkLead.campaign_id == campaign_id)
.group_by(BacklinkLead.status)
.order_by(BacklinkLead.status.asc())
.all()
)
return [{"status": r.status, "count": r.count} for r in rows]
finally:
db.close()
def list_attempts_all(self, campaign_id: str, user_id: str = "default") -> List[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(OutreachAttempt)
.filter(OutreachAttempt.campaign_id == campaign_id)
.order_by(OutreachAttempt.created_at.desc())
.all()
)
return [self._attempt_to_dict(r) for r in rows]
finally:
db.close()
def list_replies_all(self, campaign_id: str, user_id: str = "default") -> List[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(OutreachReply)
.join(OutreachAttempt, OutreachReply.attempt_id == OutreachAttempt.id)
.filter(OutreachAttempt.campaign_id == campaign_id)
.order_by(OutreachReply.received_at.desc())
.all()
)
return [self._reply_to_dict(r) for r in rows]
finally:
db.close()
def count_replies(self, campaign_id: str, user_id: str = "default") -> int:
db = get_session_for_user(user_id)
if not db:
return 0
try:
return (
db.query(OutreachReply.id)
.join(OutreachAttempt, OutreachReply.attempt_id == OutreachAttempt.id)
.filter(OutreachAttempt.campaign_id == campaign_id)
.count()
)
finally:
db.close()
def list_leads_all(self, campaign_id: str, user_id: str = "default") -> List[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return []
try:
rows = (
db.query(BacklinkLead)
.filter(BacklinkLead.campaign_id == campaign_id)
.order_by(BacklinkLead.created_at.desc())
.all()
)
return [self._lead_to_dict(r) for r in rows]
finally:
db.close()
# -- Policy Helpers (composite checks) --
def get_lead(self, lead_id: str, user_id: str = "default") -> Optional[dict]:
db = get_session_for_user(user_id)
if not db:
return None
try:
lead = db.query(BacklinkLead).filter(BacklinkLead.id == lead_id).first()
if not lead:
return None
return self._lead_to_dict(lead)
finally:
db.close()

View File

@@ -0,0 +1,307 @@
"""AI-powered outreach email template generation."""
from __future__ import annotations
import json
import re
from typing import List, Optional
from loguru import logger
from services.llm_providers.main_text_generation import llm_text_gen
SYSTEM_PROMPT = """You are an expert outreach copywriter specializing in guest post and backlink pitch emails.
Write concise, personalized outreach emails that get high response rates.
Follow these rules:
- Be specific about why you're reaching out (mention their content)
- Keep it under 200 words
- Include a clear call to action
- Sound human, not templated
- Never use spammy phrases
- Output ONLY valid JSON with "subject" and "body" keys"""
SUBJECT_LINES_PROMPT = """You are an expert email subject line writer.
Given an outreach email body, generate subject lines that are:
- Intriguing but not clickbait
- Personalized when possible
- Under 60 characters
- Varied in style (question, curiosity, value-prop)
Output ONLY valid JSON with a "subjects" key containing an array of strings."""
FOLLOW_UP_PROMPT = """You are an expert outreach copywriter.
Write a polite follow-up email for a guest post pitch that hasn't received a response.
Rules:
- Reference the original email without repeating it verbatim
- Keep it shorter than the original (under 100 words)
- Add a new angle or piece of value
- Include a clear call to action
- Sound human and respectful, never pushy
- Output ONLY valid JSON with "subject" and "body" keys"""
PERSONALIZATION_PROMPT = """You are an expert outreach personalization specialist.
Given a lead's information and a draft outreach email, personalize it for that specific lead.
Rules:
- Mention their specific content or website
- Reference something relevant from their site
- Keep the core pitch but make it feel custom-written
- Under 200 words
- Output ONLY valid JSON with "subject" and "body" keys"""
def generate_outreach_email(
topic: str,
target_site: Optional[str] = None,
tone: str = "professional",
user_id: str = "default",
existing_body: Optional[str] = None,
) -> dict:
"""Generate an outreach email using the LLM.
Args:
topic: The topic/keyword to pitch.
target_site: Optional target website name/URL.
tone: professional, friendly, casual, or formal.
user_id: Clerk user ID for subscription check.
existing_body: If provided, rewrite/improve this existing template.
Returns:
dict with "subject" and "body" keys.
"""
if existing_body:
prompt = (
f"Rewrite and improve the following outreach email for a {tone} tone. "
f"Topic: {topic}. "
f"{f'Target website: {target_site}. ' if target_site else ''}"
f"Keep the core message but make it more effective. "
f"Original email:\n\n{existing_body}\n\n"
f"Return ONLY valid JSON with 'subject' and 'body' keys."
)
else:
prompt = (
f"Write a {tone} outreach email for a guest post opportunity about: {topic}. "
f"{f'We are pitching this to: {target_site}. ' if target_site else ''}"
f"Mention specific value the guest post would bring to their audience. "
f"Return ONLY valid JSON with 'subject' and 'body' keys."
)
try:
raw = llm_text_gen(
prompt=prompt,
system_prompt=SYSTEM_PROMPT,
user_id=user_id,
temperature=0.7,
)
result = _parse_json_response(raw)
if result:
return result
return _fallback_extract(raw, topic)
except Exception as e:
logger.error(f"Failed to generate outreach email: {e}")
return {
"subject": f"Guest post opportunity: {topic}",
"body": f"Hi there,\n\nI came across your site and I'd love to contribute a guest post about {topic}. "
f"Please let me know if you're open to submissions.\n\nBest regards",
}
def generate_personalized_email(
lead_name: str,
lead_site: str,
lead_content_topic: str,
pitch_topic: str,
existing_body: str = "",
user_id: str = "default",
) -> dict:
"""Personalize an outreach email for a specific lead.
Args:
lead_name: Contact name or site owner name.
lead_site: The lead's website URL.
lead_content_topic: Topic of relevant content on their site.
pitch_topic: The topic we want to pitch.
existing_body: Optional draft to personalize further.
user_id: Clerk user ID for subscription check.
Returns:
dict with "subject" and "body" keys.
"""
if existing_body:
prompt = (
f"Personalize this outreach email for {lead_name} from {lead_site}. "
f"They have content about '{lead_content_topic}'. "
f"We want to pitch: {pitch_topic}. "
f"Mention something specific about their content on {lead_content_topic} "
f"to show we've done our research. "
f"Draft email to personalize:\n\n{existing_body}\n\n"
f"Return ONLY valid JSON with 'subject' and 'body' keys."
)
else:
prompt = (
f"Write a personalized outreach email to {lead_name} at {lead_site}. "
f"They have published content about '{lead_content_topic}'. "
f"We want to pitch a guest post about: {pitch_topic}. "
f"Reference their article on {lead_content_topic} and explain how our pitch "
f"would provide value to their audience. "
f"Return ONLY valid JSON with 'subject' and 'body' keys."
)
try:
raw = llm_text_gen(
prompt=prompt,
system_prompt=PERSONALIZATION_PROMPT,
user_id=user_id,
temperature=0.7,
)
result = _parse_json_response(raw)
if result:
return result
return _fallback_extract(raw, pitch_topic)
except Exception as e:
logger.error(f"Failed to personalize email: {e}")
return {"subject": f"Question about your content on {lead_content_topic}", "body": existing_body or f"Hi {lead_name},\n\nI enjoyed your article about {lead_content_topic}..."}
def generate_subject_lines(
body: str,
count: int = 5,
user_id: str = "default",
) -> List[str]:
"""Generate subject line suggestions for an email body.
Args:
body: The email body to generate subject lines for.
count: Number of subject lines to generate.
user_id: Clerk user ID for subscription check.
Returns:
List of subject line strings.
"""
prompt = (
f"Generate {count} subject lines for the following outreach email. "
f"Make them varied in style and optimized for open rates.\n\n"
f"Email body:\n{body}\n\n"
f"Return ONLY valid JSON with a 'subjects' key containing an array of strings."
)
try:
raw = llm_text_gen(
prompt=prompt,
system_prompt=SUBJECT_LINES_PROMPT,
user_id=user_id,
temperature=0.8,
)
if raw:
text = raw.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
data = json.loads(text)
if isinstance(data, dict) and "subjects" in data and isinstance(data["subjects"], list):
return [s.strip() for s in data["subjects"][:count]]
except json.JSONDecodeError:
pass
lines = [l.strip("- ").strip() for l in raw.strip().split("\n") if l.strip() and not l.strip().startswith("```")]
return [l for l in lines if len(l) > 10][:count]
except Exception as e:
logger.error(f"Failed to generate subject lines: {e}")
return [f"Guest post opportunity", f"Question about your content", f"Collaboration idea"]
def generate_follow_up(
original_subject: str,
original_body: str,
days_elapsed: int = 7,
reply_context: str = "",
user_id: str = "default",
) -> dict:
"""Generate a follow-up email for an outreach that hasn't received a response.
Args:
original_subject: Subject line of the original email.
original_body: Body of the original email.
days_elapsed: Number of days since the original was sent.
reply_context: If the recipient replied, context of their reply.
user_id: Clerk user ID for subscription check.
Returns:
dict with "subject" and "body" keys.
"""
if reply_context:
prompt = (
f"The recipient replied with: '{reply_context}'. "
f"Write a follow-up email that addresses their response and keeps the conversation moving. "
f"Original subject: {original_subject}.\n\n"
f"Original email:\n{original_body}\n\n"
f"Return ONLY valid JSON with 'subject' and 'body' keys."
)
else:
prompt = (
f"Write a polite follow-up email. {days_elapsed} days have passed since the original email. "
f"Do not apologize for following up. Add a new piece of value or angle. "
f"Original subject: {original_subject}.\n\n"
f"Original email:\n{original_body}\n\n"
f"Return ONLY valid JSON with 'subject' and 'body' keys."
)
try:
raw = llm_text_gen(
prompt=prompt,
system_prompt=FOLLOW_UP_PROMPT,
user_id=user_id,
temperature=0.7,
)
result = _parse_json_response(raw)
if result:
return result
return _fallback_extract(raw, original_subject)
except Exception as e:
logger.error(f"Failed to generate follow-up: {e}")
return {
"subject": f"Re: {original_subject}",
"body": f"Hi there,\n\nI wanted to follow up on my previous email. "
f"I'd love to hear your thoughts when you have a moment.\n\nBest regards",
}
def _parse_json_response(raw: str) -> Optional[dict]:
"""Try to parse JSON from LLM response, handling markdown fences."""
if not raw:
return None
text = raw.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
data = json.loads(text)
if isinstance(data, dict) and "subject" in data and "body" in data:
return {"subject": data["subject"].strip(), "body": data["body"].strip()}
except json.JSONDecodeError:
pass
return None
def _fallback_extract(raw: str, topic: str) -> dict:
"""Fallback: try to extract subject line and body from unstructured text."""
lines = [l.strip() for l in raw.strip().split("\n") if l.strip()]
subject = topic
body_lines = []
for i, line in enumerate(lines):
lower = line.lower()
if lower.startswith("subject") or lower.startswith("subject:"):
subject = line.split(":", 1)[-1].strip()
elif lower.startswith("body") or lower.startswith("body:"):
body_lines.append(line.split(":", 1)[-1].strip())
else:
body_lines.append(line)
body = "\n".join(body_lines) if body_lines else raw
return {"subject": subject, "body": body}

View File

@@ -0,0 +1,79 @@
"""
Shared OAuth callback utilities for Wix and WordPress integrations.
Provides hardened postMessage-based HTML callback generation, origin
validation, and string sanitization used across OAuth callback routes.
"""
import json
import os
from typing import Any, Optional
from urllib.parse import urlparse
def sanitize_string(value: Any, max_len: int = 500) -> str:
if value is None:
return ""
return " ".join(str(value).split())[:max_len]
def sanitize_error(error: Exception, max_len: int = 500) -> str:
return sanitize_string(error, max_len)
def normalize_origin(url: Optional[str]) -> Optional[str]:
if not url:
return None
parsed = urlparse(url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
return f"{parsed.scheme}://{parsed.netloc}"
def trusted_frontend_origin() -> Optional[str]:
origins_env = os.getenv("OAUTH_CALLBACK_ALLOWED_ORIGINS", "")
configured = [
origin
for origin in (normalize_origin(o) for o in origins_env.split(",") if o.strip())
if origin is not None
]
if configured:
return configured[0]
return normalize_origin(os.getenv("FRONTEND_URL"))
def build_oauth_callback_html(
payload: dict,
title: str,
heading: str,
message: str,
) -> str:
trusted_origin = trusted_frontend_origin()
payload_json = json.dumps(payload)
target_origin_json = json.dumps(trusted_origin or "")
heading_html = heading.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
message_html = message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return f"""
<!DOCTYPE html>
<html>
<head><title>{title}</title></head>
<body>
<h1>{heading_html}</h1>
<p>{message_html}</p>
<script>
(function() {{
var payload = {payload_json};
var targetOrigin = {target_origin_json};
var destination = window.opener || window.parent;
if (destination && targetOrigin) {{
try {{
destination.postMessage(payload, targetOrigin);
window.close();
return;
}} catch (_e) {{}}
}}
}})();
</script>
</body>
</html>
"""

View File

@@ -8,7 +8,7 @@ import sqlite3
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from loguru import logger
from cryptography.fernet import Fernet, InvalidToken
from services.database import get_user_db_path
@@ -17,6 +17,66 @@ class WixOAuthService:
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path
self.token_encryption_key = (
os.getenv("WIX_TOKEN_ENCRYPTION_KEY")
or os.getenv("OAUTH_TOKEN_ENCRYPTION_KEY")
)
self._fernet = self._initialize_fernet()
self._migration_done: set = set()
def _initialize_fernet(self) -> Optional[Fernet]:
if not self.token_encryption_key:
logger.error("Wix token encryption key is not configured.")
return None
try:
return Fernet(self.token_encryption_key.encode("utf-8"))
except Exception:
logger.error("Wix token encryption key is invalid.")
return None
def _encrypt_token(self, token: Optional[str]) -> Optional[str]:
if not token:
return None
if not self._fernet:
raise ValueError("Token encryption is unavailable: missing/invalid managed key")
return self._fernet.encrypt(token.encode("utf-8")).decode("utf-8")
def _decrypt_token(self, token_blob: Optional[str]) -> Optional[str]:
if not token_blob:
return None
if not self._fernet:
raise ValueError("Token decryption is unavailable: missing/invalid managed key")
return self._fernet.decrypt(token_blob.encode("utf-8")).decode("utf-8")
def _is_likely_encrypted_blob(self, value: Optional[str]) -> bool:
return bool(value and value.startswith("gAAAAA"))
def _migrate_plaintext_tokens_if_needed(self, conn: sqlite3.Connection, user_id: str) -> None:
if not self._fernet or user_id in self._migration_done:
return
cursor = conn.cursor()
cursor.execute(
"SELECT id, access_token, refresh_token FROM wix_oauth_tokens WHERE user_id = ?",
(user_id,),
)
rows = cursor.fetchall()
migrated = 0
for token_id, access_token, refresh_token in rows:
needs_access = access_token and not self._is_likely_encrypted_blob(access_token)
needs_refresh = refresh_token and not self._is_likely_encrypted_blob(refresh_token)
if not (needs_access or needs_refresh):
continue
enc_access = self._encrypt_token(access_token) if needs_access else access_token
enc_refresh = self._encrypt_token(refresh_token) if needs_refresh else refresh_token
cursor.execute(
"UPDATE wix_oauth_tokens SET access_token = ?, refresh_token = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
(enc_access, enc_refresh, token_id, user_id),
)
migrated += 1
if migrated:
conn.commit()
logger.info(f"Wix OAuth token migration completed for user {user_id}; rows migrated={migrated}")
self._migration_done.add(user_id)
def _get_db_path(self, user_id: str) -> str:
if self.db_path:
@@ -173,13 +233,16 @@ class WixOAuthService:
if expires_in:
expires_at = datetime.now() + timedelta(seconds=expires_in)
encrypted_access = self._encrypt_token(access_token)
encrypted_refresh = self._encrypt_token(refresh_token) if refresh_token else None
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO wix_oauth_tokens
(user_id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (user_id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id))
''', (user_id, encrypted_access, encrypted_refresh, token_type, expires_at, expires_in, scope, site_id, member_id))
conn.commit()
logger.info(f"Wix OAuth: Token inserted into database for user {user_id}")
@@ -200,6 +263,7 @@ class WixOAuthService:
return []
with sqlite3.connect(db_path) as conn:
self._migrate_plaintext_tokens_if_needed(conn, user_id)
cursor = conn.cursor()
cursor.execute('''
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, created_at
@@ -210,10 +274,29 @@ class WixOAuthService:
tokens = []
for row in cursor.fetchall():
access_token_val = row[1]
refresh_token_val = row[2]
try:
decrypted_access = (
self._decrypt_token(access_token_val)
if self._is_likely_encrypted_blob(access_token_val)
else access_token_val
)
except InvalidToken:
logger.error(f"Failed to decrypt Wix access token for user {user_id}, token_id={row[0]}")
continue
try:
decrypted_refresh = (
self._decrypt_token(refresh_token_val)
if self._is_likely_encrypted_blob(refresh_token_val)
else refresh_token_val
)
except InvalidToken:
decrypted_refresh = None
tokens.append({
"id": row[0],
"access_token": row[1],
"refresh_token": row[2],
"access_token": decrypted_access,
"refresh_token": decrypted_refresh,
"token_type": row[3],
"expires_at": row[4],
"expires_in": row[5],
@@ -248,9 +331,9 @@ class WixOAuthService:
}
with sqlite3.connect(db_path) as conn:
self._migrate_plaintext_tokens_if_needed(conn, user_id)
cursor = conn.cursor()
# Get all tokens (active and expired)
cursor.execute('''
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, created_at, is_active
FROM wix_oauth_tokens
@@ -263,10 +346,29 @@ class WixOAuthService:
expired_tokens = []
for row in cursor.fetchall():
access_token_val = row[1]
refresh_token_val = row[2]
try:
decrypted_access = (
self._decrypt_token(access_token_val)
if self._is_likely_encrypted_blob(access_token_val)
else access_token_val
)
except InvalidToken:
decrypted_access = None
try:
decrypted_refresh = (
self._decrypt_token(refresh_token_val)
if self._is_likely_encrypted_blob(refresh_token_val)
else refresh_token_val
)
except InvalidToken:
decrypted_refresh = None
token_data = {
"id": row[0],
"access_token": row[1],
"refresh_token": row[2],
"access_token": decrypted_access,
"refresh_token": decrypted_refresh,
"token_type": row[3],
"expires_at": row[4],
"expires_in": row[5],
@@ -331,34 +433,46 @@ class WixOAuthService:
user_id: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None
expires_in: Optional[int] = None,
token_id: Optional[int] = None
) -> bool:
"""Update tokens for a user (e.g., after refresh)."""
try:
# Ensure DB initialized for this user
self._init_db(user_id)
db_path = self._get_db_path(user_id)
expires_at = None
if expires_in:
expires_at = datetime.now() + timedelta(seconds=expires_in)
encrypted_access = self._encrypt_token(access_token)
encrypted_refresh = self._encrypt_token(refresh_token) if refresh_token else None
with sqlite3.connect(db_path) as conn:
self._migrate_plaintext_tokens_if_needed(conn, user_id)
cursor = conn.cursor()
if refresh_token:
cursor.execute('''
UPDATE wix_oauth_tokens
SET access_token = ?, refresh_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now')
WHERE user_id = ? AND refresh_token = ?
''', (access_token, refresh_token, expires_at, expires_in, user_id, refresh_token))
if token_id:
if encrypted_refresh:
cursor.execute('''
UPDATE wix_oauth_tokens
SET access_token = ?, refresh_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now')
WHERE user_id = ? AND id = ?
''', (encrypted_access, encrypted_refresh, expires_at, expires_in, user_id, token_id))
else:
cursor.execute('''
UPDATE wix_oauth_tokens
SET access_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now')
WHERE user_id = ? AND id = ?
''', (encrypted_access, expires_at, expires_in, user_id, token_id))
else:
cursor.execute('''
UPDATE wix_oauth_tokens
SET access_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now')
WHERE user_id = ? AND id = (SELECT id FROM wix_oauth_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1)
''', (access_token, expires_at, expires_in, user_id, user_id))
''', (encrypted_access, expires_at, expires_in, user_id, user_id))
conn.commit()
logger.info(f"Wix OAuth: Tokens updated for user {user_id}")

View File

@@ -343,7 +343,7 @@ class GoogleTrendsService:
logger.info(
f"[Trends] ===== DONE analyze_trends ===== total={total_ms}ms "
f"iot={len(interest_over_time)} ibr={len(interest_by_region)} "
f"rt_top={rt_top} rq_top={rq_top}"
f"rt_top={len(related_topics.get('top', []))} rq_top={len(related_queries.get('top', []))}"
)
result = {

View File

@@ -548,9 +548,11 @@ def validate_video_generation_operations(
def validate_scene_animation_operation(
pricing_service: PricingService,
user_id: str,
scene_count: int = 1,
) -> None:
"""
Validate the per-scene animation workflow before API calls.
Validates that the user has sufficient credits for *all* scenes in the batch.
"""
try:
operations_to_validate = [
@@ -560,6 +562,7 @@ def validate_scene_animation_operation(
'actual_provider_name': 'wavespeed',
'operation_type': 'scene_animation',
}
for _ in range(scene_count)
]
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
@@ -581,9 +584,8 @@ def validate_scene_animation_operation(
}
)
logger.info(f"[Pre-flight Validator] ✅ Scene animation validated for user {user_id}")
# Validation passed - no return needed (function raises HTTPException if validation fails)
logger.info(f"[Pre-flight Validator] ✅ Scene animation validated for user {user_id} ({scene_count} scene(s))")
except HTTPException:
raise
except Exception as e:
@@ -730,9 +732,11 @@ def validate_video_generation_operations(
def validate_scene_animation_operation(
pricing_service: PricingService,
user_id: str,
scene_count: int = 1,
) -> None:
"""
Validate the per-scene animation workflow before API calls.
Validates that the user has sufficient credits for *all* scenes in the batch.
"""
try:
operations_to_validate = [
@@ -742,6 +746,7 @@ def validate_scene_animation_operation(
'actual_provider_name': 'wavespeed',
'operation_type': 'scene_animation',
}
for _ in range(scene_count)
]
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
@@ -763,7 +768,7 @@ def validate_scene_animation_operation(
}
)
logger.info(f"[Pre-flight Validator] ✅ Scene animation validated for user {user_id}")
logger.info(f"[Pre-flight Validator] ✅ Scene animation validated for user {user_id} ({scene_count} scene(s))")
except HTTPException:
raise

View File

@@ -566,10 +566,10 @@ class PricingService:
"firecrawl_calls_limit": 0, # DISABLED: Firecrawl not in Free tier
"stability_calls_limit": 3, # 3 images - enough to try the product
"exa_calls_limit": 10, # 10 research queries - enough to try the product
"video_calls_limit": 0, # DISABLED: Video generation not in Free tier
"video_calls_limit": 2, # 2 video renders - try podcast video on Free
"image_edit_calls_limit": 5, # 5 image edits - enough to try the product
"audio_calls_limit": 5, # 5 audio clips - enough to try the product
"wavespeed_calls_limit": 0, # DISABLED: WaveSpeed not included in Free tier
"wavespeed_calls_limit": 0, # 0 = unlimited for Free; video controlled via video_calls_limit
"gemini_tokens_limit": 50000,
"openai_tokens_limit": 0, # DISABLED
"anthropic_tokens_limit": 0, # DISABLED