Validate backlink outreach sender aliases
This commit is contained in:
@@ -260,18 +260,29 @@ async def send_outreach(
|
|||||||
subject = backlink_outreach_sender.personalize(tmpl.get("subject_template", subject), variables)
|
subject = backlink_outreach_sender.personalize(tmpl.get("subject_template", subject), variables)
|
||||||
body = backlink_outreach_sender.personalize(tmpl.get("body_template", body), 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(
|
result = backlink_outreach_service.send_outreach(
|
||||||
SendOutreachRequest(
|
SendOutreachRequest(
|
||||||
lead_id=payload.lead_id,
|
lead_id=payload.lead_id,
|
||||||
campaign_id=payload.campaign_id,
|
campaign_id=payload.campaign_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
workspace_id=payload.workspace_id,
|
workspace_id=payload.workspace_id,
|
||||||
sender_email=payload.sender_email,
|
sender_email=sender_validation.effective_sender_email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
body=body,
|
||||||
idempotency_key=payload.idempotency_key,
|
idempotency_key=payload.idempotency_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
result.effective_sender_email = sender_validation.effective_sender_email
|
||||||
|
|
||||||
lead_email = ""
|
lead_email = ""
|
||||||
if result.attempt_id:
|
if result.attempt_id:
|
||||||
@@ -279,15 +290,19 @@ async def send_outreach(
|
|||||||
lead_email = (lead.get("email") or "") if lead else ""
|
lead_email = (lead.get("email") or "") if lead else ""
|
||||||
|
|
||||||
if result.policy_allowed and lead_email:
|
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,
|
to_email=lead_email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
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)
|
storage.update_attempt_status(result.attempt_id, status, user_id=user_id)
|
||||||
result.status = status
|
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.mark_idempotency(payload.idempotency_key, user_id)
|
||||||
storage.increment_user_send_counter(user_id)
|
storage.increment_user_send_counter(user_id)
|
||||||
domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown"
|
domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown"
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class SendOutreachResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
policy_allowed: bool
|
policy_allowed: bool
|
||||||
policy_reasons: List[str] = Field(default_factory=list)
|
policy_reasons: List[str] = Field(default_factory=list)
|
||||||
|
effective_sender_email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class OutreachAttemptRecord(BaseModel):
|
class OutreachAttemptRecord(BaseModel):
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import os
|
|||||||
import ssl
|
import ssl
|
||||||
import smtplib
|
import smtplib
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from typing import Optional
|
from typing import List, Optional, Set
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -17,11 +18,26 @@ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|||||||
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
|
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
|
||||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||||
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USERNAME)
|
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_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_VERIFY_TLS = os.getenv("SMTP_VERIFY_TLS", "true").lower() in ("true", "1", "yes")
|
||||||
SMTP_SEND_TIMEOUT = int(os.getenv("SMTP_SEND_TIMEOUT", "30"))
|
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:
|
class BacklinkOutreachSender:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._host = SMTP_HOST
|
self._host = SMTP_HOST
|
||||||
@@ -29,6 +45,7 @@ class BacklinkOutreachSender:
|
|||||||
self._username = SMTP_USERNAME
|
self._username = SMTP_USERNAME
|
||||||
self._password = SMTP_PASSWORD
|
self._password = SMTP_PASSWORD
|
||||||
self._from_email = SMTP_FROM_EMAIL or SMTP_USERNAME
|
self._from_email = SMTP_FROM_EMAIL or SMTP_USERNAME
|
||||||
|
self._allowed_from_emails = SMTP_ALLOWED_FROM_EMAILS
|
||||||
self._use_tls = SMTP_USE_TLS
|
self._use_tls = SMTP_USE_TLS
|
||||||
self._verify_tls = SMTP_VERIFY_TLS
|
self._verify_tls = SMTP_VERIFY_TLS
|
||||||
self._timeout = SMTP_SEND_TIMEOUT
|
self._timeout = SMTP_SEND_TIMEOUT
|
||||||
@@ -36,18 +53,68 @@ class BacklinkOutreachSender:
|
|||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(self._username and self._password)
|
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(
|
async def send_email(
|
||||||
self,
|
self,
|
||||||
to_email: str,
|
to_email: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
from_email: Optional[str] = None,
|
from_email: Optional[str] = None,
|
||||||
) -> bool:
|
) -> SendEmailResult:
|
||||||
if not self.is_configured():
|
sender_validation = self.validate_sender_alias(from_email)
|
||||||
logger.error("SMTP not configured: set SMTP_USERNAME and SMTP_PASSWORD")
|
if not sender_validation.authorized:
|
||||||
return False
|
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 = MIMEMultipart("alternative")
|
||||||
msg["From"] = sender
|
msg["From"] = sender
|
||||||
@@ -78,7 +145,12 @@ class BacklinkOutreachSender:
|
|||||||
logger.error(f"Unexpected error sending to {to_email}: {e}")
|
logger.error(f"Unexpected error sending to {to_email}: {e}")
|
||||||
return False
|
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:
|
def personalize(self, template: str, variables: dict) -> str:
|
||||||
"""Replace {placeholder} variables in a template string."""
|
"""Replace {placeholder} variables in a template string."""
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ class BacklinkOutreachService:
|
|||||||
status=attempt.get("status", "failed"),
|
status=attempt.get("status", "failed"),
|
||||||
policy_allowed=policy.allowed,
|
policy_allowed=policy.allowed,
|
||||||
policy_reasons=policy.reasons,
|
policy_reasons=policy.reasons,
|
||||||
|
effective_sender_email=request.sender_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_reporting_snapshot(self, user_id: str = "default") -> Dict[str, Any]:
|
def get_reporting_snapshot(self, user_id: str = "default") -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export interface SendOutreachResponse {
|
|||||||
status: string;
|
status: string;
|
||||||
policy_allowed: boolean;
|
policy_allowed: boolean;
|
||||||
policy_reasons: string[];
|
policy_reasons: string[];
|
||||||
|
effective_sender_email?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OutreachAttemptRecord {
|
export interface OutreachAttemptRecord {
|
||||||
|
|||||||
@@ -708,6 +708,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
|||||||
<div key={a.attempt_id} style={{ marginTop: '8px', padding: '8px 12px', background: 'rgba(255,255,255,0.04)', borderRadius: '8px', fontSize: '12px' }}>
|
<div key={a.attempt_id} style={{ marginTop: '8px', padding: '8px 12px', background: 'rgba(255,255,255,0.04)', borderRadius: '8px', fontSize: '12px' }}>
|
||||||
<span style={{ color: 'rgba(255,255,255,0.5)' }}>Latest: {a.subject} — </span>
|
<span style={{ color: 'rgba(255,255,255,0.5)' }}>Latest: {a.subject} — </span>
|
||||||
{renderStatusBadge(a.status)}
|
{renderStatusBadge(a.status)}
|
||||||
|
{a.sender_email && <span style={{ color: 'rgba(255,255,255,0.35)', marginLeft: '8px' }}>From: {a.sender_email}</span>}
|
||||||
{a.sent_at && <span style={{ color: 'rgba(255,255,255,0.3)', marginLeft: '8px' }}>{new Date(a.sent_at).toLocaleString()}</span>}
|
{a.sent_at && <span style={{ color: 'rgba(255,255,255,0.3)', marginLeft: '8px' }}>{new Date(a.sent_at).toLocaleString()}</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -724,7 +725,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
|||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: 'rgba(255,255,255,0.04)' }}>
|
<tr style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||||
{['Subject', 'Status', 'Sender', 'Sent At'].map(h => (
|
{['Subject', 'Status', 'Effective Sender', 'Sent At'].map(h => (
|
||||||
<th key={h} style={{ padding: '10px 12px', borderBottom: '1px solid rgba(255,255,255,0.08)', textAlign: 'left', color: 'rgba(255,255,255,0.4)', fontWeight: 500, fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{h}</th>
|
<th key={h} style={{ padding: '10px 12px', borderBottom: '1px solid rgba(255,255,255,0.08)', textAlign: 'left', color: 'rgba(255,255,255,0.4)', fontWeight: 500, fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user