Files
bakery-ia/services/notification/app/services/email_service.py
2025-08-23 10:19:58 +02:00

559 lines
20 KiB
Python

# ================================================================
# services/notification/app/services/email_service.py
# ================================================================
"""
Email service for sending notifications
Handles SMTP configuration and email delivery
"""
import structlog
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from email.utils import formataddr
from typing import Optional, List, Dict, Any
import aiosmtplib
from jinja2 import Template
import asyncio
from app.core.config import settings
from shared.monitoring.metrics import MetricsCollector
logger = structlog.get_logger()
metrics = MetricsCollector("notification-service")
class EmailService:
"""
Email service for sending notifications via SMTP
Supports both plain text and HTML emails
"""
def __init__(self):
self.smtp_host = settings.SMTP_HOST
self.smtp_port = settings.SMTP_PORT
self.smtp_user = settings.SMTP_USER
self.smtp_password = settings.SMTP_PASSWORD
self.smtp_tls = settings.SMTP_TLS
self.smtp_ssl = settings.SMTP_SSL
self.default_from_email = settings.DEFAULT_FROM_EMAIL
self.default_from_name = settings.DEFAULT_FROM_NAME
async def send_email(
self,
to_email: str,
subject: str,
text_content: str,
html_content: Optional[str] = None,
from_email: Optional[str] = None,
from_name: Optional[str] = None,
reply_to: Optional[str] = None,
attachments: Optional[List[Dict[str, Any]]] = None
) -> bool:
"""
Send an email notification
Args:
to_email: Recipient email address
subject: Email subject
text_content: Plain text content
html_content: HTML content (optional)
from_email: Sender email (optional, uses default)
from_name: Sender name (optional, uses default)
reply_to: Reply-to address (optional)
attachments: List of attachments (optional)
Returns:
bool: True if email was sent successfully
"""
try:
if not settings.ENABLE_EMAIL_NOTIFICATIONS:
logger.info("Email notifications disabled")
return True # Return success to avoid blocking workflow
if not self.smtp_user or not self.smtp_password:
logger.error("SMTP credentials not configured")
return False
# Validate email address
if not to_email or "@" not in to_email:
logger.error("Invalid recipient email", email=to_email)
return False
# Create message
message = MIMEMultipart('alternative')
message['Subject'] = subject
message['To'] = to_email
# Set From header
sender_email = from_email or self.default_from_email
sender_name = from_name or self.default_from_name
message['From'] = formataddr((sender_name, sender_email))
# Set Reply-To if provided
if reply_to:
message['Reply-To'] = reply_to
# Add text content
text_part = MIMEText(text_content, 'plain', 'utf-8')
message.attach(text_part)
# Add HTML content if provided
if html_content:
html_part = MIMEText(html_content, 'html', 'utf-8')
message.attach(html_part)
# Add attachments if provided
if attachments:
for attachment in attachments:
await self._add_attachment(message, attachment)
# Send email
await self._send_smtp_email(message, sender_email, to_email)
logger.info("Email sent successfully",
to=to_email,
subject=subject,
from_email=sender_email)
# Record success metrics
metrics.increment_counter("emails_sent_total", labels={"status": "success"})
return True
except Exception as e:
logger.error("Failed to send email",
to=to_email,
subject=subject,
error=str(e))
# Record failure metrics
metrics.increment_counter("emails_sent_total", labels={"status": "failed"})
return False
async def send_bulk_emails(
self,
recipients: List[str],
subject: str,
text_content: str,
html_content: Optional[str] = None,
batch_size: int = 50
) -> Dict[str, Any]:
"""
Send bulk emails with rate limiting
Args:
recipients: List of recipient email addresses
subject: Email subject
text_content: Plain text content
html_content: HTML content (optional)
batch_size: Number of emails to send per batch
Returns:
Dict containing success/failure counts
"""
results = {
"total": len(recipients),
"sent": 0,
"failed": 0,
"errors": []
}
try:
# Process in batches to respect rate limits
for i in range(0, len(recipients), batch_size):
batch = recipients[i:i + batch_size]
# Send emails concurrently within batch
tasks = [
self.send_email(
to_email=email,
subject=subject,
text_content=text_content,
html_content=html_content
)
for email in batch
]
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for email, result in zip(batch, batch_results):
if isinstance(result, Exception):
results["failed"] += 1
results["errors"].append({"email": email, "error": str(result)})
elif result:
results["sent"] += 1
else:
results["failed"] += 1
results["errors"].append({"email": email, "error": "Unknown error"})
# Rate limiting delay between batches
if i + batch_size < len(recipients):
await asyncio.sleep(1.0) # 1 second delay between batches
logger.info("Bulk email completed",
total=results["total"],
sent=results["sent"],
failed=results["failed"])
return results
except Exception as e:
logger.error("Bulk email failed", error=str(e))
results["errors"].append({"error": str(e)})
return results
async def send_template_email(
self,
to_email: str,
template_name: str,
template_data: Dict[str, Any],
subject_template: Optional[str] = None
) -> bool:
"""
Send email using a template
Args:
to_email: Recipient email address
template_name: Name of the email template
template_data: Data for template rendering
subject_template: Subject template string (optional)
Returns:
bool: True if email was sent successfully
"""
try:
# Load template (simplified - in production, load from database)
template_content = await self._load_email_template(template_name)
if not template_content:
logger.error("Template not found", template=template_name)
return False
# Render subject
subject = template_name.replace("_", " ").title()
if subject_template:
subject_tmpl = Template(subject_template)
subject = subject_tmpl.render(**template_data)
# Render content
text_template = Template(template_content.get("text", ""))
text_content = text_template.render(**template_data)
html_content = None
if template_content.get("html"):
html_template = Template(template_content["html"])
html_content = html_template.render(**template_data)
return await self.send_email(
to_email=to_email,
subject=subject,
text_content=text_content,
html_content=html_content
)
except Exception as e:
logger.error("Failed to send template email",
template=template_name,
error=str(e))
return False
async def health_check(self) -> bool:
"""
Check if email service is healthy
Returns:
bool: True if service is healthy
"""
try:
if not settings.ENABLE_EMAIL_NOTIFICATIONS:
return True # Service is "healthy" if disabled
if not self.smtp_user or not self.smtp_password:
logger.warning("SMTP credentials not configured")
return False
# Test SMTP connection
if self.smtp_ssl:
# Use implicit TLS/SSL connection (port 465 typically)
server = aiosmtplib.SMTP(hostname=self.smtp_host, port=self.smtp_port, use_tls=True)
await server.connect()
# No need for starttls() when using implicit TLS
else:
# Use plain connection, optionally upgrade with STARTTLS
server = aiosmtplib.SMTP(hostname=self.smtp_host, port=self.smtp_port)
await server.connect()
if self.smtp_tls:
# Try STARTTLS, but handle case where connection is already secure
try:
await server.starttls()
except Exception as starttls_error:
# If STARTTLS fails because connection is already using TLS, that's okay
if "already using TLS" in str(starttls_error) or "already secure" in str(starttls_error):
logger.debug("SMTP connection already secure, skipping STARTTLS")
else:
# Re-raise other STARTTLS errors
raise starttls_error
await server.login(self.smtp_user, self.smtp_password)
await server.quit()
logger.info("Email service health check passed")
return True
except Exception as e:
logger.error("Email service health check failed", error=str(e))
return False
# ================================================================
# PRIVATE HELPER METHODS
# ================================================================
async def _send_smtp_email(self, message: MIMEMultipart, from_email: str, to_email: str):
"""Send email via SMTP"""
try:
# Create SMTP connection
if self.smtp_ssl:
server = aiosmtplib.SMTP(
hostname=self.smtp_host,
port=self.smtp_port,
use_tls=True,
timeout=30
)
else:
server = aiosmtplib.SMTP(
hostname=self.smtp_host,
port=self.smtp_port,
timeout=30
)
await server.connect()
# Start TLS if required
if self.smtp_tls and not self.smtp_ssl:
await server.starttls()
# Login
await server.login(self.smtp_user, self.smtp_password)
# Send email
await server.send_message(message, from_addr=from_email, to_addrs=[to_email])
# Close connection
await server.quit()
except Exception as e:
logger.error("SMTP send failed", error=str(e))
raise
async def _add_attachment(self, message: MIMEMultipart, attachment: Dict[str, Any]):
"""Add attachment to email message"""
try:
filename = attachment.get("filename", "attachment")
content = attachment.get("content", b"")
content_type = attachment.get("content_type", "application/octet-stream")
# Create attachment part
part = MIMEBase(*content_type.split("/"))
part.set_payload(content)
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
f'attachment; filename= {filename}'
)
message.attach(part)
except Exception as e:
logger.error("Failed to add attachment", filename=attachment.get("filename"), error=str(e))
async def _load_email_template(self, template_name: str) -> Optional[Dict[str, str]]:
"""Load email template from storage"""
# Simplified template loading - in production, load from database
templates = {
"welcome": {
"text": """
¡Bienvenido a Bakery Forecast, {{user_name}}!
Gracias por registrarte en nuestra plataforma de pronóstico para panaderías.
Tu cuenta ha sido creada exitosamente y ya puedes comenzar a:
- Subir datos de ventas
- Generar pronósticos de demanda
- Optimizar tu producción
Para comenzar, visita: {{dashboard_url}}
Si tienes alguna pregunta, no dudes en contactarnos.
Saludos,
El equipo de Bakery Forecast
""",
"html": """
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; text-align: center;">
<h1 style="color: white; margin: 0;">¡Bienvenido a Bakery Forecast!</h1>
</div>
<div style="padding: 20px;">
<p>Hola <strong>{{user_name}}</strong>,</p>
<p>Gracias por registrarte en nuestra plataforma de pronóstico para panaderías.</p>
<p>Tu cuenta ha sido creada exitosamente y ya puedes comenzar a:</p>
<ul style="color: #333;">
<li>📊 Subir datos de ventas</li>
<li>🔮 Generar pronósticos de demanda</li>
<li>⚡ Optimizar tu producción</li>
</ul>
<div style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_url}}"
style="background: #667eea; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 5px; font-weight: bold;">
Ir al Dashboard
</a>
</div>
<p>Si tienes alguna pregunta, no dudes en contactarnos.</p>
<p>Saludos,<br>
<strong>El equipo de Bakery Forecast</strong></p>
</div>
<div style="background: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #666;">
© 2025 Bakery Forecast. Todos los derechos reservados.
</div>
</body>
</html>
"""
},
"forecast_alert": {
"text": """
Alerta de Pronóstico - {{bakery_name}}
Se ha detectado una variación significativa en la demanda prevista:
Producto: {{product_name}}
Fecha: {{forecast_date}}
Demanda prevista: {{predicted_demand}} unidades
Variación: {{variation_percentage}}%
{{alert_message}}
Revisa los pronósticos en: {{dashboard_url}}
Saludos,
El equipo de Bakery Forecast
""",
"html": """
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #ff6b6b; padding: 20px; text-align: center;">
<h1 style="color: white; margin: 0;">🚨 Alerta de Pronóstico</h1>
</div>
<div style="padding: 20px;">
<h2 style="color: #333;">{{bakery_name}}</h2>
<p>Se ha detectado una variación significativa en la demanda prevista:</p>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Producto:</strong> {{product_name}}</p>
<p><strong>Fecha:</strong> {{forecast_date}}</p>
<p><strong>Demanda prevista:</strong> {{predicted_demand}} unidades</p>
<p><strong>Variación:</strong> <span style="color: #ff6b6b; font-weight: bold;">{{variation_percentage}}%</span></p>
</div>
<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p style="margin: 0; color: #856404;">{{alert_message}}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_url}}"
style="background: #ff6b6b; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 5px; font-weight: bold;">
Ver Pronósticos
</a>
</div>
</div>
</body>
</html>
"""
},
"weekly_report": {
"text": """
Reporte Semanal - {{bakery_name}}
Resumen de la semana del {{week_start}} al {{week_end}}:
Ventas Totales: {{total_sales}} unidades
Precisión del Pronóstico: {{forecast_accuracy}}%
Productos más vendidos:
{{#top_products}}
- {{name}}: {{quantity}} unidades
{{/top_products}}
Recomendaciones:
{{recommendations}}
Ver reporte completo: {{report_url}}
Saludos,
El equipo de Bakery Forecast
""",
"html": """
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #74b9ff; padding: 20px; text-align: center;">
<h1 style="color: white; margin: 0;">📊 Reporte Semanal</h1>
</div>
<div style="padding: 20px;">
<h2 style="color: #333;">{{bakery_name}}</h2>
<p style="color: #666;">Semana del {{week_start}} al {{week_end}}</p>
<div style="display: flex; gap: 20px; margin: 20px 0;">
<div style="background: #dff0d8; padding: 15px; border-radius: 5px; flex: 1; text-align: center;">
<h3 style="margin: 0; color: #3c763d;">{{total_sales}}</h3>
<p style="margin: 5px 0; color: #3c763d;">Ventas Totales</p>
</div>
<div style="background: #d1ecf1; padding: 15px; border-radius: 5px; flex: 1; text-align: center;">
<h3 style="margin: 0; color: #0c5460;">{{forecast_accuracy}}%</h3>
<p style="margin: 5px 0; color: #0c5460;">Precisión</p>
</div>
</div>
<h3 style="color: #333;">Productos más vendidos:</h3>
<ul style="color: #333;">
{{#top_products}}
<li><strong>{{name}}</strong>: {{quantity}} unidades</li>
{{/top_products}}
</ul>
<div style="background: #e7f3ff; padding: 15px; border-radius: 5px; margin: 20px 0;">
<h4 style="margin-top: 0; color: #004085;">Recomendaciones:</h4>
<p style="margin-bottom: 0; color: #004085;">{{recommendations}}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{report_url}}"
style="background: #74b9ff; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 5px; font-weight: bold;">
Ver Reporte Completo
</a>
</div>
</div>
</body>
</html>
"""
}
}
return templates.get(template_name)