No Global IP whitelist - fixing failed auth response hang, fixing sender authentication.
This commit is contained in:
@@ -32,7 +32,7 @@ class EnhancedAuthenticator:
|
|||||||
def __call__(self, server, session, envelope, mechanism, auth_data):
|
def __call__(self, server, session, envelope, mechanism, auth_data):
|
||||||
if not isinstance(auth_data, LoginPassword):
|
if not isinstance(auth_data, LoginPassword):
|
||||||
logger.warning(f'Invalid auth data format: {type(auth_data)}')
|
logger.warning(f'Invalid auth data format: {type(auth_data)}')
|
||||||
return AuthResult(success=False, handled=True, message='535 Authentication failed')
|
return AuthResult(success=False, handled=False, message='535 Authentication failed')
|
||||||
|
|
||||||
# Decode bytes to string if necessary
|
# Decode bytes to string if necessary
|
||||||
username = auth_data.login
|
username = auth_data.login
|
||||||
@@ -73,7 +73,7 @@ class EnhancedAuthenticator:
|
|||||||
message=f'Invalid credentials for {username}'
|
message=f'Invalid credentials for {username}'
|
||||||
)
|
)
|
||||||
logger.warning(f'Authentication failed for {username}: invalid credentials')
|
logger.warning(f'Authentication failed for {username}: invalid credentials')
|
||||||
return AuthResult(success=False, handled=True, message='535 Authentication failed')
|
return AuthResult(success=False, handled=False, message='535 Authentication failed')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Authentication error for {username}: {e}')
|
logger.error(f'Authentication error for {username}: {e}')
|
||||||
@@ -84,7 +84,7 @@ class EnhancedAuthenticator:
|
|||||||
success=False,
|
success=False,
|
||||||
message=f'Authentication error: {str(e)}'
|
message=f'Authentication error: {str(e)}'
|
||||||
)
|
)
|
||||||
return AuthResult(success=False, handled=True, message='451 Internal server error')
|
return AuthResult(success=False, handled=False, message='451 Internal server error')
|
||||||
|
|
||||||
class EnhancedIPAuthenticator:
|
class EnhancedIPAuthenticator:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ async def start_server(shutdown_event=None):
|
|||||||
)
|
)
|
||||||
controller_tls.start()
|
controller_tls.start()
|
||||||
logger.debug(f' - Plain SMTP (IP whitelist): {BIND_IP}:{SMTP_PORT}')
|
logger.debug(f' - Plain SMTP (IP whitelist): {BIND_IP}:{SMTP_PORT}')
|
||||||
logger.debug(f' - STARTTLS SMTP (auth required): {BIND_IP}:{SMTP_TLS_PORT}')
|
logger.debug(f' - Direct TLS SMTP (SMTPS, auth required): {BIND_IP}:{SMTP_TLS_PORT}')
|
||||||
logger.debug('Management available via web interface at: http://localhost:5000/email')
|
logger.debug('Management available via web interface at: http://localhost:5000/email')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ DEFAULTS = {
|
|||||||
'; Server configuration for SMTP ports and hostname': None,
|
'; Server configuration for SMTP ports and hostname': None,
|
||||||
'; Plain SMTP port for internal/whitelisted IPs': None,
|
'; Plain SMTP port for internal/whitelisted IPs': None,
|
||||||
'SMTP_PORT': '4025',
|
'SMTP_PORT': '4025',
|
||||||
'; STARTTLS SMTP port for authenticated users': None,
|
'; TLS SMTP port for authenticated users': None,
|
||||||
'SMTP_TLS_PORT': '40587',
|
'SMTP_TLS_PORT': '40465',
|
||||||
'; Server hostname for HELO/EHLO identification': None,
|
'; Server hostname for HELO/EHLO identification': None,
|
||||||
'HOSTNAME': 'mail.example.com',
|
'HOSTNAME': 'mail.example.com',
|
||||||
'; Override HELO hostname': None,
|
'; Override HELO hostname': None,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from email_server.tool_box import get_logger
|
|||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
class CustomSMTP(AIOSMTP):
|
class CustomSMTP(AIOSMTP):
|
||||||
"""Custom SMTP class with configurable banner."""
|
"""Custom SMTP class with configurable banner and secure AUTH handling."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Sets Custom SMTP banner from settings
|
# Sets Custom SMTP banner from settings
|
||||||
@@ -30,11 +30,31 @@ class CustomSMTP(AIOSMTP):
|
|||||||
if _banner_message == '""':
|
if _banner_message == '""':
|
||||||
_banner_message = ''
|
_banner_message = ''
|
||||||
self.custom_banner = _banner_message
|
self.custom_banner = _banner_message
|
||||||
|
|
||||||
|
# Store authenticator and auth_require_tls for later use
|
||||||
|
self._custom_authenticator = kwargs.get('authenticator', None)
|
||||||
|
self._custom_auth_require_tls = kwargs.get('auth_require_tls', False)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Override the __ident__ to use our custom banner
|
# Override the __ident__ to use our custom banner
|
||||||
self.__ident__ = self.custom_banner
|
self.__ident__ = self.custom_banner
|
||||||
|
|
||||||
|
def _get_auth_methods(self):
|
||||||
|
# Only advertise AUTH if authenticator is set and (not auth_require_tls or connection is secure)
|
||||||
|
if self._custom_authenticator and (not self._custom_auth_require_tls or self.session and self.session.ssl):
|
||||||
|
return super()._get_auth_methods()
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def smtp_AUTH(self, arg):
|
||||||
|
"""
|
||||||
|
Override AUTH command to close connection after failed authentication.
|
||||||
|
"""
|
||||||
|
result = await super().smtp_AUTH(arg)
|
||||||
|
# If authentication failed, close the connection immediately
|
||||||
|
if isinstance(result, AuthResult) and not result.success:
|
||||||
|
if hasattr(self, 'session') and hasattr(self.session, 'transport') and self.session.transport:
|
||||||
|
self.session.transport.close()
|
||||||
|
return result
|
||||||
|
|
||||||
class EnhancedCombinedAuthenticator:
|
class EnhancedCombinedAuthenticator:
|
||||||
"""
|
"""
|
||||||
Enhanced combined authenticator with sender validation support.
|
Enhanced combined authenticator with sender validation support.
|
||||||
@@ -353,7 +373,9 @@ class EnhancedCustomSMTPHandler:
|
|||||||
return '250 OK'
|
return '250 OK'
|
||||||
|
|
||||||
class TLSController(Controller):
|
class TLSController(Controller):
|
||||||
"""Custom controller with TLS support - modeled after the working original."""
|
"""
|
||||||
|
Custom controller for direct TLS (SMTPS, port 465) support.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, handler, ssl_context, hostname='localhost', port=40587):
|
def __init__(self, handler, ssl_context, hostname='localhost', port=40587):
|
||||||
logger.debug(f"TLSController __init__: ssl_context={ssl_context is not None}")
|
logger.debug(f"TLSController __init__: ssl_context={ssl_context is not None}")
|
||||||
@@ -365,10 +387,11 @@ class TLSController(Controller):
|
|||||||
logger.debug(f"TLSController factory: ssl_context={self._ssl_context is not None}")
|
logger.debug(f"TLSController factory: ssl_context={self._ssl_context is not None}")
|
||||||
logger.debug(f"TLSController factory: ssl_context object={self._ssl_context}")
|
logger.debug(f"TLSController factory: ssl_context object={self._ssl_context}")
|
||||||
logger.debug(f"TLSController factory: hostname={self.smtp_hostname}")
|
logger.debug(f"TLSController factory: hostname={self.smtp_hostname}")
|
||||||
|
# This is direct TLS (SMTPS, port 465 style)
|
||||||
smtp_instance = CustomSMTP(
|
smtp_instance = CustomSMTP(
|
||||||
self.handler,
|
self.handler,
|
||||||
tls_context=self._ssl_context,
|
tls_context=self._ssl_context,
|
||||||
require_starttls=False, # Don't require STARTTLS immediately, but make it available
|
require_starttls=False, # Direct TLS: do not advertise or require STARTTLS
|
||||||
auth_require_tls=True, # If auth is used, require TLS
|
auth_require_tls=True, # If auth is used, require TLS
|
||||||
authenticator=self.handler.combined_authenticator,
|
authenticator=self.handler.combined_authenticator,
|
||||||
decode_data=True,
|
decode_data=True,
|
||||||
@@ -378,17 +401,18 @@ class TLSController(Controller):
|
|||||||
return smtp_instance
|
return smtp_instance
|
||||||
|
|
||||||
class PlainController(Controller):
|
class PlainController(Controller):
|
||||||
"""Controller for plain SMTP with username/password and IP-based authentication."""
|
"""Controller for plain SMTP with authentication and IP whitelist fallback."""
|
||||||
|
|
||||||
def __init__(self, handler, hostname='localhost', port=4025):
|
def __init__(self, handler, hostname='localhost', port=4025):
|
||||||
self.smtp_hostname = hostname # Store for HELO identification
|
self.smtp_hostname = hostname # Store for HELO identification
|
||||||
super().__init__(handler, hostname='0.0.0.0', port=port) # Bind to all interfaces
|
super().__init__(handler, hostname='0.0.0.0', port=port) # Bind to all interfaces
|
||||||
|
|
||||||
def factory(self):
|
def factory(self):
|
||||||
|
# Pass authenticator and set auth_require_tls=False to enable AUTH on plain port
|
||||||
return CustomSMTP(
|
return CustomSMTP(
|
||||||
self.handler,
|
self.handler,
|
||||||
authenticator=self.handler.combined_authenticator,
|
authenticator=self.handler.combined_authenticator,
|
||||||
auth_require_tls=False, # Allow AUTH over plain text (not recommended for production)
|
auth_require_tls=False, # Allow AUTH on plain port
|
||||||
decode_data=True,
|
decode_data=True,
|
||||||
hostname=self.smtp_hostname # Use proper hostname for HELO
|
hostname=self.smtp_hostname # Use proper hostname for HELO
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Single-database configuration for Flask.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""${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"}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""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 ###
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# SMTP Server Authentication Order and Best Practices
|
||||||
|
|
||||||
|
## Summary of Fixes (June 2025)
|
||||||
|
|
||||||
|
This document describes the authentication logic and order for the SMTP server, as well as the recent fixes applied to ensure correct sender authentication and IP whitelisting behavior.
|
||||||
|
|
||||||
|
### What Was Fixed
|
||||||
|
- **Authentication Response:**
|
||||||
|
- The server now immediately responds with an SMTP error (e.g., `535 Authentication failed`) if the username or password is incorrect, instead of hanging the session. This is achieved by returning `AuthResult(success=False, handled=False, message='535 Authentication failed')` from the authenticator, allowing the aiosmtpd framework to send the error to the client.
|
||||||
|
- **No Forced Connection Close:**
|
||||||
|
- The server does not forcibly close the connection after failed authentication, but lets the SMTP client decide whether to retry or quit, as per SMTP protocol best practices.
|
||||||
|
- **AUTH on Both Ports:**
|
||||||
|
- Both the plain SMTP port (`smtp_port`) and the secure TLS port (`smtp_tls_port`) now advertise and allow authentication (AUTH LOGIN/PLAIN). IP whitelist fallback is also available on both ports.
|
||||||
|
|
||||||
|
## Authentication Order and Logic
|
||||||
|
|
||||||
|
1. **Connection Handling**
|
||||||
|
- If a client connects to the plain SMTP port, both AUTH and IP whitelisting are available.
|
||||||
|
- If a client connects to the TLS SMTP port, the connection is immediately secured with TLS. Both AUTH and IP whitelisting are available.
|
||||||
|
|
||||||
|
2. **Sender Authentication (Username/Password)**
|
||||||
|
- When a client issues the AUTH command (LOGIN or PLAIN) on either port:
|
||||||
|
- The server checks the username and password against the database.
|
||||||
|
- If valid, the session is marked as authenticated and the sender can send as their own address or, if permitted, as any address in their domain.
|
||||||
|
- If invalid, the server responds with `535 Authentication failed` and does not hang the session.
|
||||||
|
|
||||||
|
**Code Snippet for Immediate Authentication Failure Response:**
|
||||||
|
```python
|
||||||
|
# In email_server/auth.py
|
||||||
|
def __call__(self, server, session, envelope, mechanism, auth_data):
|
||||||
|
# ...existing code...
|
||||||
|
if not isinstance(auth_data, LoginPassword):
|
||||||
|
logger.warning(f'Invalid auth data format: {type(auth_data)}')
|
||||||
|
return AuthResult(success=False, handled=False, message='535 Authentication failed')
|
||||||
|
# ...existing code...
|
||||||
|
try:
|
||||||
|
sender = get_sender_by_email(username)
|
||||||
|
if sender and check_password(password, sender.password_hash):
|
||||||
|
# ...success logic...
|
||||||
|
return AuthResult(success=True, handled=True)
|
||||||
|
else:
|
||||||
|
# ...failure logging...
|
||||||
|
return AuthResult(success=False, handled=False, message='535 Authentication failed')
|
||||||
|
except Exception as e:
|
||||||
|
# ...error logging...
|
||||||
|
return AuthResult(success=False, handled=False, message='451 Internal server error')
|
||||||
|
```
|
||||||
|
- Returning `handled=False` ensures the SMTP client is immediately informed of the failure and does not hang.
|
||||||
|
|
||||||
|
3. **IP Whitelisting (Secondary/Fallback)**
|
||||||
|
- If no AUTH is provided, the server checks if the client's IP is whitelisted for the target domain.
|
||||||
|
- If the IP is whitelisted, the session is authorized to send for that domain.
|
||||||
|
- If not, the server rejects the mail transaction.
|
||||||
|
|
||||||
|
## Best Practices for Future Development
|
||||||
|
|
||||||
|
- **Always return `handled=False` in `AuthResult` for failed authentication** to ensure the SMTP client receives an error and the session does not hang.
|
||||||
|
- **Advertise AUTH on both the plain SMTP and TLS ports**; allow both user authentication and IP whitelist fallback.
|
||||||
|
- **Do not use or advertise STARTTLS** on any port if only direct TLS is desired.
|
||||||
|
- **Log all authentication attempts** (success and failure) for auditing and troubleshooting.
|
||||||
|
- **Keep authentication and IP whitelisting logic modular** for easy updates and security reviews.
|
||||||
|
|
||||||
|
## Example Client Setup
|
||||||
|
- For user authentication, connect to either the plain SMTP port (e.g., 25 or 4025) or the TLS port (e.g., 40587) and use the correct username and password.
|
||||||
|
- For IP whitelisting, connect from an authorized IP to either port; no authentication is required, but the sender must be allowed for the domain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**This document should be updated if the authentication logic or port usage changes in the future.**
|
||||||
+85
-44
@@ -1,69 +1,110 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
sender="test@example.com"
|
|
||||||
receiver="info@example.com"
|
receiver="info@example.com"
|
||||||
password="testpass123"
|
EMAIL_SERVER="localhost" #"pymta.example.com" "localhost"
|
||||||
|
EMAIL_SERVER_auth="10.100.111.1" # IP for authenticated server ( not localhost), use your main interface ip
|
||||||
|
|
||||||
|
sender="test@example.com"
|
||||||
|
username="test@example.com"
|
||||||
|
password="ZjDvcjPSs-nwK2Ghj5vQY7L4LdmTpmn_AEZMokJTFS" # password you setup for the user!
|
||||||
domain="example.com"
|
domain="example.com"
|
||||||
body_content_file="@tests/email_body.txt"
|
body_content_file="@tests/email_body.txt"
|
||||||
SMTP_PORT=4025
|
SMTP_PORT=4025
|
||||||
SMTP_TLS_PORT=40587
|
SMTP_TLS_PORT=40465
|
||||||
cc_recipient="targetcc@example.com"
|
cc_recipient="ccrecipient@example.com"
|
||||||
bcc_recipient="targetbcc@example.com"
|
bcc_recipient="bccrecipient@example.com"
|
||||||
|
|
||||||
<<com
|
<<com
|
||||||
# Setup domain and user via web interface first
|
python -m email_server.cli_tools add-domain $domain
|
||||||
# Visit http://localhost:5000/email to configure:
|
python -m email_server.cli_tools add-user $username $password $domain
|
||||||
# - Add domain: $domain
|
python -m email_server.cli_tools add-ip 127.0.0.1 $domain
|
||||||
# - Add user: $sender with password $password
|
python -m email_server.cli_tools add-ip 213.249.224.235 $domain
|
||||||
# - Add IP whitelist: 127.0.0.1 and 10.100.111.1
|
python -m email_server.cli_tools generate-dkim $domain
|
||||||
# - Generate DKIM key for domain
|
python -m email_server.cli_tools add-custom-header $domain X-Auth-Token "abc123-example-auth"
|
||||||
|
python -m email_server.cli_tools add-custom-header $domain X-Server-ID "mail01.example.com"
|
||||||
|
|
||||||
# options to add CC and BCC recipients for swaks
|
# options to add CC and BCC recipients for swaks
|
||||||
--cc $cc_recipient
|
--cc $cc_recipient
|
||||||
--bcc $bcc_recipient
|
--bcc $bcc_recipient
|
||||||
com
|
|
||||||
|
|
||||||
swaks --to $receiver \
|
swaks --to $receiver \
|
||||||
--from $sender \
|
--from $sender \
|
||||||
--server localhost \
|
--server $EMAIL_SERVER \
|
||||||
--port $SMTP_TLS_PORT \
|
--port $SMTP_TLS_PORT \
|
||||||
--auth LOGIN \
|
--auth LOGIN \
|
||||||
--auth-user $sender \
|
--auth-user $username \
|
||||||
--auth-password $password \
|
--auth-password $password \
|
||||||
--tls \
|
--tls \
|
||||||
--header "Subject: TLS - Large body email" \
|
--header "Subject: TLS - Large body email" \
|
||||||
--body $body_content_file \
|
--body $body_content_file
|
||||||
--attach tests/email_body.txt \
|
# --attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/email_body.txt
|
||||||
--attach tests/Hello.jpg
|
# --attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/Hello.jpg
|
||||||
|
|
||||||
swaks --to $receiver \
|
|
||||||
--from $sender \
|
|
||||||
--server localhost \
|
|
||||||
--port $SMTP_PORT \
|
|
||||||
--auth LOGIN \
|
|
||||||
--auth-user $sender \
|
|
||||||
--auth-password $password \
|
|
||||||
--data "Subject: Test Email - authenticated\n\nThis is the message body."
|
|
||||||
|
|
||||||
swaks --to $receiver \
|
|
||||||
--from $sender \
|
|
||||||
--server localhost \
|
|
||||||
--port $SMTP_TLS_PORT \
|
|
||||||
--auth LOGIN \
|
|
||||||
--auth-user $sender \
|
|
||||||
--auth-password $password \
|
|
||||||
--tls \
|
|
||||||
--data "Subject: Test via STARTTLS - authenticated\n\nThis is the body."
|
|
||||||
|
|
||||||
swaks --to $receiver \
|
|
||||||
--from $sender \
|
|
||||||
--server localhost \
|
|
||||||
--port $SMTP_TLS_PORT \
|
|
||||||
--tls \
|
|
||||||
--data "Subject: Test via STARTTLS - no auth\n\nThis is the body."
|
|
||||||
com
|
com
|
||||||
|
<<com
|
||||||
|
|
||||||
|
com
|
||||||
swaks --to $receiver \
|
swaks --to $receiver \
|
||||||
--from $sender \
|
--from $sender \
|
||||||
--server localhost \
|
--server $EMAIL_SERVER \
|
||||||
--port $SMTP_PORT \
|
--port $SMTP_PORT \
|
||||||
--data "Subject: Test Email - no auth\n\nThis is the message body."
|
--auth LOGIN \
|
||||||
|
--auth-user $username \
|
||||||
|
--auth-password $password \
|
||||||
|
--data "Subject: SMTP - authenticated success\n\nThis is the message body."
|
||||||
|
|
||||||
|
# Test with Authentication TLS
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER \
|
||||||
|
--port $SMTP_TLS_PORT \
|
||||||
|
--auth LOGIN \
|
||||||
|
--auth-user $username \
|
||||||
|
--auth-password $password \
|
||||||
|
--tls \
|
||||||
|
--header "Subject: TLS - authenticated success" \
|
||||||
|
--body "This is the message body with proper headers."
|
||||||
|
|
||||||
|
# Test TLS + authentication and IP whitelist
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER_auth \
|
||||||
|
--port $SMTP_TLS_PORT \
|
||||||
|
--auth LOGIN \
|
||||||
|
--auth-user $username \
|
||||||
|
--auth-password $password \
|
||||||
|
--tls \
|
||||||
|
--data "Subject: TLS - auth + IP Whitelist \n\nTest TLS + authentication and IP whitelist"
|
||||||
|
|
||||||
|
|
||||||
|
# Test with IP authentication TLS
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER_auth \
|
||||||
|
--port $SMTP_TLS_PORT \
|
||||||
|
--tls \
|
||||||
|
--data "Subject: TLS - IP Whitelist - no auth\n\nTest with IP authentication TLS"
|
||||||
|
|
||||||
|
# Test with IP authentication SMTP
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER_auth \
|
||||||
|
--port $SMTP_PORT \
|
||||||
|
--data "Subject: SMTP - IP Whitelist - no auth\n\nTest with IP authentication SMTP"
|
||||||
|
|
||||||
|
|
||||||
|
<<com
|
||||||
|
com
|
||||||
|
# SMTP un-auth test "Email_server - no Whitelist - no auth"
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER \
|
||||||
|
--port $SMTP_PORT \
|
||||||
|
--data "Subject: SMTP - no Whitelist - no auth\n\nSMTP un-auth test Email_server - no Whitelist - no auth."
|
||||||
|
|
||||||
|
# Test TLS un-auth test "Email_server - no Whitelist - no auth"
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER \
|
||||||
|
--port $SMTP_TLS_PORT \
|
||||||
|
--tls \
|
||||||
|
--data "Subject: TLS - no Whitelist - no auth\n\nTest TLS un-auth test Email_server - no Whitelist - no auth"
|
||||||
Reference in New Issue
Block a user