Add notification service
This commit is contained in:
547
services/notification/app/services/email_service.py
Normal file
547
services/notification/app/services/email_service.py
Normal file
@@ -0,0 +1,547 @@
|
||||
# ================================================================
|
||||
# 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:
|
||||
server = aiosmtplib.SMTP(hostname=self.smtp_host, port=self.smtp_port, use_tls=True)
|
||||
else:
|
||||
server = aiosmtplib.SMTP(hostname=self.smtp_host, port=self.smtp_port)
|
||||
|
||||
await server.connect()
|
||||
|
||||
if self.smtp_tls:
|
||||
await server.starttls()
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user