attachments, att_names = [], [] if campaign.attach_mode == "pdf" and campaign.attach_pdf: att_html = "" if campaign.attach_pdf_template_id: att_tpl = db.get(EmailTemplate, campaign.attach_pdf_template_id) if att_tpl: att_html = engine.substitute(att_tpl.body_html, rec, g, custom_fields=cf, extra={"unsubscribe_link": unsub_url}).text elif campaign.attachment_id: att = db.get(Attachment, campaign.attachment_id) if att and att.kind == "html": att_html = engine.substitute(att.html_attachment_body or "", rec, g, custom_fields=cf, extra={"unsubscribe_link": unsub_url}).text if att_html: pdf_bytes = message_builder.html_to_pdf(att_html) fname = message_builder.generate_attachment_filename( var_cfg.get("attachment_kind", "invoice"), cr.generated_invoice_number) attachments.append({"filename": fname, "content": pdf_bytes, "mime_type": "application/pdf"}) att_names.append(fname) if campaign.attach_mode in ("file", "image") and campaign.attachment_id: att = db.get(Attachment, campaign.attachment_id) if att: data = attachments_service.read_attachment_bytes(att) if data is not None: attachments.append({"filename": att.filename, "content": data, "mime_type": att.mime_type}) att_names.append(att.filename) html_problems = message_builder.validate_html(result_html) if html_problems: log.warning("HTML validation warnings for %s: %s", cr.email, html_problems) mime = message_builder.build_mime_message( from_email=account.email, # real authenticated account; never spoofed to_email=cr.email, subject=subject, html_body=result_html if campaign.message_type != "plain" else "", text_body=result_text, attachments=attachments, unsubscribe_url=unsub_url if campaign.unsubscribe_enabled else "", unsubscribe_mailto=f"{account.email}?subject=unsubscribe", ) # ------------------------------------------------------------- send for real token = (db.execute(select(OAuthToken) .where(OAuthToken.account_id == account.id))).scalar_one_or_none() if token is None: cr.status = "perm_failed" cr.last_error = "No OAuth token for account" campaign.failed_count += 1 db.commit() return {"ok": False, "status": "perm_failed", "reason": "missing token"} cr.status = "processing" cr.attempt_count += 1 if job: job.status = "processing" job.attempts = cr.attempt_count attempt = SendAttempt( send_job_id=job.id if job else 0, campaign_id=campaign.id, gmail_account_id=account.id, recipient_email=cr.email, subject=subject, template_name=template.name, attachment_names=att_names, method=account.sending_method, outcome="submitted", retry_no=cr.attempt_count - 1, ) db.add(attempt) db.commit() try: client = build_gmail_client_from_account(account, token) except Exception as e: return _handle_send_error(db, self, campaign, cr, job, account, attempt, str(e)) try: if client.method == "smtp": out = client.send_mime_smtp(mime) else: out = client.send_mime_api(mime) except googleapiclient.errors.HttpError as e: content = e.content.decode(errors="replace") if isinstance(e.content, bytes) else str(e.content) return _handle_send_error(db, self, campaign, cr, job, account, attempt, f"Gmail API error {e.resp.status}: {content[:300]}") except Exception as e: return _handle_send_error(db, self, campaign, cr, job, account, attempt, str(e)) if not out.get("ok"): return _handle_send_error(db, self, campaign, cr, job, account, attempt, out.get("error", "unknown error")) # ------------------------------------------------------------- accepted msg_id = out.get("message_id", "") attempt.outcome = "accepted" attempt.message_id = msg_id attempt.thread_id = out.get("thread_id", "") attempt.provider_response = _sanitize_response(out.get("raw_response")) cr.status = "sent" cr.msg_id = msg_id cr.thread_id = out.get("thread_id", "") cr.sent_at = now() cr.last_error = "" campaign.sent_count += 1 account.sends_today = (account.sends_today + 1) if ( account.last_send_date and account.last_send_date.date() == today) else 1 account.last_send_date = now() account.last_success_at = now() account.error_count = max(0, account.error_count - 1) if job: job.status = "sent" db.add(EmailEvent(campaign_id=campaign.id, campaign_recipient_id=cr.id, gmail_account_id=account.id, event_type="sent", message_id=msg_id, detail=f"accepted via {client.method}")) db.commit() _publish_progress(campaign.id) return {"ok": True, "status": "sent", "message_id": msg_id, "thread_id": out.get("thread_id")} # --------------------------------------------------------------------------- def _recipient_dict(r) -> dict: