diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..72b17fb --- /dev/null +++ b/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app:create_app +FLASK_ENV=development \ No newline at end of file diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 58a7b1a..0000000 --- a/alembic.ini +++ /dev/null @@ -1,141 +0,0 @@ -# A generic, single database configuration. - -[alembic] -script_location = alembic -# path to migration scripts. -# this is typically a path given in POSIX (e.g. forward slashes) -# format, relative to the token %(here)s which refers to the location of this -# ini file - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. for multiple paths, the path separator -# is defined by "path_separator" below. -prepend_sys_path = . - - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. -# Any required deps can installed by adding `alembic[tz]` to the pip requirements -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to /versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "path_separator" -# below. -# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions - -# path_separator; This indicates what character is used to split lists of file -# paths, including version_locations and prepend_sys_path within configparser -# files such as alembic.ini. -# The default rendered in new alembic.ini files is "os", which uses os.pathsep -# to provide os-dependent path splitting. -# -# Note that in order to support legacy alembic.ini files, this default does NOT -# take place if path_separator is not present in alembic.ini. If this -# option is omitted entirely, fallback logic is as follows: -# -# 1. Parsing of the version_locations option falls back to using the legacy -# "version_path_separator" key, which if absent then falls back to the legacy -# behavior of splitting on spaces and/or commas. -# 2. Parsing of the prepend_sys_path option falls back to the legacy -# behavior of splitting on spaces, commas, or colons. -# -# Valid values for path_separator are: -# -# path_separator = : -# path_separator = ; -# path_separator = space -# path_separator = newline -# -# Use os.pathsep. Default configuration used for new projects. -path_separator = os - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -# database URL. This is consumed by the user-maintained env.py script only. -# other means of configuring database URLs may be customized within the env.py -# file. -sqlalchemy.url = sqlite:///email_server/server_data/smtp_server.db - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Logging configuration. This is also consumed by the user-maintained -# env.py script only. -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARNING -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARNING -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README deleted file mode 100644 index 98e4f9c..0000000 --- a/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index 6905c2d..0000000 --- a/alembic/env.py +++ /dev/null @@ -1,79 +0,0 @@ -from logging.config import fileConfig - -from sqlalchemy import engine_from_config -from sqlalchemy import pool - -from alembic import context -from email_server.models import Base - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -target_metadata = Base.metadata - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index 480b130..0000000 --- a/alembic/script.py.mako +++ /dev/null @@ -1,28 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/53036910f343_initial_migration.py b/alembic/versions/53036910f343_initial_migration.py deleted file mode 100644 index 15bcec5..0000000 --- a/alembic/versions/53036910f343_initial_migration.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Initial migration - -Revision ID: 53036910f343 -Revises: -Create Date: 2025-06-01 11:14:39.589608 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '53036910f343' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - # The following columns may already exist, so we skip adding them if present. - # op.add_column('esrv_auth_logs', sa.Column('auth_type', sa.String(), nullable=False, server_default='user')) - # op.add_column('esrv_auth_logs', sa.Column('identifier', sa.String(), nullable=False, server_default='unknown')) - # op.add_column('esrv_auth_logs', sa.Column('ip_address', sa.String(), nullable=True)) - # op.add_column('esrv_auth_logs', sa.Column('created_at', sa.DateTime(), nullable=True)) - # SQLite does not support ALTER COLUMN, so we skip changing 'message' type/nullability here. - # If you need to change the type/nullability, do a manual migration as described in Alembic docs. - # op.drop_column('esrv_auth_logs', 'peer') - # op.drop_column('esrv_auth_logs', 'timestamp') - # op.drop_column('esrv_auth_logs', 'username') - # op.alter_column('esrv_custom_headers', 'created_at', - # existing_type=sa.TIMESTAMP(), - # type_=sa.DateTime(), - # existing_nullable=True, - # existing_server_default=sa.text("'2025-05-31 00:00:00'")) - op.drop_index(op.f('idx_esrv_custom_headers_domain'), table_name='esrv_custom_headers') - op.drop_index(op.f('idx_esrv_dkim_keys_domain'), table_name='esrv_dkim_keys') - # op.alter_column('esrv_domains', 'created_at', - # existing_type=sa.TIMESTAMP(), - # type_=sa.DateTime(), - # existing_nullable=True, - # existing_server_default=sa.text("'2025-05-31 00:00:00'")) - op.drop_column('esrv_domains', 'requires_auth') - op.add_column('esrv_email_logs', sa.Column('from_address', sa.String(), nullable=False, server_default='unknown')) - op.add_column('esrv_email_logs', sa.Column('to_address', sa.String(), nullable=False, server_default='unknown')) - op.add_column('esrv_email_logs', sa.Column('subject', sa.Text(), nullable=True)) - op.add_column('esrv_email_logs', sa.Column('message', sa.Text(), nullable=True)) - op.add_column('esrv_email_logs', sa.Column('created_at', sa.DateTime(), nullable=True)) - op.drop_index(op.f('idx_esrv_email_logs_mail_from'), table_name='esrv_email_logs') - op.drop_index(op.f('idx_esrv_email_logs_timestamp'), table_name='esrv_email_logs') - # op.drop_column('esrv_email_logs', 'rcpt_tos') - # op.drop_column('esrv_email_logs', 'content') - # op.drop_column('esrv_email_logs', 'dkim_signed') - # op.drop_column('esrv_email_logs', 'peer') - # op.drop_column('esrv_email_logs', 'message_id') - # op.drop_column('esrv_email_logs', 'mail_from') - # op.drop_column('esrv_email_logs', 'timestamp') - # op.alter_column('esrv_users', 'created_at', - # existing_type=sa.TIMESTAMP(), - # type_=sa.DateTime(), - # existing_nullable=True, - # existing_server_default=sa.text("'2025-05-31 00:00:00'")) - op.drop_index(op.f('idx_esrv_users_domain'), table_name='esrv_users') - op.drop_index(op.f('idx_esrv_users_email'), table_name='esrv_users') - # op.alter_column('esrv_whitelisted_ips', 'created_at', - # existing_type=sa.TIMESTAMP(), - # type_=sa.DateTime(), - # existing_nullable=True, - # existing_server_default=sa.text("'2025-05-31 00:00:00'")) - op.drop_index(op.f('idx_esrv_whitelisted_ips_domain'), table_name='esrv_whitelisted_ips') - op.drop_index(op.f('idx_esrv_whitelisted_ips_ip'), table_name='esrv_whitelisted_ips') - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_index(op.f('idx_esrv_whitelisted_ips_ip'), 'esrv_whitelisted_ips', ['ip_address'], unique=False) - op.create_index(op.f('idx_esrv_whitelisted_ips_domain'), 'esrv_whitelisted_ips', ['domain_id'], unique=False) - op.alter_column('esrv_whitelisted_ips', 'created_at', - existing_type=sa.DateTime(), - type_=sa.TIMESTAMP(), - existing_nullable=True, - existing_server_default=sa.text("'2025-05-31 00:00:00'")) - op.create_index(op.f('idx_esrv_users_email'), 'esrv_users', ['email'], unique=False) - op.create_index(op.f('idx_esrv_users_domain'), 'esrv_users', ['domain_id'], unique=False) - op.alter_column('esrv_users', 'created_at', - existing_type=sa.DateTime(), - type_=sa.TIMESTAMP(), - existing_nullable=True, - existing_server_default=sa.text("'2025-05-31 00:00:00'")) - op.add_column('esrv_email_logs', sa.Column('timestamp', sa.DATETIME(), nullable=False)) - op.add_column('esrv_email_logs', sa.Column('mail_from', sa.VARCHAR(), nullable=False)) - op.add_column('esrv_email_logs', sa.Column('message_id', sa.VARCHAR(), nullable=False)) - op.add_column('esrv_email_logs', sa.Column('peer', sa.VARCHAR(), nullable=False)) - op.add_column('esrv_email_logs', sa.Column('dkim_signed', sa.BOOLEAN(), nullable=True)) - op.add_column('esrv_email_logs', sa.Column('content', sa.TEXT(), nullable=False)) - op.add_column('esrv_email_logs', sa.Column('rcpt_tos', sa.VARCHAR(), nullable=False)) - op.create_index(op.f('idx_esrv_email_logs_timestamp'), 'esrv_email_logs', ['timestamp'], unique=False) - op.create_index(op.f('idx_esrv_email_logs_mail_from'), 'esrv_email_logs', ['mail_from'], unique=False) - op.drop_column('esrv_email_logs', 'created_at') - op.drop_column('esrv_email_logs', 'message') - op.drop_column('esrv_email_logs', 'subject') - op.drop_column('esrv_email_logs', 'to_address') - op.drop_column('esrv_email_logs', 'from_address') - op.add_column('esrv_domains', sa.Column('requires_auth', sa.BOOLEAN(), nullable=True)) - op.alter_column('esrv_domains', 'created_at', - existing_type=sa.DateTime(), - type_=sa.TIMESTAMP(), - existing_nullable=True, - existing_server_default=sa.text("'2025-05-31 00:00:00'")) - op.create_index(op.f('idx_esrv_dkim_keys_domain'), 'esrv_dkim_keys', ['domain_id'], unique=False) - op.create_index(op.f('idx_esrv_custom_headers_domain'), 'esrv_custom_headers', ['domain_id'], unique=False) - op.alter_column('esrv_custom_headers', 'created_at', - existing_type=sa.DateTime(), - type_=sa.TIMESTAMP(), - existing_nullable=True, - existing_server_default=sa.text("'2025-05-31 00:00:00'")) - op.add_column('esrv_auth_logs', sa.Column('username', sa.VARCHAR(), nullable=True)) - op.add_column('esrv_auth_logs', sa.Column('timestamp', sa.DATETIME(), nullable=False)) - op.add_column('esrv_auth_logs', sa.Column('peer', sa.VARCHAR(), nullable=False)) - op.alter_column('esrv_auth_logs', 'message', - existing_type=sa.Text(), - type_=sa.VARCHAR(), - nullable=False) - op.drop_column('esrv_auth_logs', 'created_at') - op.drop_column('esrv_auth_logs', 'ip_address') - op.drop_column('esrv_auth_logs', 'identifier') - op.drop_column('esrv_auth_logs', 'auth_type') - # ### end Alembic commands ### diff --git a/alembic/versions/7f200580bbd3_add_replaced_at_to_dkim_keys.py b/alembic/versions/7f200580bbd3_add_replaced_at_to_dkim_keys.py deleted file mode 100644 index e087511..0000000 --- a/alembic/versions/7f200580bbd3_add_replaced_at_to_dkim_keys.py +++ /dev/null @@ -1,28 +0,0 @@ -"""add_replaced_at_to_dkim_keys - -Revision ID: 7f200580bbd3 -Revises: d02f993649e8 -Create Date: 2025-06-07 12:48:07.930008 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '7f200580bbd3' -down_revision: Union[str, None] = 'd02f993649e8' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Add replaced_at field to DKIM keys table.""" - op.add_column('esrv_dkim_keys', sa.Column('replaced_at', sa.DateTime(), nullable=True)) - - -def downgrade() -> None: - """Remove replaced_at field from DKIM keys table.""" - op.drop_column('esrv_dkim_keys', 'replaced_at') diff --git a/alembic/versions/d02f993649e8_add_legacy_emaillog_columns_for_.py b/alembic/versions/d02f993649e8_add_legacy_emaillog_columns_for_.py deleted file mode 100644 index ed235a1..0000000 --- a/alembic/versions/d02f993649e8_add_legacy_emaillog_columns_for_.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Add legacy EmailLog columns for backward compatibility - -Revision ID: d02f993649e8 -Revises: 53036910f343 -Create Date: 2025-06-01 11:50:54.362830 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'd02f993649e8' -down_revision: Union[str, None] = '53036910f343' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # Add legacy columns for backward compatibility with existing EmailRelay code - op.add_column('esrv_email_logs', sa.Column('message_id', sa.String(), nullable=True)) - op.add_column('esrv_email_logs', sa.Column('timestamp', sa.DateTime(), nullable=True)) - op.add_column('esrv_email_logs', sa.Column('peer', sa.String(), nullable=True)) - op.add_column('esrv_email_logs', sa.Column('mail_from', sa.String(), nullable=True)) - op.add_column('esrv_email_logs', sa.Column('rcpt_tos', sa.String(), nullable=True)) - op.add_column('esrv_email_logs', sa.Column('content', sa.Text(), nullable=True)) - op.add_column('esrv_email_logs', sa.Column('dkim_signed', sa.Boolean(), nullable=True, default=False)) - - -def downgrade() -> None: - """Downgrade schema.""" - # Remove the legacy columns - op.drop_column('esrv_email_logs', 'dkim_signed') - op.drop_column('esrv_email_logs', 'content') - op.drop_column('esrv_email_logs', 'rcpt_tos') - op.drop_column('esrv_email_logs', 'mail_from') - op.drop_column('esrv_email_logs', 'peer') - op.drop_column('esrv_email_logs', 'timestamp') - op.drop_column('esrv_email_logs', 'message_id') diff --git a/app.py b/app.py index d8c448d..763b2a1 100644 --- a/app.py +++ b/app.py @@ -22,6 +22,7 @@ import argparse from datetime import datetime from flask import Flask, render_template, redirect, url_for, jsonify from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate # Add the project root to Python path sys.path.append(os.path.dirname(os.path.abspath(__file__))) @@ -66,6 +67,24 @@ class SMTPServerApp: self.smtp_task.cancel() logger.info("SMTP server stopped") + def _get_absolute_database_url(self): + """Convert relative database URL to absolute path for Flask-SQLAlchemy""" + db_url = self.settings['Database']['database_url'] + + # If it's already absolute or not a SQLite file path, return as-is + if not db_url.startswith('sqlite:///') or db_url.startswith('sqlite:////'): + return db_url + + # Convert relative SQLite path to absolute + # Remove 'sqlite:///' prefix + relative_path = db_url[10:] # len('sqlite:///') = 10 + + # Get absolute path relative to project root + project_root = os.path.dirname(os.path.abspath(__file__)) + absolute_path = os.path.join(project_root, relative_path) + + return f'sqlite:///{absolute_path}' + def create_flask_app(self): """Create and configure the Flask application""" app = Flask(__name__, @@ -75,7 +94,8 @@ class SMTPServerApp: # Flask configuration app.config.update({ 'SECRET_KEY': self.settings.get('Flask', 'secret_key', fallback='change-this-secret-key-in-production'), - 'SQLALCHEMY_DATABASE_URI': f"sqlite:///{self.settings.get('Database', 'database_path', fallback='email_server/server_data/smtp_server.db')}", + # Convert relative database path to absolute path + 'SQLALCHEMY_DATABASE_URI': self._get_absolute_database_url(), 'SQLALCHEMY_TRACK_MODIFICATIONS': False, 'TEMPLATES_AUTO_RELOAD': True, 'SEND_FILE_MAX_AGE_DEFAULT': 0 # Disable caching for development @@ -84,6 +104,13 @@ class SMTPServerApp: # Initialize database db = SQLAlchemy(app) + # Import existing models and register them with Flask-SQLAlchemy + from email_server.models import Base, Domain, User, WhitelistedIP, DKIMKey, EmailLog, AuthLog, CustomHeader + # Set the metadata for Flask-Migrate to use existing models + db.Model.metadata = Base.metadata + + migrate = Migrate(app, db, directory='migrations') + # Create database tables if they don't exist with app.app_context(): create_tables() @@ -423,5 +450,16 @@ Press Ctrl+C to stop the server sys.exit(1) +# For Flask CLI: expose a create_app() factory at module level +smtp_server_app_instance = SMTPServerApp() + +def create_app(): + """Flask application factory for CLI and Flask-Migrate support. + + Returns: + Flask: The Flask application instance. + """ + return smtp_server_app_instance.create_flask_app() + if __name__ == '__main__': main() diff --git a/email_server/models.py b/email_server/models.py index ae53d6d..00b1bdf 100644 --- a/email_server/models.py +++ b/email_server/models.py @@ -16,7 +16,8 @@ from email_server.settings_loader import load_settings from email_server.tool_box import ensure_folder_exists, get_logger settings = load_settings() -DATABASE_URL = settings['Database']['DATABASE_URL'] +# ConfigParser keys are case-insensitive, so we can use either case +DATABASE_URL = settings['Database']['database_url'] ensure_folder_exists(DATABASE_URL) diff --git a/main.py b/main.py_OLD similarity index 100% rename from main.py rename to main.py_OLD diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/3ce273a1be20_initial_migration.py b/migrations/versions/3ce273a1be20_initial_migration.py new file mode 100644 index 0000000..6ef2a27 --- /dev/null +++ b/migrations/versions/3ce273a1be20_initial_migration.py @@ -0,0 +1,108 @@ +"""Initial migration + +Revision ID: 3ce273a1be20 +Revises: +Create Date: 2025-06-07 15:25:35.603295 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3ce273a1be20' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('esrv_dkim_keys') + op.drop_table('esrv_auth_logs') + op.drop_table('esrv_users') + op.drop_table('esrv_domains') + op.drop_table('esrv_whitelisted_ips') + op.drop_table('esrv_email_logs') + op.drop_table('esrv_custom_headers') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('esrv_custom_headers', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('domain_id', sa.INTEGER(), nullable=False), + sa.Column('header_name', sa.VARCHAR(), nullable=False), + sa.Column('header_value', sa.VARCHAR(), nullable=False), + sa.Column('is_active', sa.BOOLEAN(), nullable=True), + sa.Column('created_at', sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('esrv_email_logs', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('message_id', sa.VARCHAR(), nullable=False), + sa.Column('timestamp', sa.DATETIME(), nullable=False), + sa.Column('peer', sa.VARCHAR(), nullable=False), + sa.Column('mail_from', sa.VARCHAR(), nullable=False), + sa.Column('rcpt_tos', sa.VARCHAR(), nullable=False), + sa.Column('content', sa.TEXT(), nullable=False), + sa.Column('status', sa.VARCHAR(), nullable=False), + sa.Column('dkim_signed', sa.BOOLEAN(), nullable=True), + sa.Column('from_address', sa.VARCHAR(), server_default=sa.text("'unknown'"), nullable=False), + sa.Column('to_address', sa.VARCHAR(), server_default=sa.text("'unknown'"), nullable=False), + sa.Column('subject', sa.TEXT(), nullable=True), + sa.Column('message', sa.TEXT(), nullable=True), + sa.Column('created_at', sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('message_id') + ) + op.create_table('esrv_whitelisted_ips', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('ip_address', sa.VARCHAR(), nullable=False), + sa.Column('domain_id', sa.INTEGER(), nullable=False), + sa.Column('is_active', sa.BOOLEAN(), nullable=True), + sa.Column('created_at', sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('esrv_domains', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('domain_name', sa.VARCHAR(), nullable=False), + sa.Column('is_active', sa.BOOLEAN(), nullable=True), + sa.Column('created_at', sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('domain_name') + ) + op.create_table('esrv_users', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('email', sa.VARCHAR(), nullable=False), + sa.Column('password_hash', sa.VARCHAR(), nullable=False), + sa.Column('domain_id', sa.INTEGER(), nullable=False), + sa.Column('can_send_as_domain', sa.BOOLEAN(), nullable=True), + sa.Column('is_active', sa.BOOLEAN(), nullable=True), + sa.Column('created_at', sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('esrv_auth_logs', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('auth_type', sa.VARCHAR(), nullable=False), + sa.Column('identifier', sa.VARCHAR(), nullable=False), + sa.Column('ip_address', sa.VARCHAR(), nullable=True), + sa.Column('success', sa.BOOLEAN(), nullable=False), + sa.Column('message', sa.TEXT(), nullable=True), + sa.Column('created_at', sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('esrv_dkim_keys', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('domain_id', sa.INTEGER(), nullable=False), + sa.Column('selector', sa.VARCHAR(), nullable=False), + sa.Column('private_key', sa.TEXT(), nullable=False), + sa.Column('public_key', sa.TEXT(), nullable=False), + sa.Column('is_active', sa.BOOLEAN(), nullable=True), + sa.Column('created_at', sa.DATETIME(), nullable=True), + sa.Column('replaced_at', sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 250c942..91cbe38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ Flask-SQLAlchemy Jinja2 Werkzeug requests +Flask-Migrate>=4.0.0 # Additional utilities -alembic \ No newline at end of file +python-dotenv \ No newline at end of file