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:
@@ -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 ###
|
||||
Reference in New Issue
Block a user