Lección 27 de 29Módulo 6: Automatización y Mejores Prácticas

27. Envío Automático de Emails

SMTP, attachments, HTML emails y seguridad

20 minutos

Generar reportes es solo la mitad del trabajo. En esta lección aprenderás a enviarlos automáticamente por email con adjuntos, HTML profesional y listas de distribución dinámicas.

¿Por qué automatizar el envío de emails?

El problema manual

Sin automatización, después de generar reportes debes:

  1. Abrir cliente de email
  2. Adjuntar archivos uno por uno
  3. Copiar lista de destinatarios
  4. Escribir asunto y mensaje
  5. Verificar que se envió correctamente
  6. Repetir para cada stakeholder o departamento

Tiempo: 10-15 minutos por reporte x 20 reportes mensuales = 4 horas/mes

La solución automatizada

Un script Python ejecuta todo automáticamente:

  • Envía a múltiples destinatarios simultáneamente
  • Adjunta archivos PDF, Excel, imágenes
  • Formatea emails con HTML profesional
  • Incluye contenido dinámico (métricas en el cuerpo)
  • Maneja errores y reintentos
  • Registra envíos exitosos en log

Tiempo: 0 minutos de intervención humana

Fundamentos de email con Python

Protocolo SMTP (Simple Mail Transfer Protocol)

Python usa smtplib para conectarse a servidores SMTP:

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders

# Servidores SMTP comunes
SMTP_SERVERS = {
    'Gmail': ('smtp.gmail.com', 587),
    'Outlook': ('smtp-mail.outlook.com', 587),
    'Yahoo': ('smtp.mail.yahoo.com', 587),
    'Office365': ('smtp.office365.com', 587),
    'Custom': ('smtp.tuempresa.com', 587)
}

Tipos de contenido MIME

# Texto plano
MIMEText('Contenido texto plano', 'plain')

# HTML
MIMEText('<h1>Contenido HTML</h1>', 'html')

# Adjunto
MIMEBase('application', 'octet-stream')

# Multipart (combina texto + adjuntos)
MIMEMultipart()

Implementación completa

Paso 1: Configuración segura de credenciales

# config.py
"""
Configuración de email - NO SUBIR A GIT
"""

import os
from dotenv import load_dotenv

# Cargar variables de entorno
load_dotenv()

EMAIL_CONFIG = {
    # Credenciales (usar variables de entorno)
    'smtp_server': 'smtp.gmail.com',
    'smtp_port': 587,
    'email_usuario': os.getenv('EMAIL_USUARIO', 'analytics@tuempresa.com'),
    'email_password': os.getenv('EMAIL_PASSWORD'),  # App Password para Gmail

    # Configuración de envío
    'remitente_nombre': 'Analytics Team',
    'usar_tls': True,
    'timeout': 30,

    # Listas de distribución
    'destinatarios': {
        'ejecutivos': [
            'ceo@tuempresa.com',
            'cfo@tuempresa.com',
            'coo@tuempresa.com'
        ],
        'marketing': [
            'marketing-director@tuempresa.com',
            'marketing-analyst@tuempresa.com'
        ],
        'ventas': [
            'sales-manager@tuempresa.com',
            'sales-ops@tuempresa.com'
        ],
        'todos': [
            'analytics@tuempresa.com',
            'team@tuempresa.com'
        ]
    }
}

# Para Gmail: Generar App Password en https://myaccount.google.com/apppasswords
# Para Outlook/Office365: Habilitar SMTP en configuración de cuenta

Paso 2: Clase EmailSender robusta

# email_sender.py
"""
Sistema de envío automático de emails con reportes
"""

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from email import encoders
from datetime import datetime
import os
import logging
from config import EMAIL_CONFIG

# Configurar logging
logging.basicConfig(
    filename='logs/email_sender.log',
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)

class EmailSender:
    """Envía emails automáticamente con adjuntos y HTML"""

    def __init__(self):
        """Inicializa conexión SMTP"""
        self.config = EMAIL_CONFIG
        self.smtp_server = None

    def conectar_smtp(self):
        """Establece conexión con servidor SMTP"""
        try:
            self.smtp_server = smtplib.SMTP(
                self.config['smtp_server'],
                self.config['smtp_port'],
                timeout=self.config['timeout']
            )
            self.smtp_server.ehlo()

            if self.config['usar_tls']:
                self.smtp_server.starttls()
                self.smtp_server.ehlo()

            self.smtp_server.login(
                self.config['email_usuario'],
                self.config['email_password']
            )

            logging.info("Conexión SMTP establecida exitosamente")
            return True

        except smtplib.SMTPAuthenticationError:
            logging.error("Error de autenticación SMTP - Verificar credenciales")
            return False
        except smtplib.SMTPException as e:
            logging.error(f"Error SMTP: {e}")
            return False
        except Exception as e:
            logging.error(f"Error conectando a SMTP: {e}")
            return False

    def crear_mensaje_html(self, titulo, metricas=None, contenido_adicional=""):
        """Crea email HTML profesional con métricas"""

        # Extraer métricas si existen
        metricas_html = ""
        if metricas:
            metricas_html = f"""
            <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; margin: 30px 0;">
                <div style="background: linear-gradient(135deg, #ae4291 0%, #35286b 100%);
                            color: white; padding: 20px; border-radius: 10px; text-align: center;">
                    <div style="font-size: 14px; opacity: 0.9;">Ventas Totales</div>
                    <div style="font-size: 32px; font-weight: bold; margin: 10px 0;">
                        ${metricas.get('total_ventas', 0):,.0f}
                    </div>
                    <div style="font-size: 14px;">
                        {metricas.get('var_ventas', 0):+.1f}% vs mes anterior
                    </div>
                </div>

                <div style="background: linear-gradient(135deg, #ae4291 0%, #35286b 100%);
                            color: white; padding: 20px; border-radius: 10px; text-align: center;">
                    <div style="font-size: 14px; opacity: 0.9;">Transacciones</div>
                    <div style="font-size: 32px; font-weight: bold; margin: 10px 0;">
                        {metricas.get('num_transacciones', 0):,}
                    </div>
                    <div style="font-size: 14px;">
                        {metricas.get('var_transacciones', 0):+.1f}% vs mes anterior
                    </div>
                </div>
            </div>
            """

        html_template = f"""
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
             margin: 0; padding: 0; background-color: #f5f5f5;">

    <div style="max-width: 600px; margin: 20px auto; background-color: white;
                border-radius: 10px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">

        <!-- Header -->
        <div style="background: linear-gradient(135deg, #35286b 0%, #ae4291 100%);
                    padding: 30px; text-align: center; color: white;">
            <h1 style="margin: 0; font-size: 28px;">{titulo}</h1>
            <p style="margin: 10px 0 0 0; opacity: 0.9;">
                {datetime.now().strftime('%d de %B, %Y')}
            </p>
        </div>

        <!-- Contenido -->
        <div style="padding: 30px;">
            <p style="font-size: 16px; line-height: 1.6; color: #333;">
                Hola,
            </p>
            <p style="font-size: 16px; line-height: 1.6; color: #333;">
                Adjunto encontrarás el reporte automatizado de análisis.
                A continuación un resumen de las métricas clave:
            </p>

            {metricas_html}

            {contenido_adicional}

            <p style="font-size: 16px; line-height: 1.6; color: #333;">
                Para más detalles, por favor revisa los archivos adjuntos.
            </p>

            <div style="margin: 30px 0; padding: 20px; background-color: #f8f9fa;
                        border-left: 4px solid #ae4291; border-radius: 5px;">
                <p style="margin: 0; font-size: 14px; color: #666;">
                    <strong>Nota:</strong> Este email fue generado automáticamente.
                    Si tienes preguntas, contacta al equipo de Analytics.
                </p>
            </div>
        </div>

        <!-- Footer -->
        <div style="background-color: #f8f9fa; padding: 20px; text-align: center;
                    border-top: 1px solid #dee2e6;">
            <p style="margin: 0; font-size: 14px; color: #666;">
                Analytics Team | Powered by Python
            </p>
        </div>
    </div>

</body>
</html>
"""
        return html_template

    def adjuntar_archivo(self, mensaje, ruta_archivo):
        """Adjunta archivo al email"""
        try:
            # Verificar que archivo existe
            if not os.path.exists(ruta_archivo):
                logging.warning(f"Archivo no encontrado: {ruta_archivo}")
                return False

            # Determinar tipo MIME basado en extensión
            nombre_archivo = os.path.basename(ruta_archivo)
            extension = nombre_archivo.split('.')[-1].lower()

            mime_types = {
                'pdf': ('application', 'pdf'),
                'xlsx': ('application', 'vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
                'csv': ('text', 'csv'),
                'png': ('image', 'png'),
                'jpg': ('image', 'jpeg'),
                'jpeg': ('image', 'jpeg')
            }

            maintype, subtype = mime_types.get(extension, ('application', 'octet-stream'))

            # Leer y adjuntar archivo
            with open(ruta_archivo, 'rb') as archivo:
                if maintype == 'image':
                    adjunto = MIMEImage(archivo.read(), _subtype=subtype)
                else:
                    adjunto = MIMEBase(maintype, subtype)
                    adjunto.set_payload(archivo.read())
                    encoders.encode_base64(adjunto)

                adjunto.add_header(
                    'Content-Disposition',
                    f'attachment; filename={nombre_archivo}'
                )
                mensaje.attach(adjunto)

            logging.info(f"Archivo adjuntado: {nombre_archivo}")
            return True

        except Exception as e:
            logging.error(f"Error adjuntando archivo {ruta_archivo}: {e}")
            return False

    def enviar_email(self, destinatarios, asunto, cuerpo_html, archivos_adjuntos=None, metricas=None):
        """
        Envía email con adjuntos

        Args:
            destinatarios: Lista de emails o string con grupo ('ejecutivos', 'marketing')
            asunto: Asunto del email
            cuerpo_html: HTML del email o None para usar plantilla
            archivos_adjuntos: Lista de rutas de archivos a adjuntar
            metricas: Dict con métricas para incluir en template
        """
        try:
            # Resolver destinatarios si es un grupo
            if isinstance(destinatarios, str):
                destinatarios = self.config['destinatarios'].get(
                    destinatarios,
                    [destinatarios]
                )

            # Crear mensaje
            mensaje = MIMEMultipart()
            mensaje['From'] = f"{self.config['remitente_nombre']} <{self.config['email_usuario']}>"
            mensaje['To'] = ', '.join(destinatarios)
            mensaje['Subject'] = asunto
            mensaje['Date'] = datetime.now().strftime('%a, %d %b %Y %H:%M:%S %z')

            # Usar plantilla HTML si no se proporciona cuerpo
            if cuerpo_html is None:
                cuerpo_html = self.crear_mensaje_html(asunto, metricas)

            # Adjuntar cuerpo HTML
            mensaje.attach(MIMEText(cuerpo_html, 'html', 'utf-8'))

            # Adjuntar archivos
            if archivos_adjuntos:
                for archivo in archivos_adjuntos:
                    self.adjuntar_archivo(mensaje, archivo)

            # Conectar y enviar
            if not self.smtp_server or not self.smtp_server.sock:
                if not self.conectar_smtp():
                    return False

            self.smtp_server.send_message(mensaje)

            logging.info(f"Email enviado exitosamente a {len(destinatarios)} destinatarios")
            print(f"✅ Email enviado a: {', '.join(destinatarios)}")
            return True

        except smtplib.SMTPException as e:
            logging.error(f"Error SMTP enviando email: {e}")
            print(f"❌ Error SMTP: {e}")
            return False
        except Exception as e:
            logging.error(f"Error enviando email: {e}")
            print(f"❌ Error: {e}")
            return False

    def enviar_reporte_completo(self, grupo_destinatarios, pdf_path, excel_path, html_path, metricas):
        """Envía reporte completo con todos los adjuntos"""
        fecha = datetime.now().strftime('%Y-%m-%d')
        asunto = f"Reporte de Ventas - {fecha}"

        archivos = [pdf_path, excel_path, html_path]
        archivos_existentes = [f for f in archivos if os.path.exists(f)]

        if not archivos_existentes:
            logging.error("No se encontraron archivos para adjuntar")
            return False

        return self.enviar_email(
            destinatarios=grupo_destinatarios,
            asunto=asunto,
            cuerpo_html=None,  # Usa template
            archivos_adjuntos=archivos_existentes,
            metricas=metricas
        )

    def cerrar_conexion(self):
        """Cierra conexión SMTP"""
        if self.smtp_server:
            try:
                self.smtp_server.quit()
                logging.info("Conexión SMTP cerrada")
            except:
                pass

Paso 3: Script de integración completa

# send_report.py
"""
Genera y envía reporte automáticamente
"""

from report_generator import ReportGenerator
from email_sender import EmailSender
from datetime import datetime
import os

def main():
    """Genera reportes y los envía por email"""
    print("="*60)
    print("📧 GENERACIÓN Y ENVÍO AUTOMÁTICO DE REPORTES")
    print("="*60)
    print(f"Inicio: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")

    # Crear directorios necesarios
    os.makedirs('logs', exist_ok=True)

    try:
        # 1. Generar reportes
        print("📊 PASO 1: Generando reportes...")
        generator = ReportGenerator(data_path='data/ventas.csv')

        pdf_file = generator.generar_reporte_pdf()
        excel_file = generator.generar_reporte_excel()
        html_file = generator.generar_reporte_html()

        # Obtener métricas para incluir en email
        metricas = generator.calcular_metricas()

        # 2. Enviar por email
        print("\n📧 PASO 2: Enviando reportes por email...")
        sender = EmailSender()

        # Enviar a ejecutivos (solo PDF)
        print("\n→ Enviando a ejecutivos...")
        sender.enviar_email(
            destinatarios='ejecutivos',
            asunto=f"Reporte Ejecutivo de Ventas - {datetime.now().strftime('%Y-%m-%d')}",
            cuerpo_html=None,
            archivos_adjuntos=[pdf_file],
            metricas=metricas
        )

        # Enviar a marketing (PDF + Excel)
        print("\n→ Enviando a marketing...")
        sender.enviar_email(
            destinatarios='marketing',
            asunto=f"Reporte de Marketing - {datetime.now().strftime('%Y-%m-%d')}",
            cuerpo_html=None,
            archivos_adjuntos=[pdf_file, excel_file],
            metricas=metricas
        )

        # Enviar reporte completo a analytics
        print("\n→ Enviando reporte completo a analytics...")
        sender.enviar_reporte_completo(
            grupo_destinatarios='todos',
            pdf_path=pdf_file,
            excel_path=excel_file,
            html_path=html_file,
            metricas=metricas
        )

        # Cerrar conexión
        sender.cerrar_conexion()

        print("\n" + "="*60)
        print("✅ PROCESO COMPLETADO EXITOSAMENTE")
        print("="*60)
        print(f"Fin: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")

    except Exception as e:
        print(f"\n❌ Error en el proceso: {e}")
        logging.error(f"Error en proceso principal: {e}")

if __name__ == "__main__":
    main()

Seguridad y mejores prácticas

1. No hardcodear contraseñas

# ❌ MAL - Contraseña en código
password = "mi_password_secreto"

# ✅ BIEN - Variables de entorno
import os
password = os.getenv('EMAIL_PASSWORD')

# ✅ MEJOR - Archivo .env (NO SUBIR A GIT)
from dotenv import load_dotenv
load_dotenv()
password = os.getenv('EMAIL_PASSWORD')

2. Usar App Passwords (Gmail)

Gmail requiere "App Passwords" en vez de contraseña real:

  1. Ir a https://myaccount.google.com/apppasswords
  2. Seleccionar "Mail" y "Other (Custom name)"
  3. Copiar password de 16 caracteres
  4. Usar ese password en tu script

3. Rate limiting y buenas prácticas

import time

# No enviar más de 100 emails/minuto
for destinatario in destinatarios:
    enviar_email(destinatario, asunto, cuerpo)
    time.sleep(0.6)  # Esperar 0.6 segundos entre envíos

4. Manejo de errores y reintentos

def enviar_con_reintentos(email_func, max_intentos=3):
    """Reintenta envío si falla"""
    for intento in range(max_intentos):
        try:
            resultado = email_func()
            if resultado:
                return True
        except Exception as e:
            logging.warning(f"Intento {intento + 1} falló: {e}")
            if intento < max_intentos - 1:
                time.sleep(5)  # Esperar 5 segundos antes de reintentar
    return False

Casos de uso avanzados

1. Emails personalizados por destinatario

for destinatario in ['ceo@empresa.com', 'cfo@empresa.com']:
    # Filtrar métricas específicas para cada rol
    metricas_personalizadas = filtrar_por_rol(destinatario)

    enviar_email(
        destinatarios=[destinatario],
        asunto=f"Tu Reporte Personalizado - {datetime.now()}",
        metricas=metricas_personalizadas
    )

2. Alertas condicionales

# Enviar solo si hay cambios significativos
if abs(metricas['var_ventas']) > 10:
    enviar_email(
        destinatarios='ejecutivos',
        asunto="🚨 ALERTA: Cambio significativo en ventas",
        metricas=metricas
    )

3. Resúmenes semanales vs diarios

from datetime import datetime

dia = datetime.now().weekday()

if dia == 0:  # Lunes - reporte semanal
    enviar_reporte_semanal()
else:  # Otros días - reporte diario
    enviar_reporte_diario()

Resumen de la lección

✅ Implementaste sistema completo de envío de emails con Python ✅ Creaste templates HTML profesionales para emails ✅ Configuraste adjuntos múltiples (PDF, Excel, imágenes) ✅ Aprendiste seguridad y best practices (App Passwords, variables de entorno) ✅ Desarrollaste listas de distribución y envíos personalizados


🎯 Próxima lección: 28. Scheduling con Cron - Programa estos scripts para ejecución automática

En la siguiente lección aprenderás a programar estos scripts para que se ejecuten automáticamente cada día, semana o mes sin ninguna intervención manual. ¡La automatización total está a una lección de distancia!

¿Completaste esta lección?

Marca esta lección como completada. Tu progreso se guardará en tu navegador.