from_email=account.email, to_email=str(payload.to_email), subject=subject, html_body=res_html, text_body=res_text, attachments=attachments, unsubscribe_url=unsub_url, ) # REAL send in a worker thread with a sync DB session (token refresh writes) def _real_send(): with SyncSession() as s: acc = s.get(GmailAccount, account.id) token = s.query(OAuthToken).filter_by(account_id=account.id).first() if not token: return {"ok": False, "error": "No OAuth token stored for this account", "error_class": "auth"} client = build_gmail_client_from_account(acc, token) if client.method == "smtp": return client.send_mime_smtp(mime) return client.send_mime_api(mime) try: result = await asyncio.get_event_loop().run_in_executor(None, _real_send) except Exception as e: await log_audit(db, user.id, "test_send.error", "gmail_account", account.id, {"error": str(e)[:200]}) return {"ok": False, "stage": "temp_failed", "detail": str(e)[:400], "error_class": "unknown", "elapsed_ms": int((time.monotonic() - t0) * 1000)} elapsed = int((time.monotonic() - t0) * 1000) if result.get("ok"): await log_audit(db, user.id, "test_send.success", "gmail_account", account.id, {"to": str(payload.to_email), "message_id": result.get("message_id")}) return {"ok": True, "stage": "accepted", "detail": "Gmail accepted the message", "message_id": result.get("message_id"), "thread_id": result.get("thread_id"), "method": client_method(account), "provider_response": _clip(str(result.get("raw_response", ""))), "elapsed_ms": elapsed} # provider refused — surface the REAL error, never fake success err = str(result.get("error", "") or result.get("detail", "")) await log_audit(db, user.id, "test_send.failed", "gmail_account", account.id, {"to": str(payload.to_email), "error": err[:200]}) return {"ok": False, "stage": "perm_failed", "detail": err[:400], "error_class": result.get("error_class", "unknown"), "method": client_method(account), "provider_response": _clip(str(result.get("raw_response", ""))), "elapsed_ms": elapsed} def _clip(s: str) -> str: return s[:300] def client_method(account: GmailAccount) -> str: return account.sending_method or "api" @router.get("/accounts") async def test_accounts(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): """Authorized accounts available for test sends.""" rows = (await db.execute( select(GmailAccount).where(GmailAccount.user_id == user.id, GmailAccount.status == "authorized", ==== """Send Test Email: REAL send through a connected Gmail account with provider-confirmed results (message ID from Gmail), full variable substitution and optional attachments. This is the compliance-friendly way to verify end-to-end delivery before a campaign.""" import asyncio import base64 from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, EmailStr, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db, require_csrf from app.core.config import settings as cfg from app.core.security import log_audit from app.models import EmailTemplate, GmailAccount, OAuthToken, User from app.services import message_builder from app.services.campaign_engine import get_setting from app.services.gmail_service import build_gmail_client_from_account from app.services.variable_engine import GeneratedValues, VariableEngine from app.db.session import SyncSession router = APIRouter(prefix="/test-send", tags=["test-send"]) class TestSendIn(BaseModel): account_id: int to_email: EmailStr subject: str = Field(min_length=1, max_length=500) template_id: Optional[int] = None body_html: str = "" body_text: str = "" include_pdf: bool = False use_random_variables: bool = True @router.post("/send") async def send_test(payload: TestSendIn, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user),