feat: add email system with multi-provider support

Implements a comprehensive email system with:
- Multi-provider support (SMTP, SendGrid, Mailgun, Amazon SES)
- Database-stored templates with i18n (EN, FR, DE, LB)
- Jinja2 template rendering with variable interpolation
- Email logging for debugging and compliance
- Debug mode for development (logs instead of sending)
- Welcome email integration in signup flow

New files:
- models/database/email.py: EmailTemplate and EmailLog models
- app/services/email_service.py: Provider abstraction and service
- scripts/seed_email_templates.py: Template seeding script
- tests/unit/services/test_email_service.py: 28 unit tests
- docs/features/email-system.md: Complete documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 21:05:50 +01:00
parent 98d082699c
commit 64fd8b5194
11 changed files with 2540 additions and 0 deletions

View File

@@ -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 ###