diff --git a/backend/routers/backlink_outreach.py b/backend/routers/backlink_outreach.py index 1900f38b..5ec39c7c 100644 --- a/backend/routers/backlink_outreach.py +++ b/backend/routers/backlink_outreach.py @@ -260,18 +260,29 @@ async def send_outreach( subject = backlink_outreach_sender.personalize(tmpl.get("subject_template", subject), variables) body = backlink_outreach_sender.personalize(tmpl.get("body_template", body), variables) + sender_validation = backlink_outreach_sender.validate_sender_alias(payload.sender_email) + if not sender_validation.authorized: + return SendOutreachResponse( + attempt_id="", + status="failed", + policy_allowed=False, + policy_reasons=sender_validation.failure_reasons, + effective_sender_email=sender_validation.effective_sender_email or None, + ) + 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, + sender_email=sender_validation.effective_sender_email, subject=subject, body=body, idempotency_key=payload.idempotency_key, ) ) + result.effective_sender_email = sender_validation.effective_sender_email lead_email = "" if result.attempt_id: @@ -279,15 +290,19 @@ async def send_outreach( lead_email = (lead.get("email") or "") if lead else "" if result.policy_allowed and lead_email: - sent = await backlink_outreach_sender.send_email( + send_result = await backlink_outreach_sender.send_email( to_email=lead_email, subject=subject, body=body, + from_email=payload.sender_email, ) - status = "sent" if sent else "failed" + status = "sent" if send_result.success else "failed" storage.update_attempt_status(result.attempt_id, status, user_id=user_id) result.status = status - if sent: + result.effective_sender_email = send_result.effective_sender_email or result.effective_sender_email + if send_result.failure_reasons: + result.policy_reasons = (result.policy_reasons or []) + send_result.failure_reasons + if send_result.success: 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" diff --git a/backend/services/backlink_outreach_models.py b/backend/services/backlink_outreach_models.py index 8f7e0873..a5d6e0e3 100644 --- a/backend/services/backlink_outreach_models.py +++ b/backend/services/backlink_outreach_models.py @@ -166,6 +166,7 @@ class SendOutreachResponse(BaseModel): status: str policy_allowed: bool policy_reasons: List[str] = Field(default_factory=list) + effective_sender_email: Optional[str] = None class OutreachAttemptRecord(BaseModel): diff --git a/backend/services/backlink_outreach_sender.py b/backend/services/backlink_outreach_sender.py index 4eba9061..530c4cf8 100644 --- a/backend/services/backlink_outreach_sender.py +++ b/backend/services/backlink_outreach_sender.py @@ -6,9 +6,10 @@ import os import ssl import smtplib import asyncio +from dataclasses import dataclass, field from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -from typing import Optional +from typing import List, Optional, Set from loguru import logger @@ -17,11 +18,26 @@ 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_ALLOWED_FROM_EMAILS = os.getenv("SMTP_ALLOWED_FROM_EMAILS", "") 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")) +@dataclass +class SenderAuthorizationResult: + authorized: bool + effective_sender_email: str = "" + failure_reasons: List[str] = field(default_factory=list) + + +@dataclass +class SendEmailResult: + success: bool + effective_sender_email: str = "" + failure_reasons: List[str] = field(default_factory=list) + + class BacklinkOutreachSender: def __init__(self): self._host = SMTP_HOST @@ -29,6 +45,7 @@ class BacklinkOutreachSender: self._username = SMTP_USERNAME self._password = SMTP_PASSWORD self._from_email = SMTP_FROM_EMAIL or SMTP_USERNAME + self._allowed_from_emails = SMTP_ALLOWED_FROM_EMAILS self._use_tls = SMTP_USE_TLS self._verify_tls = SMTP_VERIFY_TLS self._timeout = SMTP_SEND_TIMEOUT @@ -36,18 +53,68 @@ class BacklinkOutreachSender: def is_configured(self) -> bool: return bool(self._username and self._password) + @staticmethod + def _normalize_email(email: Optional[str]) -> str: + return (email or "").strip().lower() + + def _allowed_sender_aliases(self) -> Set[str]: + aliases = { + self._normalize_email(alias) + for alias in self._allowed_from_emails.split(",") + if self._normalize_email(alias) + } + for configured_sender in (self._from_email, self._username): + normalized = self._normalize_email(configured_sender) + if normalized: + aliases.add(normalized) + return aliases + + def validate_sender_alias(self, from_email: Optional[str] = None) -> SenderAuthorizationResult: + default_sender = self._normalize_email(self._from_email or self._username) + requested_sender = self._normalize_email(from_email) or default_sender + + if not self.is_configured(): + return SenderAuthorizationResult( + authorized=False, + effective_sender_email=requested_sender, + failure_reasons=["smtp_not_configured"], + ) + if not requested_sender: + return SenderAuthorizationResult( + authorized=False, + failure_reasons=["smtp_sender_missing"], + ) + + allowed_aliases = self._allowed_sender_aliases() + if requested_sender not in allowed_aliases: + return SenderAuthorizationResult( + authorized=False, + effective_sender_email=requested_sender, + failure_reasons=["sender_alias_not_authorized"], + ) + + return SenderAuthorizationResult( + authorized=True, + effective_sender_email=requested_sender, + ) + 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 + ) -> SendEmailResult: + sender_validation = self.validate_sender_alias(from_email) + if not sender_validation.authorized: + logger.error(f"SMTP sender validation failed: {sender_validation.failure_reasons}") + return SendEmailResult( + success=False, + effective_sender_email=sender_validation.effective_sender_email, + failure_reasons=sender_validation.failure_reasons, + ) - sender = from_email or self._from_email + sender = sender_validation.effective_sender_email msg = MIMEMultipart("alternative") msg["From"] = sender @@ -78,7 +145,12 @@ class BacklinkOutreachSender: logger.error(f"Unexpected error sending to {to_email}: {e}") return False - return await loop.run_in_executor(None, _send) + success = await loop.run_in_executor(None, _send) + return SendEmailResult( + success=success, + effective_sender_email=sender, + failure_reasons=[] if success else ["smtp_send_failed"], + ) def personalize(self, template: str, variables: dict) -> str: """Replace {placeholder} variables in a template string.""" diff --git a/backend/services/backlink_outreach_service.py b/backend/services/backlink_outreach_service.py index da8846cd..3ef2ff79 100644 --- a/backend/services/backlink_outreach_service.py +++ b/backend/services/backlink_outreach_service.py @@ -241,6 +241,7 @@ class BacklinkOutreachService: status=attempt.get("status", "failed"), policy_allowed=policy.allowed, policy_reasons=policy.reasons, + effective_sender_email=request.sender_email, ) def get_reporting_snapshot(self, user_id: str = "default") -> Dict[str, Any]: diff --git a/frontend/src/api/backlinkOutreachApi.ts b/frontend/src/api/backlinkOutreachApi.ts index 4e6f07ea..826002bf 100644 --- a/frontend/src/api/backlinkOutreachApi.ts +++ b/frontend/src/api/backlinkOutreachApi.ts @@ -192,6 +192,7 @@ export interface SendOutreachResponse { status: string; policy_allowed: boolean; policy_reasons: string[]; + effective_sender_email?: string | null; } export interface OutreachAttemptRecord { diff --git a/frontend/src/components/BacklinkOutreach/BacklinkOutreachDashboard.tsx b/frontend/src/components/BacklinkOutreach/BacklinkOutreachDashboard.tsx index 35f9d8d9..9bc0eb47 100644 --- a/frontend/src/components/BacklinkOutreach/BacklinkOutreachDashboard.tsx +++ b/frontend/src/components/BacklinkOutreach/BacklinkOutreachDashboard.tsx @@ -708,6 +708,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
Latest: {a.subject} — {renderStatusBadge(a.status)} + {a.sender_email && From: {a.sender_email}} {a.sent_at && {new Date(a.sent_at).toLocaleString()}}
))} @@ -724,7 +725,7 @@ const BacklinkOutreachDashboard: React.FC = () => { - {['Subject', 'Status', 'Sender', 'Sent At'].map(h => ( + {['Subject', 'Status', 'Effective Sender', 'Sent At'].map(h => ( ))}
{h}