"""Campaign engine: materialization, rotation, sequencing, assignment. The engine generates per-recipient values (invoice number, amount, shipping, subject, template, phone, sender account) with collision-checked uniqueness and full audit data. """ import random from datetime import date, datetime, timedelta, timezone from typing import List, Optional, Sequence from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models import (Campaign, CampaignRecipient, EmailTemplate, GmailAccount, SubjectLine, CompanyPhoneNumber, Recipient, SuppressionEntry) from app.services.variable_engine import VariableEngine, GeneratedValues from app.core.config import settings def utcnow(): return datetime.now(timezone.utc) class RotationAssigner: """Assigns sender accounts per message: sequential or weighted, with limits & cooldown.""" def __init__(self, accounts: list, strategy: str, weights: dict = None, max_per_account: int = 200): self.accounts = [a for a in accounts if a.status == "authorized" and a.is_active] self.strategy = strategy if strategy in ("sequential", "weighted") else "sequential" self.weights = weights or {} self.max_per_account = max_per_account self.usage = {a.id: 0 for a in self.accounts} self.cooling = {a.id: False for a in self.accounts} self._seq_index = 0 self._pool = [] self._pool_pos = 0 def _available(self): avail = [] for a in self.accounts: if self.usage[a.id] >= self.max_per_account: ---SEND--- """Send task: real Gmail API/SMTP send with retry, backoff, DLQ, pause/cancel, rate limits. Status model (no fake success): queued -> processing -> sent (Gmail accepted, message ID recorded) temporary errors -> retry with exponential backoff + jitter, dead-letter after max attempts permanent errors -> perm_failed (no retry) suppressed / skipped -> dropped pre-send """ import logging import random import re as _re import time from datetime import date, datetime, timedelta, timezone import googleapiclient.errors import redis as redis_lib from celery import Task from sqlalchemy import select from app.core.config import settings from app.db.session import SyncSession from app.models import (Attachment, Campaign, CampaignRecipient, EmailEvent, EmailTemplate, GmailAccount, OAuthToken, Recipient, SendAttempt, SendJob, SubjectLine, SuppressionEntry) from app.services import attachments_service, message_builder from app.services.gmail_service import build_gmail_client_from_account from app.services.variable_engine import GeneratedValues, VariableEngine from app.worker.celery_app import celery_app log = logging.getLogger("send") try: rdb = redis_lib.from_url(settings.REDIS_URL, socket_timeout=2) except Exception: rdb = None TEMPORARY_PATTERNS = ["rate limit", "rate-limit", "429", "quota", "backend error", "transient", "temporarily", "503", "502", "500", "timeout", "connection", "try again", "resourceexhausted", "tempfail", "unavailable", "internal error", "deadline"] PERMANENT_PATTERNS = ["invalid grant", "401", "403", "unauthorized", "forbidden", "invalidrecipient", "failedprecondition", "invalid argument", "malformed", "does not exist", "no such user", "550", "553", "554", "rejected", "policy violation", "disabled", "authentication"] def classify_error(msg: str) -> str: m = (msg or "").lower() for p in TEMPORARY_PATTERNS: if p in m: return "temporary" for p in PERMANENT_PATTERNS: if p in m: return "permanent" return "unknown" def now(): return datetime.now(timezone.utc)