@celery_app.task(bind=True, base=SendTask, name="app.tasks.send_tasks.send_one", ignore_result=False) def send_one(self, campaign_id: int, campaign_recipient_id: int): """Process one recipient: build message, send through Gmail, record real outcome.""" with SyncSession() as db: campaign = db.get(Campaign, campaign_id) if campaign is None: return {"ok": False, "status": "cancelled", "reason": "campaign deleted"} if campaign.status in ("paused", "cancelled", "completed"): return {"ok": False, "status": "cancelled", "reason": f"campaign {campaign.status}"} cr = db.get(CampaignRecipient, campaign_recipient_id) if cr is None: return {"ok": False, "status": "cancelled", "reason": "campaign_recipient deleted"} if cr.status in ("sent", "suppressed", "perm_failed"): return {"ok": True, "status": cr.status, "reason": "already finalized"} job = (db.execute(select(SendJob) .where(SendJob.campaign_recipient_id == cr.id, SendJob.campaign_id == campaign.id))).scalar_one_or_none() # Re-check suppression at send time (recipient may have unsubscribed since enqueue) sup = (db.execute(select(SuppressionEntry) .where(SuppressionEntry.email == cr.email))).scalar_one_or_none() if sup: cr.status = "suppressed" campaign.suppressed_count += 1 cr.last_error = "Suppressed at send time" db.add(EmailEvent(campaign_id=campaign.id, campaign_recipient_id=cr.id, event_type="suppressed", detail="Suppressed at send time")) db.commit() _publish_progress(campaign.id) return {"ok": True, "status": "suppressed", "reason": "suppression hit"} account = db.get(GmailAccount, cr.assigned_account_id) if cr.assigned_account_id else None if account is None or not account.is_active or account.status != "authorized": cr.status = "skipped" campaign.skipped_count += 1 cr.last_error = "Assigned account unavailable/unauthorized" db.commit() _publish_progress(campaign.id) return {"ok": False, "status": "skipped", "reason": "account unavailable"} # Per-account daily cap (never exceed provider limits) today = now().date() if account.last_send_date and account.last_send_date.date() == today \ and account.sends_today >= account.daily_limit: campaign.status = "paused" db.commit() _publish_control(campaign.id, "pause", f"Account {account.email} reached daily limit ({account.daily_limit})") return {"ok": False, "status": "paused", "reason": "daily limit reached"} if account.cooldown_until and account.cooldown_until > now(): _reschedule(db, self, campaign.id, cr, job, account.cooldown_until - now(), "account cooling down") return {"ok": False, "status": "rescheduled", "reason": "cooldown"} # Per-account rate window interval = float(campaign.send_interval_seconds or 1.0) ok, wait = _rate_window(f"acct:{account.id}", interval) if not ok: _reschedule(db, self, campaign.id, cr, job, timedelta(seconds=wait), "rate limit window") return {"ok": False, "status": "rescheduled", "reason": "rate window"} # ------------------------------------------------------------- build message recipient = db.get(Recipient, cr.recipient_id) subject_row = db.get(SubjectLine, cr.generated_subject_id) if cr.generated_subject_id else None template = db.get(EmailTemplate, cr.generated_template_id) if cr.generated_template_id else None subject = subject_row.subject if subject_row else campaign.name if template is None: cr.status = "perm_failed" cr.last_error = "No template assigned" campaign.failed_count += 1 db.commit() return {"ok": False, "status": "perm_failed", "reason": "no template"} var_cfg = dict(campaign.variable_config or {}) engine = VariableEngine(var_cfg) _today = date.today() g = GeneratedValues( invoice_number=cr.generated_invoice_number, amount=cr.generated_amount, shipping=cr.generated_shipping, random_name=engine.generate_random_name(), invoice_date=var_cfg.get("invoice_date") or _today.isoformat(), due_date=var_cfg.get("due_date") or (_effective_invoice_date(var_cfg.get("invoice_date") or _today.isoformat()) + timedelta(days=int(var_cfg.get("due_days", 14)))).isoformat(), phone=cr.generated_phone or var_cfg.get("company_phone", ""), ) unsub_url = _unsubscribe_url(recipient, campaign) rec = _recipient_dict(recipient) cf = (recipient.custom_fields if recipient else None) or {} result_html = engine.substitute(template.body_html, rec, g, custom_fields=cf, extra={"unsubscribe_link": unsub_url}).text result_text = engine.substitute(template.body_text or _plain_fallback(result_html), rec, g, custom_fields=cf, extra={"unsubscribe_link": unsub_url}).text attachments, att_names = [], []