Arag / app /services /email_service.py
baqir3033
Add two-way support messaging and author usage dashboard.
b115793
Raw
History Blame Contribute Delete
25.6 kB
"""Author RAG Chatbot SaaS — Email Service (Gmail SMTP).
All outgoing emails go through this service.
RULE: Email sends are always queued as Celery tasks (non-blocking).
RULE: Never call this directly from API handlers — use tasks/email_task.py.
Templates are inline HTML for zero external dependency.
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import structlog
from app.config import get_settings
logger = structlog.get_logger(__name__)
cfg = get_settings()
class EmailService:
"""Sends transactional emails via Gmail SMTP (STARTTLS on 587, SSL on 465)."""
def _smtp_meta(self):
"""Return resolved from/support addresses for outgoing mail."""
from app.services.smtp_config_service import resolve_smtp_config_sync
creds = resolve_smtp_config_sync()
if creds:
return creds.from_name, creds.from_address, creds.support_email
return cfg.EMAIL_FROM_NAME, str(cfg.EMAIL_FROM_ADDRESS or cfg.SMTP_USERNAME or ""), str(
cfg.SUPPORT_EMAIL or cfg.SMTP_USERNAME or "",
)
def _send(self, to: str, subject: str, html_body: str, reply_to: str | None = None) -> None:
"""Send a single email.
Args:
to: Recipient email address.
subject: Email subject line.
html_body: Full HTML body content.
reply_to: Optional Reply-To header for reader replies.
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
from_name, from_addr, _ = self._smtp_meta()
msg["From"] = f"{from_name} <{from_addr}>"
msg["To"] = to
if reply_to:
msg["Reply-To"] = reply_to
msg.attach(MIMEText(html_body, "html"))
from app.services.smtp_config_service import resolve_smtp_config_sync
from app.services.smtp_transport import send_raw_message
creds = resolve_smtp_config_sync()
if not creds:
raise smtplib.SMTPException("SMTP is not configured")
try:
send_raw_message(creds, from_addr, to, msg.as_string())
logger.info("Email sent", to=to, subject=subject)
except smtplib.SMTPException as e:
logger.error("Failed to send email", to=to, subject=subject, error=str(e))
raise
def send_welcome(self, to: str, name: str) -> None:
"""Send welcome email after successful registration.
Args:
to: Author's email address.
name: Author's display name.
"""
subject = "Welcome to AuthorBot! 🎉"
body = _render_template("welcome", {"name": name})
self._send(to, subject, body)
def send_account_locked(self, to: str) -> None:
"""Notify user their account has been temporarily locked.
Args:
to: Author's email address.
"""
subject = "AuthorBot: Account temporarily locked"
body = _render_template("account_locked", {"minutes": cfg.LOCKOUT_DURATION_MINUTES})
self._send(to, subject, body)
def send_subscription_granted(self, to: str, name: str, plan: str, expires_at: str) -> None:
"""Notify author that access has been granted.
Args:
to: Author's email address.
name: Author's display name.
plan: Subscription plan name.
expires_at: Formatted expiry date string.
"""
subject = "Your AuthorBot chatbot is now active!"
body = _render_template("subscription_granted", {
"name": name, "plan": plan, "expires_at": expires_at
})
self._send(to, subject, body)
def send_subscription_revoked(self, to: str, name: str, reason: str) -> None:
"""Notify author that access has been revoked.
Args:
to: Author's email address.
name: Author's display name.
reason: Revocation reason.
"""
subject = "AuthorBot: Your chatbot access has been revoked"
body = _render_template("subscription_revoked", {"name": name, "reason": reason})
self._send(to, subject, body)
def send_subscription_expiry_warning(
self, to: str, name: str, days_remaining: int, expires_at: str
) -> None:
"""Send expiry warning email (7 days and 1 day before).
Args:
to: Author's email address.
name: Author's display name.
days_remaining: Number of days until expiry.
expires_at: Formatted expiry date string.
"""
subject = f"AuthorBot: Your subscription expires in {days_remaining} day(s)"
body = _render_template("expiry_warning", {
"name": name, "days_remaining": days_remaining, "expires_at": expires_at
})
self._send(to, subject, body)
def send_token_budget_warning(
self, to: str, name: str, pct_used: int, tokens_used: int,
tokens_total: int, forecast_date: str
) -> None:
"""Send token budget warning email.
Args:
to: Author's email address.
name: Author's display name.
pct_used: Percentage of budget consumed.
tokens_used: Raw tokens consumed this month.
tokens_total: Total monthly budget.
forecast_date: Estimated date of exhaustion.
"""
subject = f"AuthorBot: You've used {pct_used}% of your monthly tokens"
body = _render_template("token_warning", {
"name": name, "pct_used": pct_used,
"tokens_used": f"{tokens_used:,}", "tokens_total": f"{tokens_total:,}",
"forecast_date": forecast_date,
})
self._send(to, subject, body)
def send_weekly_digest(
self, to: str, name: str, chat_count: int, top_book: str,
token_pct: int, top_country: str
) -> None:
"""Send weekly summary digest email.
Args:
to: Author's email address.
name: Author's display name.
chat_count: Total chats this week.
top_book: Title of the most-engaged book.
token_pct: Token usage percentage this month.
top_country: Country with most visitors.
"""
subject = f"AuthorBot Weekly: {chat_count} chats this week 📚"
body = _render_template("weekly_digest", {
"name": name, "chat_count": chat_count, "top_book": top_book,
"token_pct": token_pct, "top_country": top_country,
})
self._send(to, subject, body)
def send_publish_help_request(
self,
*,
author_name: str,
author_email: str,
book_title: str,
platform_names: list[str],
message: str,
submitted_at: str,
book_id: str | None = None,
isbn: str | None = None,
book_author: str | None = None,
source_url: str | None = None,
) -> None:
"""Send publish-help request to the support inbox.
Args:
author_name: Display name of the requesting author.
author_email: Author's account email.
book_title: Title of the book needing distribution help.
platform_names: Human-readable platform names.
message: Optional note from the author.
submitted_at: ISO timestamp of the request.
book_id: Optional book UUID.
isbn: Optional ISBN.
book_author: Optional book author name.
source_url: Optional source listing URL.
"""
dest = self._smtp_meta()[2] or cfg.SUPPORT_EMAIL
if not dest:
raise ValueError("SUPPORT_EMAIL is not configured")
subject = f"Publish help: {book_title} ({len(platform_names)} platform(s))"
body = _render_template("publish_help", {
"author_name": author_name,
"author_email": author_email,
"book_title": book_title,
"book_id": book_id or "—",
"isbn": isbn or "—",
"book_author": book_author or "—",
"source_url": source_url or "—",
"platforms": ", ".join(platform_names),
"message": message or "(no message)",
"submitted_at": submitted_at,
})
self._send(str(dest), subject, body)
def send_contact_request(
self,
author_name: str,
author_email: str,
category: str,
message: str,
submitted_at: str,
subject: str | None = None,
) -> None:
"""Send a general contact message to the support inbox."""
dest = self._smtp_meta()[2] or cfg.SUPPORT_EMAIL
if not dest:
raise ValueError("SUPPORT_EMAIL is not configured")
mail_subject = subject or f"Contact: {category}{author_name}"
body = _render_template("contact_request", {
"author_name": author_name,
"author_email": author_email,
"category": category,
"subject": mail_subject,
"message": message,
"submitted_at": submitted_at,
})
self._send(str(dest), mail_subject, body)
def send_contact_reply(
self,
*,
author_name: str,
author_email: str,
subject: str,
original_message: str,
reply_body: str,
replied_at: str,
) -> None:
"""Notify an author that SuperAdmin replied to their support message."""
mail_subject = f"Re: {subject}"
body = _render_template("contact_reply", {
"author_name": author_name,
"subject": subject,
"original_message": original_message,
"reply_body": reply_body,
"replied_at": replied_at,
})
self._send(author_email, mail_subject, body)
def send_author_reader_email(
self,
*,
to_email: str,
reader_name: str | None,
author_name: str,
author_email: str,
subject: str,
message: str,
sent_at: str,
) -> None:
"""Send a personal message from an author to a reader who opted in."""
import html
greeting = reader_name or "there"
body = _render_template("author_reader_email", {
"greeting": html.escape(greeting),
"author_name": html.escape(author_name),
"message": html.escape(message),
"sent_at": sent_at,
})
self._send(to_email, subject, body, reply_to=author_email)
def send_account_credentials(
self,
*,
to: str,
name: str,
login_url: str,
temp_password: str | None = None,
user_chose_password: bool = False,
) -> None:
"""Send login credentials after Stripe payment or SuperAdmin support action."""
import html
subject = "Your AuthorBot account is ready"
body = _render_template("account_credentials", {
"name": html.escape(name),
"login_url": html.escape(login_url),
"email": html.escape(to),
"temp_password": html.escape(temp_password) if temp_password else None,
"user_chose_password": user_chose_password,
"forgot_url": html.escape(f"{login_url.rstrip('/login')}/reset-password"),
})
self._send(to, subject, body)
def send_payment_failed_notice(self, to: str, name: str, billing_url: str) -> None:
"""Notify author that a subscription payment failed."""
import html
subject = "Action required: payment failed"
body = _render_template("payment_failed", {
"name": html.escape(name),
"billing_url": html.escape(billing_url),
})
self._send(to, subject, body)
def send_subscription_cancelled_notice(self, to: str, name: str) -> None:
"""Notify author that their subscription was cancelled."""
import html
subject = "Your AuthorBot subscription has ended"
body = _render_template("subscription_cancelled_notice", {
"name": html.escape(name),
})
self._send(to, subject, body)
def send_publish_help_confirmation(
self,
to: str,
name: str,
book_title: str,
platform_names: list[str],
) -> None:
"""Confirm receipt of a publish-help request to the author.
Args:
to: Author's email address.
name: Author's display name.
book_title: Title of the book.
platform_names: Platforms requested for help.
"""
subject = "We received your publishing help request"
body = _render_template("publish_help_confirmation", {
"name": name,
"book_title": book_title,
"platforms": ", ".join(platform_names),
})
self._send(to, subject, body)
# ─── Template Renderer ────────────────────────────────────────────────────────
def _render_template(template_name: str, context: dict) -> str:
"""Render an HTML email template with context variables.
Args:
template_name: Name of the template (maps to a function below).
context: Dictionary of variables to inject.
Returns:
Rendered HTML string.
"""
templates = {
"welcome": _tpl_welcome,
"account_locked": _tpl_account_locked,
"subscription_granted": _tpl_subscription_granted,
"subscription_revoked": _tpl_subscription_revoked,
"expiry_warning": _tpl_expiry_warning,
"token_warning": _tpl_token_warning,
"weekly_digest": _tpl_weekly_digest,
"publish_help": _tpl_publish_help,
"publish_help_confirmation": _tpl_publish_help_confirmation,
"contact_request": _tpl_contact_request,
"contact_reply": _tpl_contact_reply,
"author_reader_email": _tpl_author_reader_email,
"account_credentials": _tpl_account_credentials,
"payment_failed": _tpl_payment_failed,
"subscription_cancelled_notice": _tpl_subscription_cancelled_notice,
}
renderer = templates.get(template_name)
if not renderer:
raise ValueError(f"Unknown email template: {template_name}")
return renderer(context)
def _base_html(title: str, content: str) -> str:
"""Wrap content in a branded HTML email shell."""
return f"""
<!DOCTYPE html><html><head><meta charset="utf-8">
<title>{title}</title></head>
<body style="margin:0;padding:0;background:#f4f4f8;font-family:Inter,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f8;padding:40px 0;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0"
style="background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
<tr><td style="background:linear-gradient(135deg,#6366f1,#8b5cf6);padding:32px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:24px;font-weight:700;">✦ AuthorBot</h1>
</td></tr>
<tr><td style="padding:40px;">
{content}
</td></tr>
<tr><td style="background:#f9f9fb;padding:24px;text-align:center;color:#9ca3af;font-size:12px;">
AuthorBot — Intelligent Book Advisor for Authors<br>
You're receiving this because you have an AuthorBot account.
</td></tr>
</table>
</td></tr>
</table></body></html>
"""
def _tpl_welcome(ctx: dict) -> str:
return _base_html("Welcome to AuthorBot", f"""
<h2 style="color:#1f2937;">Welcome, {ctx['name']}! 🎉</h2>
<p style="color:#4b5563;line-height:1.6;">Your AuthorBot account is ready.
Start by uploading your first book and configuring your chatbot.</p>
<p style="color:#4b5563;line-height:1.6;">Your chatbot will help readers discover
and fall in love with your books — automatically.</p>
""")
def _tpl_account_locked(ctx: dict) -> str:
return _base_html("Account Locked", f"""
<h2 style="color:#dc2626;">Account Temporarily Locked</h2>
<p style="color:#4b5563;">Too many failed login attempts.
Your account is locked for {ctx['minutes']} minutes.</p>
<p style="color:#4b5563;">If this wasn't you, please contact support immediately.</p>
""")
def _tpl_subscription_granted(ctx: dict) -> str:
return _base_html("Subscription Active", f"""
<h2 style="color:#059669;">Your chatbot is now active! ✓</h2>
<p style="color:#4b5563;">Hi {ctx['name']}, your <strong>{ctx['plan']}</strong> subscription
is now active.</p>
<p style="color:#4b5563;">Access expires: <strong>{ctx['expires_at']}</strong></p>
""")
def _tpl_subscription_revoked(ctx: dict) -> str:
return _base_html("Access Revoked", f"""
<h2 style="color:#dc2626;">Chatbot Access Revoked</h2>
<p style="color:#4b5563;">Hi {ctx['name']}, your chatbot access has been revoked.</p>
<p style="color:#4b5563;">Reason: {ctx['reason']}</p>
<p style="color:#4b5563;">Please contact us to restore access.</p>
""")
def _tpl_expiry_warning(ctx: dict) -> str:
return _base_html("Subscription Expiring Soon", f"""
<h2 style="color:#d97706;">Subscription Expiring in {ctx['days_remaining']} Day(s)</h2>
<p style="color:#4b5563;">Hi {ctx['name']}, your subscription expires on
<strong>{ctx['expires_at']}</strong>.</p>
<p style="color:#4b5563;">Contact us to renew and keep your chatbot running.</p>
""")
def _tpl_token_warning(ctx: dict) -> str:
return _base_html("Token Budget Warning", f"""
<h2 style="color:#d97706;">You've used {ctx['pct_used']}% of your tokens</h2>
<p style="color:#4b5563;">Hi {ctx['name']},</p>
<p style="color:#4b5563;">Usage: <strong>{ctx['tokens_used']}</strong> /
{ctx['tokens_total']} tokens</p>
<p style="color:#4b5563;">At this pace, budget runs out around
<strong>{ctx['forecast_date']}</strong>.</p>
""")
def _tpl_weekly_digest(ctx: dict) -> str:
return _base_html("Weekly Digest", f"""
<h2 style="color:#1f2937;">Your week in review 📚</h2>
<p style="color:#4b5563;">Hi {ctx['name']}, here's what happened this week:</p>
<ul style="color:#4b5563;line-height:2;">
<li>💬 <strong>{ctx['chat_count']}</strong> visitor conversations</li>
<li>📖 Top book: <strong>{ctx['top_book']}</strong></li>
<li>🔋 Token usage: <strong>{ctx['token_pct']}%</strong> this month</li>
<li>🌍 Top country: <strong>{ctx['top_country']}</strong></li>
</ul>
""")
def _tpl_publish_help(ctx: dict) -> str:
return _base_html("Publish Help Request", f"""
<h2 style="color:#1f2937;">New publishing help request</h2>
<p style="color:#4b5563;line-height:1.6;">
<strong>Author:</strong> {ctx['author_name']} &lt;{ctx['author_email']}&gt;<br>
<strong>Book:</strong> {ctx['book_title']}<br>
<strong>Book author:</strong> {ctx['book_author']}<br>
<strong>ISBN:</strong> {ctx['isbn']}<br>
<strong>Book ID:</strong> {ctx['book_id']}<br>
<strong>Source URL:</strong> {ctx['source_url']}<br>
<strong>Platforms:</strong> {ctx['platforms']}<br>
<strong>Submitted:</strong> {ctx['submitted_at']}
</p>
<div style="padding:14px 16px;border-radius:12px;background:#f9fafb;border:1px solid #e5e7eb;">
<strong style="color:#374151;">Message</strong>
<p style="color:#4b5563;margin:8px 0 0;white-space:pre-wrap;">{ctx['message']}</p>
</div>
""")
def _tpl_publish_help_confirmation(ctx: dict) -> str:
return _base_html("Request Received", f"""
<h2 style="color:#059669;">We received your request ✓</h2>
<p style="color:#4b5563;line-height:1.6;">Hi {ctx['name']},</p>
<p style="color:#4b5563;line-height:1.6;">
Thanks for reaching out about <strong>{ctx['book_title']}</strong>.
Our team will help you publish on: <strong>{ctx['platforms']}</strong>.
</p>
<p style="color:#4b5563;line-height:1.6;">We aim to reply within 24 hours.</p>
""")
def _tpl_contact_request(ctx: dict) -> str:
return _base_html("Contact Request", f"""
<h2 style="color:#1f2937;">New contact message</h2>
<p style="color:#4b5563;line-height:1.6;">
<strong>Author:</strong> {ctx['author_name']} &lt;{ctx['author_email']}&gt;<br>
<strong>Category:</strong> {ctx['category']}<br>
<strong>Subject:</strong> {ctx.get('subject', '—')}<br>
<strong>Submitted:</strong> {ctx['submitted_at']}
</p>
<div style="padding:14px 16px;border-radius:12px;background:#f9fafb;border:1px solid #e5e7eb;">
<strong style="color:#374151;">Message</strong>
<p style="color:#4b5563;margin:8px 0 0;white-space:pre-wrap;">{ctx['message']}</p>
</div>
""")
def _tpl_contact_reply(ctx: dict) -> str:
return _base_html("Support Reply", f"""
<h2 style="color:#1f2937;">Hi {ctx['author_name']},</h2>
<p style="color:#4b5563;line-height:1.6;">
Our team replied to your message about <strong>{ctx['subject']}</strong>.
</p>
<div style="padding:14px 16px;border-radius:12px;background:#f0fdf4;border:1px solid #bbf7d0;margin:16px 0;">
<strong style="color:#166534;">Our reply</strong>
<p style="color:#374151;margin:8px 0 0;white-space:pre-wrap;line-height:1.6;">{ctx['reply_body']}</p>
</div>
<div style="padding:14px 16px;border-radius:12px;background:#f9fafb;border:1px solid #e5e7eb;">
<strong style="color:#6b7280;font-size:12px;">Your original message</strong>
<p style="color:#9ca3af;margin:8px 0 0;white-space:pre-wrap;font-size:13px;">{ctx['original_message']}</p>
</div>
<p style="color:#9ca3af;font-size:12px;margin-top:24px;">
Log in to your AuthorBot dashboard to view this reply in the Messages tab.
</p>
""")
def _tpl_author_reader_email(ctx: dict) -> str:
return _base_html("Message from your author", f"""
<h2 style="color:#1f2937;">Hi {ctx['greeting']},</h2>
<p style="color:#4b5563;line-height:1.6;">
<strong>{ctx['author_name']}</strong> sent you a message via their book chatbot:
</p>
<div style="padding:14px 16px;border-radius:12px;background:#f9fafb;border:1px solid #e5e7eb;">
<p style="color:#374151;margin:0;white-space:pre-wrap;line-height:1.6;">{ctx['message']}</p>
</div>
<p style="color:#9ca3af;font-size:12px;margin-top:24px;">
Reply to this email to reach {ctx['author_name']} directly.
</p>
""")
def _tpl_account_credentials(ctx: dict) -> str:
pwd_block = ""
if ctx.get("temp_password"):
pwd_block = f"""
<p style="color:#4b5563;line-height:1.6;">
<strong>Email:</strong> {ctx['email']}<br>
<strong>Temporary password:</strong> <code style="background:#f3f4f6;padding:2px 6px;border-radius:4px;">{ctx['temp_password']}</code>
</p>
<p style="color:#d97706;font-size:13px;">Please change this password after your first login.</p>
"""
elif ctx.get("user_chose_password"):
pwd_block = f"""
<p style="color:#4b5563;line-height:1.6;">
<strong>Email:</strong> {ctx['email']}<br>
Use the password you set during checkout.
</p>
<p style="color:#4b5563;font-size:13px;">
Forgot it? <a href="{ctx['forgot_url']}" style="color:#6366f1;">Reset your password</a>
</p>
"""
else:
pwd_block = f"""
<p style="color:#4b5563;line-height:1.6;">
<strong>Email:</strong> {ctx['email']}
</p>
"""
return _base_html("Your Account", f"""
<h2 style="color:#059669;">Welcome, {ctx['name']}!</h2>
<p style="color:#4b5563;line-height:1.6;">Your AuthorBot account is active. Sign in to upload books and configure your chatbot.</p>
{pwd_block}
<p style="margin-top:24px;">
<a href="{ctx['login_url']}" style="display:inline-block;background:#6366f1;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;">
Sign in to your dashboard
</a>
</p>
""")
def _tpl_payment_failed(ctx: dict) -> str:
return _base_html("Payment Failed", f"""
<h2 style="color:#dc2626;">Payment failed</h2>
<p style="color:#4b5563;line-height:1.6;">Hi {ctx['name']}, we could not process your latest subscription payment.</p>
<p style="color:#4b5563;line-height:1.6;">Update your payment method within 7 days to keep your chatbot running.</p>
<p style="margin-top:20px;">
<a href="{ctx['billing_url']}" style="color:#6366f1;font-weight:600;">Manage billing</a>
</p>
""")
def _tpl_subscription_cancelled_notice(ctx: dict) -> str:
return _base_html("Subscription Ended", f"""
<h2 style="color:#1f2937;">Subscription ended</h2>
<p style="color:#4b5563;line-height:1.6;">Hi {ctx['name']}, your AuthorBot subscription has been cancelled and your chatbot access has ended.</p>
<p style="color:#4b5563;line-height:1.6;">Contact support if you'd like to reactivate.</p>
""")