Files
ALwrity/backend/services/backlink_outreach_sender.py
ajaysi 8699ffc27d fix: resolve remaining 5 QA audit findings (#3, #8, #10, #11, #12)
#3 — Duplicate prospect handling: add_lead now checks (campaign_id, url)
     before insert; bulk_add_leads skips existing URLs.
#8 — Atomic rate limiting: try_increment_* methods atomically check cap
     and increment in a single session; router uses these before send.
#10 — Reply matching via Message-ID: sender generates Message-ID header,
     stored on OutreachAttempt; reply monitor parses In-Reply-To/References;
     poll_replies matches by message_id first, falls back to from_email.
#11 — Save-to-campaign uses existing store results instead of
      re-running expensive deepDiscover.
#12 — Lead status Literal type: Pydantic models enforce valid status
      values; backend validates via LEAD_VALID_STATUSES frozenset;
      frontend API typed as LeadStatus union.
2026-06-03 20:06:11 +05:30

167 lines
6.0 KiB
Python

"""Email sender for backlink outreach via SMTP."""
from __future__ import annotations
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 List, Optional, Set
from uuid import uuid4
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_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 = ""
message_id: str = ""
failure_reasons: List[str] = field(default_factory=list)
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._allowed_from_emails = SMTP_ALLOWED_FROM_EMAILS
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)
@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,
) -> 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 = sender_validation.effective_sender_email
msg_id = f"<{uuid4().hex}@{sender.split('@')[-1] if '@' in sender else 'outreach.local'}>"
msg = MIMEMultipart("alternative")
msg["From"] = sender
msg["To"] = to_email
msg["Subject"] = subject
msg["Message-ID"] = msg_id
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
success = await loop.run_in_executor(None, _send)
return SendEmailResult(
success=success,
effective_sender_email=sender,
message_id=msg_id if success else "",
failure_reasons=[] if success else ["smtp_send_failed"],
)
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()