diff --git a/.env.example b/.env.example index f5f0ad25..30286c89 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,39 @@ STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here STRIPE_TRIAL_DAYS=30 +# ============================================================================= +# EMAIL CONFIGURATION +# ============================================================================= +# Provider: smtp, sendgrid, mailgun, ses +EMAIL_PROVIDER=smtp +EMAIL_FROM_ADDRESS=noreply@wizamart.com +EMAIL_FROM_NAME=Wizamart +EMAIL_REPLY_TO= + +# SMTP Settings (used when EMAIL_PROVIDER=smtp) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_USE_TLS=true +SMTP_USE_SSL=false + +# SendGrid (used when EMAIL_PROVIDER=sendgrid) +# SENDGRID_API_KEY=SG.your_api_key_here + +# Mailgun (used when EMAIL_PROVIDER=mailgun) +# MAILGUN_API_KEY=your_api_key_here +# MAILGUN_DOMAIN=mg.yourdomain.com + +# Amazon SES (used when EMAIL_PROVIDER=ses) +# AWS_ACCESS_KEY_ID=your_access_key +# AWS_SECRET_ACCESS_KEY=your_secret_key +# AWS_REGION=eu-west-1 + +# Email behavior +EMAIL_ENABLED=true +EMAIL_DEBUG=false + # ============================================================================= # PLATFORM LIMITS # ============================================================================= diff --git a/alembic/versions/d7a4a3f06394_add_email_templates_and_logs_tables.py b/alembic/versions/d7a4a3f06394_add_email_templates_and_logs_tables.py new file mode 100644 index 00000000..5861cd13 --- /dev/null +++ b/alembic/versions/d7a4a3f06394_add_email_templates_and_logs_tables.py @@ -0,0 +1,270 @@ +"""add email templates and logs tables + +Revision ID: d7a4a3f06394 +Revises: 404b3e2d2865 +Create Date: 2025-12-27 20:48:00.661523 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd7a4a3f06394' +down_revision: Union[str, None] = '404b3e2d2865' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('email_templates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=100), nullable=False), + sa.Column('language', sa.String(length=5), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('category', sa.String(length=50), nullable=False), + sa.Column('subject', sa.String(length=500), nullable=False), + sa.Column('body_html', sa.Text(), nullable=False), + sa.Column('body_text', sa.Text(), nullable=True), + sa.Column('variables', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sqlite_autoincrement=True + ) + op.create_index(op.f('ix_email_templates_category'), 'email_templates', ['category'], unique=False) + op.create_index(op.f('ix_email_templates_code'), 'email_templates', ['code'], unique=False) + op.create_index(op.f('ix_email_templates_id'), 'email_templates', ['id'], unique=False) + op.create_table('email_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('template_code', sa.String(length=100), nullable=True), + sa.Column('template_id', sa.Integer(), nullable=True), + sa.Column('recipient_email', sa.String(length=255), nullable=False), + sa.Column('recipient_name', sa.String(length=255), nullable=True), + sa.Column('subject', sa.String(length=500), nullable=False), + sa.Column('body_html', sa.Text(), nullable=True), + sa.Column('body_text', sa.Text(), nullable=True), + sa.Column('from_email', sa.String(length=255), nullable=False), + sa.Column('from_name', sa.String(length=255), nullable=True), + sa.Column('reply_to', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('sent_at', sa.DateTime(), nullable=True), + sa.Column('delivered_at', sa.DateTime(), nullable=True), + sa.Column('opened_at', sa.DateTime(), nullable=True), + sa.Column('clicked_at', sa.DateTime(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('retry_count', sa.Integer(), nullable=False), + sa.Column('provider', sa.String(length=50), nullable=True), + sa.Column('provider_message_id', sa.String(length=255), nullable=True), + sa.Column('vendor_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('related_type', sa.String(length=50), nullable=True), + sa.Column('related_id', sa.Integer(), nullable=True), + sa.Column('extra_data', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['template_id'], ['email_templates.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_email_logs_id'), 'email_logs', ['id'], unique=False) + op.create_index(op.f('ix_email_logs_provider_message_id'), 'email_logs', ['provider_message_id'], unique=False) + op.create_index(op.f('ix_email_logs_recipient_email'), 'email_logs', ['recipient_email'], unique=False) + op.create_index(op.f('ix_email_logs_status'), 'email_logs', ['status'], unique=False) + op.create_index(op.f('ix_email_logs_template_code'), 'email_logs', ['template_code'], unique=False) + op.create_index(op.f('ix_email_logs_user_id'), 'email_logs', ['user_id'], unique=False) + op.create_index(op.f('ix_email_logs_vendor_id'), 'email_logs', ['vendor_id'], unique=False) + op.alter_column('application_logs', 'created_at', + existing_type=sa.DATETIME(), + nullable=False) + op.alter_column('application_logs', 'updated_at', + existing_type=sa.DATETIME(), + nullable=False) + op.drop_index(op.f('ix_capacity_snapshots_date'), table_name='capacity_snapshots') + op.create_index('ix_capacity_snapshots_date', 'capacity_snapshots', ['snapshot_date'], unique=False) + op.create_index(op.f('ix_capacity_snapshots_snapshot_date'), 'capacity_snapshots', ['snapshot_date'], unique=True) + op.alter_column('cart_items', 'created_at', + existing_type=sa.DATETIME(), + nullable=False) + op.alter_column('cart_items', 'updated_at', + existing_type=sa.DATETIME(), + nullable=False) + op.drop_index(op.f('ix_customers_addresses_id'), table_name='customer_addresses') + op.create_index(op.f('ix_customer_addresses_id'), 'customer_addresses', ['id'], unique=False) + op.alter_column('inventory', 'warehouse', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('inventory', 'bin_location', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('inventory', 'location', + existing_type=sa.VARCHAR(), + nullable=True) + op.drop_index(op.f('idx_inventory_product_location'), table_name='inventory') + op.drop_constraint(op.f('uq_inventory_product_location'), 'inventory', type_='unique') + op.create_unique_constraint('uq_inventory_product_warehouse_bin', 'inventory', ['product_id', 'warehouse', 'bin_location']) + op.create_index(op.f('ix_marketplace_import_errors_import_job_id'), 'marketplace_import_errors', ['import_job_id'], unique=False) + op.create_index(op.f('ix_marketplace_product_translations_id'), 'marketplace_product_translations', ['id'], unique=False) + op.alter_column('marketplace_products', 'is_digital', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('0')) + op.alter_column('marketplace_products', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('1')) + op.drop_index(op.f('idx_mp_is_active'), table_name='marketplace_products') + op.drop_index(op.f('idx_mp_platform'), table_name='marketplace_products') + op.drop_index(op.f('idx_mp_sku'), table_name='marketplace_products') + op.create_index(op.f('ix_marketplace_products_is_active'), 'marketplace_products', ['is_active'], unique=False) + op.create_index(op.f('ix_marketplace_products_is_digital'), 'marketplace_products', ['is_digital'], unique=False) + op.create_index(op.f('ix_marketplace_products_mpn'), 'marketplace_products', ['mpn'], unique=False) + op.create_index(op.f('ix_marketplace_products_platform'), 'marketplace_products', ['platform'], unique=False) + op.create_index(op.f('ix_marketplace_products_sku'), 'marketplace_products', ['sku'], unique=False) + op.drop_index(op.f('uq_order_item_exception'), table_name='order_item_exceptions') + op.create_index(op.f('ix_order_item_exceptions_original_gtin'), 'order_item_exceptions', ['original_gtin'], unique=False) + op.create_unique_constraint(None, 'order_item_exceptions', ['order_item_id']) + op.alter_column('order_items', 'needs_product_match', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text("'0'")) + op.drop_index(op.f('ix_order_items_gtin'), table_name='order_items') + op.drop_index(op.f('ix_order_items_product_id'), table_name='order_items') + op.create_index(op.f('ix_product_translations_id'), 'product_translations', ['id'], unique=False) + op.drop_index(op.f('idx_product_active'), table_name='products') + op.drop_index(op.f('idx_product_featured'), table_name='products') + op.drop_index(op.f('idx_product_gtin'), table_name='products') + op.drop_index(op.f('idx_product_vendor_gtin'), table_name='products') + op.drop_constraint(op.f('uq_product'), 'products', type_='unique') + op.create_index('idx_product_vendor_active', 'products', ['vendor_id', 'is_active'], unique=False) + op.create_index('idx_product_vendor_featured', 'products', ['vendor_id', 'is_featured'], unique=False) + op.create_index(op.f('ix_products_gtin'), 'products', ['gtin'], unique=False) + op.create_index(op.f('ix_products_vendor_sku'), 'products', ['vendor_sku'], unique=False) + op.create_unique_constraint('uq_vendor_marketplace_product', 'products', ['vendor_id', 'marketplace_product_id']) + op.drop_index(op.f('ix_vendors_domains_domain'), table_name='vendor_domains') + op.drop_index(op.f('ix_vendors_domains_id'), table_name='vendor_domains') + op.create_index(op.f('ix_vendor_domains_domain'), 'vendor_domains', ['domain'], unique=True) + op.create_index(op.f('ix_vendor_domains_id'), 'vendor_domains', ['id'], unique=False) + op.alter_column('vendor_subscriptions', 'payment_retry_count', + existing_type=sa.INTEGER(), + nullable=False, + existing_server_default=sa.text('0')) + op.create_foreign_key(None, 'vendor_subscriptions', 'subscription_tiers', ['tier_id'], ['id']) + op.drop_index(op.f('ix_vendors_themes_id'), table_name='vendor_themes') + op.create_index(op.f('ix_vendor_themes_id'), 'vendor_themes', ['id'], unique=False) + op.drop_index(op.f('ix_vendors_users_id'), table_name='vendor_users') + op.drop_index(op.f('ix_vendors_users_invitation_token'), table_name='vendor_users') + op.create_index(op.f('ix_vendor_users_id'), 'vendor_users', ['id'], unique=False) + op.create_index(op.f('ix_vendor_users_invitation_token'), 'vendor_users', ['invitation_token'], unique=False) + op.alter_column('vendors', 'company_id', + existing_type=sa.INTEGER(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('vendors', 'company_id', + existing_type=sa.INTEGER(), + nullable=True) + op.drop_index(op.f('ix_vendor_users_invitation_token'), table_name='vendor_users') + op.drop_index(op.f('ix_vendor_users_id'), table_name='vendor_users') + op.create_index(op.f('ix_vendors_users_invitation_token'), 'vendor_users', ['invitation_token'], unique=False) + op.create_index(op.f('ix_vendors_users_id'), 'vendor_users', ['id'], unique=False) + op.drop_index(op.f('ix_vendor_themes_id'), table_name='vendor_themes') + op.create_index(op.f('ix_vendors_themes_id'), 'vendor_themes', ['id'], unique=False) + op.drop_constraint(None, 'vendor_subscriptions', type_='foreignkey') + op.alter_column('vendor_subscriptions', 'payment_retry_count', + existing_type=sa.INTEGER(), + nullable=True, + existing_server_default=sa.text('0')) + op.drop_index(op.f('ix_vendor_domains_id'), table_name='vendor_domains') + op.drop_index(op.f('ix_vendor_domains_domain'), table_name='vendor_domains') + op.create_index(op.f('ix_vendors_domains_id'), 'vendor_domains', ['id'], unique=False) + op.create_index(op.f('ix_vendors_domains_domain'), 'vendor_domains', ['domain'], unique=1) + op.drop_constraint('uq_vendor_marketplace_product', 'products', type_='unique') + op.drop_index(op.f('ix_products_vendor_sku'), table_name='products') + op.drop_index(op.f('ix_products_gtin'), table_name='products') + op.drop_index('idx_product_vendor_featured', table_name='products') + op.drop_index('idx_product_vendor_active', table_name='products') + op.create_unique_constraint(op.f('uq_product'), 'products', ['vendor_id', 'marketplace_product_id']) + op.create_index(op.f('idx_product_vendor_gtin'), 'products', ['vendor_id', 'gtin'], unique=False) + op.create_index(op.f('idx_product_gtin'), 'products', ['gtin'], unique=False) + op.create_index(op.f('idx_product_featured'), 'products', ['vendor_id', 'is_featured'], unique=False) + op.create_index(op.f('idx_product_active'), 'products', ['vendor_id', 'is_active'], unique=False) + op.drop_index(op.f('ix_product_translations_id'), table_name='product_translations') + op.create_index(op.f('ix_order_items_product_id'), 'order_items', ['product_id'], unique=False) + op.create_index(op.f('ix_order_items_gtin'), 'order_items', ['gtin'], unique=False) + op.alter_column('order_items', 'needs_product_match', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text("'0'")) + op.drop_constraint(None, 'order_item_exceptions', type_='unique') + op.drop_index(op.f('ix_order_item_exceptions_original_gtin'), table_name='order_item_exceptions') + op.create_index(op.f('uq_order_item_exception'), 'order_item_exceptions', ['order_item_id'], unique=1) + op.drop_index(op.f('ix_marketplace_products_sku'), table_name='marketplace_products') + op.drop_index(op.f('ix_marketplace_products_platform'), table_name='marketplace_products') + op.drop_index(op.f('ix_marketplace_products_mpn'), table_name='marketplace_products') + op.drop_index(op.f('ix_marketplace_products_is_digital'), table_name='marketplace_products') + op.drop_index(op.f('ix_marketplace_products_is_active'), table_name='marketplace_products') + op.create_index(op.f('idx_mp_sku'), 'marketplace_products', ['sku'], unique=False) + op.create_index(op.f('idx_mp_platform'), 'marketplace_products', ['platform'], unique=False) + op.create_index(op.f('idx_mp_is_active'), 'marketplace_products', ['is_active'], unique=False) + op.alter_column('marketplace_products', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('1')) + op.alter_column('marketplace_products', 'is_digital', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('0')) + op.drop_index(op.f('ix_marketplace_product_translations_id'), table_name='marketplace_product_translations') + op.drop_index(op.f('ix_marketplace_import_errors_import_job_id'), table_name='marketplace_import_errors') + op.drop_constraint('uq_inventory_product_warehouse_bin', 'inventory', type_='unique') + op.create_unique_constraint(op.f('uq_inventory_product_location'), 'inventory', ['product_id', 'location']) + op.create_index(op.f('idx_inventory_product_location'), 'inventory', ['product_id', 'location'], unique=False) + op.alter_column('inventory', 'location', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('inventory', 'bin_location', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('inventory', 'warehouse', + existing_type=sa.VARCHAR(), + nullable=True) + op.drop_index(op.f('ix_customer_addresses_id'), table_name='customer_addresses') + op.create_index(op.f('ix_customers_addresses_id'), 'customer_addresses', ['id'], unique=False) + op.alter_column('cart_items', 'updated_at', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('cart_items', 'created_at', + existing_type=sa.DATETIME(), + nullable=True) + op.drop_index(op.f('ix_capacity_snapshots_snapshot_date'), table_name='capacity_snapshots') + op.drop_index('ix_capacity_snapshots_date', table_name='capacity_snapshots') + op.create_index(op.f('ix_capacity_snapshots_date'), 'capacity_snapshots', ['snapshot_date'], unique=1) + op.alter_column('application_logs', 'updated_at', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('application_logs', 'created_at', + existing_type=sa.DATETIME(), + nullable=True) + op.drop_index(op.f('ix_email_logs_vendor_id'), table_name='email_logs') + op.drop_index(op.f('ix_email_logs_user_id'), table_name='email_logs') + op.drop_index(op.f('ix_email_logs_template_code'), table_name='email_logs') + op.drop_index(op.f('ix_email_logs_status'), table_name='email_logs') + op.drop_index(op.f('ix_email_logs_recipient_email'), table_name='email_logs') + op.drop_index(op.f('ix_email_logs_provider_message_id'), table_name='email_logs') + op.drop_index(op.f('ix_email_logs_id'), table_name='email_logs') + op.drop_table('email_logs') + op.drop_index(op.f('ix_email_templates_id'), table_name='email_templates') + op.drop_index(op.f('ix_email_templates_code'), table_name='email_templates') + op.drop_index(op.f('ix_email_templates_category'), table_name='email_templates') + op.drop_table('email_templates') + # ### end Alembic commands ### diff --git a/app/core/config.py b/app/core/config.py index b599c2b6..6e814786 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -125,6 +125,39 @@ class Settings(BaseSettings): stripe_webhook_secret: str = "" stripe_trial_days: int = 30 # 1-month free trial (card collected upfront but not charged) + # ============================================================================= + # EMAIL CONFIGURATION + # ============================================================================= + # Provider: smtp, sendgrid, mailgun, ses + email_provider: str = "smtp" + email_from_address: str = "noreply@wizamart.com" + email_from_name: str = "Wizamart" + email_reply_to: str = "" # Optional reply-to address + + # SMTP Settings (used when email_provider=smtp) + smtp_host: str = "localhost" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_use_tls: bool = True + smtp_use_ssl: bool = False # For port 465 + + # SendGrid (used when email_provider=sendgrid) + sendgrid_api_key: str = "" + + # Mailgun (used when email_provider=mailgun) + mailgun_api_key: str = "" + mailgun_domain: str = "" + + # Amazon SES (used when email_provider=ses) + aws_access_key_id: str = "" + aws_secret_access_key: str = "" + aws_region: str = "eu-west-1" + + # Email behavior + email_enabled: bool = True # Set to False to disable all emails + email_debug: bool = False # Log emails instead of sending (for development) + # ============================================================================= # DEMO/SEED DATA CONFIGURATION # ============================================================================= diff --git a/app/services/email_service.py b/app/services/email_service.py new file mode 100644 index 00000000..d63af1aa --- /dev/null +++ b/app/services/email_service.py @@ -0,0 +1,572 @@ +# app/services/email_service.py +""" +Email service with multi-provider support. + +Supports: +- SMTP (default) +- SendGrid +- Mailgun +- Amazon SES + +Features: +- Multi-language templates from database +- Jinja2 template rendering +- Email logging and tracking +- Queue support via background tasks +""" + +import json +import logging +import smtplib +from abc import ABC, abstractmethod +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Any + +from jinja2 import Environment, BaseLoader +from sqlalchemy.orm import Session + +from app.core.config import settings +from models.database.email import EmailLog, EmailStatus, EmailTemplate + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# EMAIL PROVIDER ABSTRACTION +# ============================================================================= + + +class EmailProvider(ABC): + """Abstract base class for email providers.""" + + @abstractmethod + def send( + self, + to_email: str, + to_name: str | None, + subject: str, + body_html: str, + body_text: str | None, + from_email: str, + from_name: str | None, + reply_to: str | None = None, + ) -> tuple[bool, str | None, str | None]: + """ + Send an email. + + Returns: + tuple: (success, provider_message_id, error_message) + """ + pass + + +class SMTPProvider(EmailProvider): + """SMTP email provider.""" + + def send( + self, + to_email: str, + to_name: str | None, + subject: str, + body_html: str, + body_text: str | None, + from_email: str, + from_name: str | None, + reply_to: str | None = None, + ) -> tuple[bool, str | None, str | None]: + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email + msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email + + if reply_to: + msg["Reply-To"] = reply_to + + # Attach text and HTML parts + if body_text: + msg.attach(MIMEText(body_text, "plain", "utf-8")) + msg.attach(MIMEText(body_html, "html", "utf-8")) + + # Connect and send + if settings.smtp_use_ssl: + server = smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port) + else: + server = smtplib.SMTP(settings.smtp_host, settings.smtp_port) + + try: + if settings.smtp_use_tls and not settings.smtp_use_ssl: + server.starttls() + + if settings.smtp_user and settings.smtp_password: + server.login(settings.smtp_user, settings.smtp_password) + + server.sendmail(from_email, [to_email], msg.as_string()) + return True, None, None + + finally: + server.quit() + + except Exception as e: + logger.error(f"SMTP send error: {e}") + return False, None, str(e) + + +class SendGridProvider(EmailProvider): + """SendGrid email provider.""" + + def send( + self, + to_email: str, + to_name: str | None, + subject: str, + body_html: str, + body_text: str | None, + from_email: str, + from_name: str | None, + reply_to: str | None = None, + ) -> tuple[bool, str | None, str | None]: + try: + from sendgrid import SendGridAPIClient + from sendgrid.helpers.mail import Mail, Email, To, Content + + message = Mail( + from_email=Email(from_email, from_name), + to_emails=To(to_email, to_name), + subject=subject, + ) + + message.add_content(Content("text/html", body_html)) + if body_text: + message.add_content(Content("text/plain", body_text)) + + if reply_to: + message.reply_to = Email(reply_to) + + sg = SendGridAPIClient(settings.sendgrid_api_key) + response = sg.send(message) + + if response.status_code in (200, 201, 202): + message_id = response.headers.get("X-Message-Id") + return True, message_id, None + else: + return False, None, f"SendGrid error: {response.status_code}" + + except ImportError: + return False, None, "SendGrid library not installed. Run: pip install sendgrid" + except Exception as e: + logger.error(f"SendGrid send error: {e}") + return False, None, str(e) + + +class MailgunProvider(EmailProvider): + """Mailgun email provider.""" + + def send( + self, + to_email: str, + to_name: str | None, + subject: str, + body_html: str, + body_text: str | None, + from_email: str, + from_name: str | None, + reply_to: str | None = None, + ) -> tuple[bool, str | None, str | None]: + try: + import requests + + from_str = f"{from_name} <{from_email}>" if from_name else from_email + to_str = f"{to_name} <{to_email}>" if to_name else to_email + + data = { + "from": from_str, + "to": to_str, + "subject": subject, + "html": body_html, + } + + if body_text: + data["text"] = body_text + if reply_to: + data["h:Reply-To"] = reply_to + + response = requests.post( + f"https://api.mailgun.net/v3/{settings.mailgun_domain}/messages", + auth=("api", settings.mailgun_api_key), + data=data, + timeout=30, + ) + + if response.status_code == 200: + result = response.json() + return True, result.get("id"), None + else: + return False, None, f"Mailgun error: {response.status_code} - {response.text}" + + except Exception as e: + logger.error(f"Mailgun send error: {e}") + return False, None, str(e) + + +class SESProvider(EmailProvider): + """Amazon SES email provider.""" + + def send( + self, + to_email: str, + to_name: str | None, + subject: str, + body_html: str, + body_text: str | None, + from_email: str, + from_name: str | None, + reply_to: str | None = None, + ) -> tuple[bool, str | None, str | None]: + try: + import boto3 + + ses = boto3.client( + "ses", + region_name=settings.aws_region, + aws_access_key_id=settings.aws_access_key_id, + aws_secret_access_key=settings.aws_secret_access_key, + ) + + from_str = f"{from_name} <{from_email}>" if from_name else from_email + + body = {"Html": {"Charset": "UTF-8", "Data": body_html}} + if body_text: + body["Text"] = {"Charset": "UTF-8", "Data": body_text} + + kwargs = { + "Source": from_str, + "Destination": {"ToAddresses": [to_email]}, + "Message": { + "Subject": {"Charset": "UTF-8", "Data": subject}, + "Body": body, + }, + } + + if reply_to: + kwargs["ReplyToAddresses"] = [reply_to] + + response = ses.send_email(**kwargs) + return True, response.get("MessageId"), None + + except ImportError: + return False, None, "boto3 library not installed. Run: pip install boto3" + except Exception as e: + logger.error(f"SES send error: {e}") + return False, None, str(e) + + +class DebugProvider(EmailProvider): + """Debug provider - logs emails instead of sending.""" + + def send( + self, + to_email: str, + to_name: str | None, + subject: str, + body_html: str, + body_text: str | None, + from_email: str, + from_name: str | None, + reply_to: str | None = None, + ) -> tuple[bool, str | None, str | None]: + logger.info( + f"\n{'='*60}\n" + f"DEBUG EMAIL\n" + f"{'='*60}\n" + f"To: {to_name} <{to_email}>\n" + f"From: {from_name} <{from_email}>\n" + f"Reply-To: {reply_to}\n" + f"Subject: {subject}\n" + f"{'='*60}\n" + f"Body (text):\n{body_text or '(none)'}\n" + f"{'='*60}\n" + f"Body (html):\n{body_html[:500]}...\n" + f"{'='*60}\n" + ) + return True, f"debug-{to_email}", None + + +# ============================================================================= +# EMAIL SERVICE +# ============================================================================= + + +def get_provider() -> EmailProvider: + """Get the configured email provider.""" + if settings.email_debug: + return DebugProvider() + + provider_map = { + "smtp": SMTPProvider, + "sendgrid": SendGridProvider, + "mailgun": MailgunProvider, + "ses": SESProvider, + } + + provider_class = provider_map.get(settings.email_provider.lower()) + if not provider_class: + logger.warning(f"Unknown email provider: {settings.email_provider}, using SMTP") + return SMTPProvider() + + return provider_class() + + +class EmailService: + """ + Email service for sending templated emails. + + Usage: + email_service = EmailService(db) + + # Send using database template + email_service.send_template( + template_code="signup_welcome", + language="en", + to_email="user@example.com", + to_name="John Doe", + variables={"first_name": "John", "login_url": "https://..."}, + vendor_id=1, + ) + + # Send raw email + email_service.send_raw( + to_email="user@example.com", + subject="Hello", + body_html="
Custom message
", + body_text="Hello\n\nCustom message", +) +``` + +## Email Templates + +### Creating Templates + +Templates use Jinja2 syntax for variable interpolation: + +```html +Hello {{ first_name }},
+Welcome to {{ company_name }}!
+``` + +### Seeding Templates + +Run the seed script to populate default templates: + +```bash +python scripts/seed_email_templates.py +``` + +This creates templates for: + +- `signup_welcome` (en, fr, de, lb) + +### Available Variables + +For `signup_welcome`: + +| Variable | Description | +|----------|-------------| +| first_name | User's first name | +| company_name | Vendor company name | +| email | User's email address | +| vendor_code | Vendor code for dashboard URL | +| login_url | Direct link to dashboard | +| trial_days | Number of trial days | +| tier_name | Subscription tier name | + +## Provider Setup + +### SMTP + +Standard SMTP configuration: + +```env +EMAIL_PROVIDER=smtp +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_USE_TLS=true +``` + +### SendGrid + +1. Create account at [sendgrid.com](https://sendgrid.com) +2. Generate API key in Settings > API Keys +3. Configure: + +```env +EMAIL_PROVIDER=sendgrid +SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx +``` + +4. Install package: `pip install sendgrid` + +### Mailgun + +1. Create account at [mailgun.com](https://mailgun.com) +2. Add and verify your domain +3. Get API key from Domain Settings +4. Configure: + +```env +EMAIL_PROVIDER=mailgun +MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxx +MAILGUN_DOMAIN=mg.yourdomain.com +``` + +### Amazon SES + +1. Set up SES in AWS Console +2. Verify sender domain/email +3. Create IAM user with SES permissions +4. Configure: + +```env +EMAIL_PROVIDER=ses +AWS_ACCESS_KEY_ID=AKIAXXXXXXXXXXXXXXXX +AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +AWS_REGION=eu-west-1 +``` + +5. Install package: `pip install boto3` + +## Email Logging + +All emails are logged to the `email_logs` table. Query examples: + +```python +# Get failed emails +failed = db.query(EmailLog).filter( + EmailLog.status == EmailStatus.FAILED.value +).all() + +# Get emails for a vendor +vendor_emails = db.query(EmailLog).filter( + EmailLog.vendor_id == vendor_id +).order_by(EmailLog.created_at.desc()).all() + +# Get recent signup emails +signups = db.query(EmailLog).filter( + EmailLog.template_code == "signup_welcome", + EmailLog.created_at >= datetime.now() - timedelta(days=7) +).all() +``` + +## Language Fallback + +The system automatically falls back to English if a template isn't available in the requested language: + +1. Request template for "de" (German) +2. If not found, try "en" (English) +3. If still not found, return None (log error) + +## Testing + +Run email service tests: + +```bash +pytest tests/unit/services/test_email_service.py -v +``` + +Test coverage includes: + +- Provider abstraction (Debug, SMTP, etc.) +- Template rendering with Jinja2 +- Language fallback behavior +- Email sending success/failure +- EmailLog model methods +- Template variable handling + +## Architecture + +``` +app/services/email_service.py # Email service with provider abstraction +models/database/email.py # EmailTemplate and EmailLog models +app/core/config.py # Email configuration settings +scripts/seed_email_templates.py # Template seeding script +``` + +### Provider Abstraction + +The system uses a strategy pattern for email providers: + +``` +EmailProvider (ABC) +├── SMTPProvider +├── SendGridProvider +├── MailgunProvider +├── SESProvider +└── DebugProvider +``` + +Each provider implements the `send()` method with the same signature, making it easy to switch providers via configuration. + +## Future Enhancements + +Planned improvements: + +1. **Email Queue**: Background task queue for high-volume sending +2. **Webhook Tracking**: Track deliveries, opens, clicks via provider webhooks +3. **Template Editor**: Admin UI for editing templates +4. **A/B Testing**: Test different email versions +5. **Scheduled Emails**: Send emails at specific times diff --git a/mkdocs.yml b/mkdocs.yml index 793494df..c6101f48 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -201,6 +201,7 @@ nav: - Platform Homepage: features/platform-homepage.md - Vendor Landing Pages: features/vendor-landing-pages.md - Subscription & Billing: features/subscription-billing.md + - Email System: features/email-system.md # --- User Guides --- - User Guides: diff --git a/models/database/__init__.py b/models/database/__init__.py index 52138194..d959a781 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -18,6 +18,7 @@ from .base import Base from .company import Company from .content_page import ContentPage from .customer import Customer, CustomerAddress +from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate from .inventory import Inventory from .invoice import ( Invoice, @@ -101,6 +102,11 @@ __all__ = [ # Customer "Customer", "CustomerAddress", + # Email + "EmailCategory", + "EmailLog", + "EmailStatus", + "EmailTemplate", # Product - Enums "ProductType", "DigitalDeliveryMethod", diff --git a/models/database/email.py b/models/database/email.py new file mode 100644 index 00000000..cfccfa8a --- /dev/null +++ b/models/database/email.py @@ -0,0 +1,192 @@ +# models/database/email.py +""" +Email system database models. + +Provides: +- EmailTemplate: Multi-language email templates stored in database +- EmailLog: Email sending history and tracking +""" + +import enum +from datetime import datetime + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + Integer, + String, + Text, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base + +from .base import TimestampMixin + + +class EmailCategory(str, enum.Enum): + """Email template categories.""" + + AUTH = "auth" # signup, password reset, verification + ORDERS = "orders" # order confirmations, shipping + BILLING = "billing" # invoices, payment failures + SYSTEM = "system" # team invites, notifications + MARKETING = "marketing" # newsletters, promotions + + +class EmailStatus(str, enum.Enum): + """Email sending status.""" + + PENDING = "pending" + SENT = "sent" + FAILED = "failed" + BOUNCED = "bounced" + DELIVERED = "delivered" + OPENED = "opened" + CLICKED = "clicked" + + +class EmailTemplate(Base, TimestampMixin): + """ + Multi-language email templates. + + Templates use Jinja2 syntax for variable interpolation. + Each template can have multiple language versions. + """ + + __tablename__ = "email_templates" + + id = Column(Integer, primary_key=True, index=True) + + # Template identification + code = Column(String(100), nullable=False, index=True) # e.g., "signup_welcome" + language = Column(String(5), nullable=False, default="en") # e.g., "en", "fr", "de", "lb" + + # Template metadata + name = Column(String(255), nullable=False) # Human-readable name + description = Column(Text, nullable=True) # Template purpose description + category = Column( + String(50), default=EmailCategory.SYSTEM.value, nullable=False, index=True + ) + + # Email content + subject = Column(String(500), nullable=False) # Subject line (supports variables) + body_html = Column(Text, nullable=False) # HTML body + body_text = Column(Text, nullable=True) # Plain text fallback + + # Template variables (JSON list of expected variables) + # e.g., ["first_name", "company_name", "login_url"] + variables = Column(Text, nullable=True) + + # Status + is_active = Column(Boolean, default=True, nullable=False) + + # Unique constraint: one template per code+language + __table_args__ = ( + {"sqlite_autoincrement": True}, + ) + + def __repr__(self): + return f"Hi {{ first_name }},
+ +Thank you for signing up for Wizamart! Your account for {{ company_name }} is now active.
+ +Vendor Code: {{ vendor_code }}
+Plan: {{ tier_name }}
+Trial Period: {{ trial_days }} days free
+You can start managing your orders, inventory, and invoices right away:
+ + + ++ If you have any questions, just reply to this email or visit our help center. +
+ +Best regards,
The Wizamart Team
© 2024 Wizamart. Built for Luxembourg e-commerce.
+Bonjour {{ first_name }},
+ +Merci de vous être inscrit sur Wizamart ! Votre compte pour {{ company_name }} est maintenant actif.
+ +Code vendeur : {{ vendor_code }}
+Forfait : {{ tier_name }}
+Période d'essai : {{ trial_days }} jours gratuits
+Vous pouvez commencer à gérer vos commandes, stocks et factures dès maintenant :
+ + + ++ Si vous avez des questions, répondez simplement à cet email. +
+ +Cordialement,
L'équipe Wizamart
© 2024 Wizamart. Conçu pour le e-commerce luxembourgeois.
+Hallo {{ first_name }},
+ +Vielen Dank für Ihre Anmeldung bei Wizamart! Ihr Konto für {{ company_name }} ist jetzt aktiv.
+ +Verkäufercode: {{ vendor_code }}
+Tarif: {{ tier_name }}
+Testzeitraum: {{ trial_days }} Tage kostenlos
+Sie können sofort mit der Verwaltung Ihrer Bestellungen, Bestände und Rechnungen beginnen:
+ + + ++ Bei Fragen antworten Sie einfach auf diese E-Mail. +
+ +Mit freundlichen Grüßen,
Das Wizamart-Team
© 2024 Wizamart. Entwickelt für den luxemburgischen E-Commerce.
+Moien {{ first_name }},
+ +Merci fir d'Umeldung op Wizamart! Äre Kont fir {{ company_name }} ass elo aktiv.
+ +Verkeefer Code: {{ vendor_code }}
+Plang: {{ tier_name }}
+Testperiod: {{ trial_days }} Deeg gratis
+Dir kënnt direkt ufänken Är Bestellungen, Lager a Rechnungen ze verwalten:
+ + + ++ Wann Dir Froen hutt, äntwert einfach op dës E-Mail. +
+ +Mat beschte Gréiss,
D'Wizamart Team
© 2024 Wizamart. Gemaach fir de lëtzebuergeschen E-Commerce.
+Test
", + category=EmailCategory.SYSTEM.value, + ) + db.add(template) + db.commit() + + service = EmailService(db) + + # Request German, should fallback to English + result = service.get_template("test_template", "de") + + assert result is not None + assert result.language == "en" + + # Cleanup + db.delete(template) + db.commit() + + def test_get_template_specific_language(self, db): + """Test get_template returns specific language if available.""" + # Create templates in both languages + template_en = EmailTemplate( + code="test_lang_template", + language="en", + name="Test Template EN", + subject="English Subject", + body_html="English
", + category=EmailCategory.SYSTEM.value, + ) + template_fr = EmailTemplate( + code="test_lang_template", + language="fr", + name="Test Template FR", + subject="French Subject", + body_html="Français
", + category=EmailCategory.SYSTEM.value, + ) + db.add(template_en) + db.add(template_fr) + db.commit() + + service = EmailService(db) + + # Request French + result = service.get_template("test_lang_template", "fr") + + assert result is not None + assert result.language == "fr" + assert result.subject == "French Subject" + + # Cleanup + db.delete(template_en) + db.delete(template_fr) + db.commit() + + +@pytest.mark.unit +@pytest.mark.email +class TestEmailSending: + """Test suite for email sending functionality.""" + + @patch("app.services.email_service.get_provider") + @patch("app.services.email_service.settings") + def test_send_raw_success(self, mock_settings, mock_get_provider, db): + """Test successful raw email sending.""" + # Setup mocks + mock_settings.email_enabled = True + mock_settings.email_from_address = "noreply@test.com" + mock_settings.email_from_name = "Test" + mock_settings.email_reply_to = "" + mock_settings.email_provider = "smtp" + + mock_provider = MagicMock() + mock_provider.send.return_value = (True, "msg-123", None) + mock_get_provider.return_value = mock_provider + + service = EmailService(db) + + log = service.send_raw( + to_email="user@example.com", + to_name="User", + subject="Test Subject", + body_html="Welcome {{ first_name }} to {{ company }}
", + body_text="Welcome {{ first_name }} to {{ company }}", + category=EmailCategory.SYSTEM.value, + ) + db.add(template) + db.commit() + + # Setup mocks + mock_settings.email_enabled = True + mock_settings.email_from_address = "noreply@test.com" + mock_settings.email_from_name = "Test" + mock_settings.email_reply_to = "" + mock_settings.email_provider = "smtp" + + mock_provider = MagicMock() + mock_provider.send.return_value = (True, "msg-456", None) + mock_get_provider.return_value = mock_provider + + service = EmailService(db) + + log = service.send_template( + template_code="test_send_template", + to_email="user@example.com", + language="en", + variables={ + "first_name": "John", + "company": "ACME Corp" + }, + ) + + assert log.status == EmailStatus.SENT.value + assert log.template_code == "test_send_template" + assert log.subject == "Hello John" + + # Cleanup + db.delete(template) + db.commit() + + def test_send_template_not_found(self, db): + """Test sending with non-existent template.""" + service = EmailService(db) + + log = service.send_template( + template_code="nonexistent_template", + to_email="user@example.com", + ) + + assert log.status == EmailStatus.FAILED.value + assert "not found" in log.error_message.lower() + + +@pytest.mark.unit +@pytest.mark.email +class TestEmailLog: + """Test suite for EmailLog model methods.""" + + def test_mark_sent(self, db): + """Test EmailLog.mark_sent method.""" + log = EmailLog( + recipient_email="test@example.com", + subject="Test", + from_email="noreply@test.com", + status=EmailStatus.PENDING.value, + ) + db.add(log) + db.flush() + + log.mark_sent("provider-msg-id") + + assert log.status == EmailStatus.SENT.value + assert log.sent_at is not None + assert log.provider_message_id == "provider-msg-id" + + db.rollback() + + def test_mark_failed(self, db): + """Test EmailLog.mark_failed method.""" + log = EmailLog( + recipient_email="test@example.com", + subject="Test", + from_email="noreply@test.com", + status=EmailStatus.PENDING.value, + retry_count=0, + ) + db.add(log) + db.flush() + + log.mark_failed("Connection timeout") + + assert log.status == EmailStatus.FAILED.value + assert log.error_message == "Connection timeout" + assert log.retry_count == 1 + + db.rollback() + + def test_mark_delivered(self, db): + """Test EmailLog.mark_delivered method.""" + log = EmailLog( + recipient_email="test@example.com", + subject="Test", + from_email="noreply@test.com", + status=EmailStatus.SENT.value, + ) + db.add(log) + db.flush() + + log.mark_delivered() + + assert log.status == EmailStatus.DELIVERED.value + assert log.delivered_at is not None + + db.rollback() + + def test_mark_opened(self, db): + """Test EmailLog.mark_opened method.""" + log = EmailLog( + recipient_email="test@example.com", + subject="Test", + from_email="noreply@test.com", + status=EmailStatus.DELIVERED.value, + ) + db.add(log) + db.flush() + + log.mark_opened() + + assert log.status == EmailStatus.OPENED.value + assert log.opened_at is not None + + db.rollback() + + +@pytest.mark.unit +@pytest.mark.email +class TestEmailTemplate: + """Test suite for EmailTemplate model.""" + + def test_variables_list_property(self, db): + """Test EmailTemplate.variables_list property.""" + template = EmailTemplate( + code="test_vars", + language="en", + name="Test", + subject="Test", + body_html="Test
", + category=EmailCategory.SYSTEM.value, + variables=json.dumps(["first_name", "last_name", "email"]), + ) + db.add(template) + db.flush() + + assert template.variables_list == ["first_name", "last_name", "email"] + + db.rollback() + + def test_variables_list_empty(self, db): + """Test EmailTemplate.variables_list with no variables.""" + template = EmailTemplate( + code="test_no_vars", + language="en", + name="Test", + subject="Test", + body_html="Test
", + category=EmailCategory.SYSTEM.value, + variables=None, + ) + db.add(template) + db.flush() + + assert template.variables_list == [] + + db.rollback() + + def test_variables_list_invalid_json(self, db): + """Test EmailTemplate.variables_list with invalid JSON.""" + template = EmailTemplate( + code="test_invalid_json", + language="en", + name="Test", + subject="Test", + body_html="Test
", + category=EmailCategory.SYSTEM.value, + variables="not valid json", + ) + db.add(template) + db.flush() + + assert template.variables_list == [] + + db.rollback() + + def test_template_repr(self, db): + """Test EmailTemplate string representation.""" + template = EmailTemplate( + code="signup_welcome", + language="en", + name="Welcome", + subject="Welcome", + body_html="Welcome
", + category=EmailCategory.AUTH.value, + ) + + assert "signup_welcome" in repr(template) + assert "en" in repr(template) + + +@pytest.mark.unit +@pytest.mark.email +class TestSignupWelcomeEmail: + """Test suite for signup welcome email integration.""" + + @pytest.fixture + def welcome_template(self, db): + """Create a welcome template for testing.""" + import json + + template = EmailTemplate( + code="signup_welcome", + language="en", + name="Signup Welcome", + subject="Welcome {{ first_name }}!", + body_html="Welcome {{ first_name }} to {{ company_name }}
", + body_text="Welcome {{ first_name }} to {{ company_name }}", + category=EmailCategory.AUTH.value, + variables=json.dumps([ + "first_name", "company_name", "email", "vendor_code", + "login_url", "trial_days", "tier_name" + ]), + ) + db.add(template) + db.commit() + + yield template + + # Cleanup + db.delete(template) + db.commit() + + def test_welcome_template_rendering(self, db, welcome_template): + """Test that welcome template renders correctly.""" + service = EmailService(db) + + template = service.get_template("signup_welcome", "en") + assert template is not None + assert template.code == "signup_welcome" + + # Test rendering + rendered = service.render_template( + template.subject, + {"first_name": "John"} + ) + assert rendered == "Welcome John!" + + def test_welcome_template_has_required_variables(self, db, welcome_template): + """Test welcome template has all required variables.""" + template = ( + db.query(EmailTemplate) + .filter( + EmailTemplate.code == "signup_welcome", + EmailTemplate.language == "en", + ) + .first() + ) + + assert template is not None + + required_vars = [ + "first_name", + "company_name", + "vendor_code", + "login_url", + "trial_days", + "tier_name", + ] + + for var in required_vars: + assert var in template.variables_list, f"Missing variable: {var}" + + @patch("app.services.email_service.get_provider") + @patch("app.services.email_service.settings") + def test_welcome_email_send(self, mock_settings, mock_get_provider, db, welcome_template): + """Test sending welcome email.""" + # Setup mocks + mock_settings.email_enabled = True + mock_settings.email_from_address = "noreply@test.com" + mock_settings.email_from_name = "Test" + mock_settings.email_reply_to = "" + mock_settings.email_provider = "smtp" + + mock_provider = MagicMock() + mock_provider.send.return_value = (True, "welcome-msg-123", None) + mock_get_provider.return_value = mock_provider + + service = EmailService(db) + + log = service.send_template( + template_code="signup_welcome", + to_email="newuser@example.com", + to_name="John Doe", + language="en", + variables={ + "first_name": "John", + "company_name": "ACME Corp", + "email": "newuser@example.com", + "vendor_code": "ACME", + "login_url": "https://wizamart.com/vendor/ACME/dashboard", + "trial_days": 30, + "tier_name": "Essential", + }, + vendor_id=1, + user_id=1, + related_type="signup", + ) + + assert log.status == EmailStatus.SENT.value + assert log.template_code == "signup_welcome" + assert log.subject == "Welcome John!" + assert log.recipient_email == "newuser@example.com"