1 Commits

8 changed files with 310 additions and 154 deletions

View File

@@ -29,10 +29,11 @@ class EnhancedAuthenticator:
- Comprehensive audit logging
"""
def __call__(self, server, session, envelope, mechanism, auth_data):
async def __call__(self, server, session, envelope, mechanism, auth_data):
if not isinstance(auth_data, LoginPassword):
logger.warning(f'Invalid auth data format: {type(auth_data)}')
return AuthResult(success=False, handled=True, message='535 Authentication failed')
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
# Decode bytes to string if necessary
username = auth_data.login
@@ -73,7 +74,8 @@ class EnhancedAuthenticator:
message=f'Invalid credentials for {username}'
)
logger.warning(f'Authentication failed for {username}: invalid credentials')
return AuthResult(success=False, handled=True, message='535 Authentication failed')
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
except Exception as e:
logger.error(f'Authentication error for {username}: {e}')
@@ -84,7 +86,8 @@ class EnhancedAuthenticator:
success=False,
message=f'Authentication error: {str(e)}'
)
return AuthResult(success=False, handled=True, message='451 Internal server error')
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
class EnhancedIPAuthenticator:
"""
@@ -98,7 +101,7 @@ class EnhancedIPAuthenticator:
def can_authenticate_for_domain(self, ip_address: str, domain_name: str) -> tuple[bool, str]:
"""
Check if IP can authenticate for a specific domain.
Check if IP can authenticate for a specific domain or is globally whitelisted.
Args:
ip_address: Client IP address
@@ -108,11 +111,25 @@ class EnhancedIPAuthenticator:
(success, message) tuple
"""
try:
# First, check for domain-specific whitelist
whitelisted_ip = get_whitelisted_ip(ip_address, domain_name)
if whitelisted_ip:
return True, f"IP {ip_address} authorized for domain {domain_name}"
else:
return False, f"IP {ip_address} not authorized for domain {domain_name}"
# Then, check for global whitelist
from email_server.models import Session, WhitelistedIP, get_domain_by_name
session = Session()
try:
global_ip = session.query(WhitelistedIP).filter_by(ip_address=ip_address, global_ip=True, is_active=True).first()
if global_ip:
# Check if the domain exists and is active
domain = get_domain_by_name(domain_name)
if domain:
return True, f"IP {ip_address} is globally whitelisted for existing domain {domain_name}"
else:
return False, f"Domain {domain_name} does not exist or is not active on this server"
finally:
session.close()
return False, f"IP {ip_address} not authorized for domain {domain_name} or globally"
except Exception as e:
logger.error(f"Error checking IP authorization: {e}")
return False, f"Error checking IP authorization: {str(e)}"

View File

@@ -88,16 +88,18 @@ class Sender(Base):
class WhitelistedIP(Base):
"""
IP whitelist model with domain-specific authentication.
IP whitelist model with domain-specific and global authentication.
Security feature:
- IPs can only send emails for their specific authorized domain
- IPs can be global (allowed for any domain) or domain-specific
- IPs can only send emails for their specific authorized domain unless global_ip is True
"""
__tablename__ = 'esrv_whitelisted_ips'
id = Column(Integer, primary_key=True)
ip_address = Column(String, nullable=False)
domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=False)
domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=True)
global_ip = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
@@ -107,13 +109,13 @@ class WhitelistedIP(Base):
Args:
domain_name: The domain name to check
Returns:
True if IP is authorized for this domain
True if IP is authorized for this domain or is global
"""
if not self.is_active:
return False
if self.global_ip:
return True
# Need to check against the actual domain
session = Session()
try:
@@ -126,7 +128,7 @@ class WhitelistedIP(Base):
session.close()
def __repr__(self):
return f"<WhitelistedIP(id={self.id}, ip='{self.ip_address}', domain_id={self.domain_id})>"
return f"<WhitelistedIP(id={self.id}, ip='{self.ip_address}', domain_id={self.domain_id}, global_ip={self.global_ip})>"
class EmailLog(Base):
"""Email log model for tracking sent emails."""

View File

@@ -22,52 +22,66 @@ def ips_list():
"""List all whitelisted IPs."""
session = Session()
try:
ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id).order_by(WhitelistedIP.ip_address).all()
return render_template('ips.html', ips=ips)
all_ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id, isouter=True).order_by(WhitelistedIP.ip_address).all()
domain_ips = [item for item in all_ips if not item[0].global_ip]
global_ips = [item for item in all_ips if item[0].global_ip]
return render_template('ips.html', ips=domain_ips, global_ips=global_ips)
finally:
session.close()
@email_bp.route('/ips/add', methods=['GET', 'POST'])
def add_ip():
"""Add new whitelisted IP."""
"""Add new whitelisted IP(s)."""
session = Session()
try:
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
if request.method == 'POST':
ip_address = request.form.get('ip_address', '').strip()
ip_addresses_raw = request.form.get('ip_addresses', '').strip()
domain_id = request.form.get('domain_id', type=int)
if not all([ip_address, domain_id]):
flash('All fields are required', 'error')
global_ip = request.form.get('global_ip') == 'on'
# Split IPs by line or comma
ip_list = []
for line in ip_addresses_raw.splitlines():
for ip in line.split(','):
ip = ip.strip()
if ip:
ip_list.append(ip)
if not ip_list:
flash('Please enter at least one IP address', 'error')
return redirect(url_for('email.add_ip'))
# Basic IP validation
try:
socket.inet_aton(ip_address)
except socket.error:
flash('Invalid IP address format', 'error')
if not global_ip and not domain_id:
flash('Please select a domain for non-global IPs', 'error')
return redirect(url_for('email.add_ip'))
# Check if IP already exists for this domain
existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, domain_id=domain_id).first()
if existing:
flash(f'IP {ip_address} already whitelisted for this domain', 'error')
return redirect(url_for('email.ips_list'))
# Create whitelisted IP
whitelist = WhitelistedIP(
ip_address=ip_address,
domain_id=domain_id
)
session.add(whitelist)
added = 0
for ip_address in ip_list:
# Basic IP validation
try:
socket.inet_aton(ip_address)
except socket.error:
flash(f'Invalid IP address format: {ip_address}', 'error')
continue
# Check if IP already exists for this domain/global
existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, global_ip=global_ip)
if not global_ip:
existing = existing.filter_by(domain_id=domain_id)
else:
existing = existing.filter_by(domain_id=None)
if existing.first():
flash(f'IP {ip_address} already whitelisted for this domain/global', 'error')
continue
# Create whitelisted IP
whitelist = WhitelistedIP(
ip_address=ip_address,
domain_id=None if global_ip else domain_id,
global_ip=global_ip
)
session.add(whitelist)
added += 1
session.commit()
flash(f'IP {ip_address} added to whitelist', 'success')
if added:
flash(f'{added} IP(s) added to whitelist', 'success')
return redirect(url_for('email.ips_list'))
return render_template('add_ip.html', domains=domains)
except Exception as e:
session.rollback()
logger.error(f"Error adding IP: {e}")
@@ -166,33 +180,35 @@ def edit_ip(ip_id: int):
if request.method == 'POST':
ip_address = request.form.get('ip_address', '').strip()
domain_id = request.form.get('domain_id', type=int)
global_ip = request.form.get('global_ip') == 'on'
if not all([ip_address, domain_id]):
flash('All fields are required', 'error')
if not ip_address:
flash('IP address is required', 'error')
return redirect(url_for('email.edit_ip', ip_id=ip_id))
# Basic IP validation
try:
socket.inet_aton(ip_address)
except socket.error:
flash('Invalid IP address format', 'error')
return redirect(url_for('email.edit_ip', ip_id=ip_id))
# Check if IP already exists for this domain (excluding current record)
# Check if IP already exists for this domain/global (excluding current record)
existing = session.query(WhitelistedIP).filter(
WhitelistedIP.ip_address == ip_address,
WhitelistedIP.domain_id == domain_id,
WhitelistedIP.global_ip == global_ip,
WhitelistedIP.id != ip_id
).first()
if existing:
flash(f'IP {ip_address} already whitelisted for this domain', 'error')
)
if not global_ip:
existing = existing.filter(WhitelistedIP.domain_id == domain_id)
else:
existing = existing.filter(WhitelistedIP.domain_id == None)
if existing.first():
flash(f'IP {ip_address} already whitelisted for this domain/global', 'error')
return redirect(url_for('email.edit_ip', ip_id=ip_id))
# Update IP record
ip_record.ip_address = ip_address
ip_record.domain_id = domain_id
ip_record.global_ip = global_ip
ip_record.domain_id = None if global_ip else domain_id
session.commit()
flash(f'IP whitelist record updated', 'success')
return redirect(url_for('email.ips_list'))

View File

@@ -40,30 +40,30 @@
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="ip_address" class="form-label">IP Address</label>
<input type="text"
class="form-control font-monospace"
id="ip_address"
name="ip_address"
required
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
placeholder="192.168.1.100"
value="{{ request.args.get('ip', '') }}">
<label for="ip_addresses" class="form-label">IP Addresses</label>
<textarea class="form-control font-monospace" id="ip_addresses" name="ip_addresses" rows="3" required placeholder="One IP per line, or separate with commas">{{ request.args.get('ip', '') }}</textarea>
<div class="form-text">
IPv4 address that will be allowed to send emails without authentication
Enter one or more IPv4 addresses (one per line or comma-separated).<br>
Each IP will be added as a separate whitelist entry.
</div>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="global_ip" name="global_ip">
<label class="form-check-label" for="global_ip">
Global IP (allow for any domain)
</label>
</div>
<div class="mb-4">
<label for="domain_id" class="form-label">Authorized Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required>
<select class="form-select" id="domain_id" name="domain_id">
<option value="">Select a domain...</option>
{% for domain in domains %}
<option value="{{ domain.id }}">{{ domain.domain_name }}</option>
{% endfor %}
</select>
<div class="form-text">
This IP will only be able to send emails for the selected domain
This IP will only be able to send emails for the selected domain (unless Global IP is checked)
</div>
</div>
@@ -109,25 +109,18 @@
const data = await response.json();
document.getElementById('current-ip').innerHTML =
`<span class="text-primary">${data.ip_addr}</span>`;
} catch (er) {
try {
const response = await fetch('https://httpbin.org/ip');
const data = await response.json();
document.getElementById('current-ip').innerHTML =
`<span class="text-primary">${data.origin}</span>`;
} catch (error) {
document.getElementById('current-ip').innerHTML =
} catch (error) {
document.getElementById('current-ip').innerHTML =
'<span class="text-muted">Unable to detect</span>';
}
}
}
function useCurrentIP() {
const currentIPElement = document.getElementById('current-ip');
const ip = currentIPElement.textContent.trim();
if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') {
document.getElementById('ip_address').value = ip;
document.getElementById('ip_addresses').value = ip;
// Focus on domain selection
document.getElementById('domain_id').focus();
} else {
@@ -135,19 +128,22 @@
}
}
// IP address validation
document.getElementById('ip_address').addEventListener('input', function(e) {
const ip = e.target.value;
const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ip && !ipPattern.test(ip)) {
e.target.setCustomValidity('Please enter a valid IPv4 address');
} else {
e.target.setCustomValidity('');
}
});
// Auto-detect IP on page load
detectCurrentIP();
// Enable/disable domain select based on global_ip checkbox
const globalIpCheckbox = document.getElementById('global_ip');
const domainSelect = document.getElementById('domain_id');
function toggleDomainSelect() {
if (globalIpCheckbox.checked) {
domainSelect.disabled = true;
domainSelect.required = false;
} else {
domainSelect.disabled = false;
domainSelect.required = true;
}
}
globalIpCheckbox.addEventListener('change', toggleDomainSelect);
toggleDomainSelect();
</script>
{% endblock %}

View File

@@ -21,16 +21,21 @@
id="ip_address"
name="ip_address"
value="{{ ip_record.ip_address }}"
placeholder="e.g., 192.168.1.1 or 192.168.1.0/24"
placeholder="e.g., 192.168.1.1"
required>
<div class="form-text">
Enter a single IP address or CIDR block
Enter a single IP address
</div>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="global_ip" name="global_ip" {% if ip_record.global_ip %}checked{% endif %}>
<label class="form-check-label" for="global_ip">
Global IP (allow for any domain)
</label>
</div>
<div class="mb-3">
<label for="domain_id" class="form-label">Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required>
<select class="form-select" id="domain_id" name="domain_id" {% if ip_record.global_ip %}disabled{% endif %} required>
<option value="">Select a domain</option>
{% for domain in domains %}
<option value="{{ domain.id }}"
@@ -40,7 +45,7 @@
{% endfor %}
</select>
<div class="form-text">
This IP will be able to send emails for the selected domain
This IP will be able to send emails for the selected domain (unless Global IP is checked)
</div>
</div>
@@ -158,6 +163,19 @@ document.addEventListener('DOMContentLoaded', function() {
const parts = ip.split('/')[0].split('.');
return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255);
}
// Enable/disable domain select based on global_ip checkbox
const globalIpCheckbox = document.getElementById('global_ip');
const domainSelect = document.getElementById('domain_id');
function toggleDomainSelect() {
if (globalIpCheckbox.checked) {
domainSelect.disabled = true;
} else {
domainSelect.disabled = false;
}
}
globalIpCheckbox.addEventListener('change', toggleDomainSelect);
toggleDomainSelect();
});
</script>
{% endblock %}

View File

@@ -21,7 +21,7 @@
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-list me-2"></i>
Whitelisted IP Addresses
Per Domain Whitelisted IP Addresses
</h5>
</div>
<div class="card-body">
@@ -44,7 +44,11 @@
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
</td>
<td>
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
{% if ip.global_ip %}
<span class="badge bg-info">Global</span>
{% else %}
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
{% endif %}
</td>
<td>
{% if ip.is_active %}
@@ -123,6 +127,97 @@
{% endif %}
</div>
</div>
{% if global_ips %}
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-list me-2"></i>
Global Whitelisted IP Addresses <span class="text-muted small">(can be used for all domains)</span>
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>IP Address</th>
<th>Status</th>
<th>Added</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for ip, domain in global_ips %}
<tr>
<td>
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
</td>
<td>
{% if ip.is_active %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>
Active
</span>
{% else %}
<span class="badge bg-danger">
<i class="bi bi-x-circle me-1"></i>
Inactive
</span>
{% endif %}
</td>
<td>
<small class="text-muted">
{{ ip.created_at.strftime('%Y-%m-%d %H:%M') }}
</small>
</td>
<td>
<div class="btn-group" role="group">
<!-- Edit Button -->
<a href="{{ url_for('email.edit_ip', ip_id=ip.id) }}"
class="btn btn-outline-primary btn-sm"
title="Edit IP">
<i class="bi bi-pencil"></i>
</a>
<!-- Enable/Disable Button -->
{% if ip.is_active %}
<form method="post" action="{{ url_for('email.disable_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-warning btn-sm"
title="Disable IP"
onclick="return confirm('Disable {{ ip.ip_address }}?')">
<i class="bi bi-pause-circle"></i>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('email.enable_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-success btn-sm"
title="Enable IP"
onclick="return confirm('Enable {{ ip.ip_address }}?')">
<i class="bi bi-play-circle"></i>
</button>
</form>
{% endif %}
<!-- Permanent Remove Button -->
<form method="post" action="{{ url_for('email.remove_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-danger btn-sm"
title="Permanently Remove IP"
onclick="return confirm('Permanently remove {{ ip.ip_address }}? This cannot be undone!')">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-lg-4">
@@ -139,7 +234,7 @@
<ul class="list-unstyled mb-3">
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Active IPs:</strong> {{ ips|selectattr('0.is_active')|list|length }}
<strong>Active IPs:</strong> {{ (ips|selectattr('0.is_active')|list|length) + (global_ips|selectattr('0.is_active')|list|length) }}
</li>
<li class="mb-2">
<i class="bi bi-server text-info me-2"></i>
@@ -161,9 +256,10 @@
</h6>
<ul class="mb-0 small">
<li>Whitelisted IPs can send emails without username/password authentication</li>
<li>Each IP is associated with a specific domain</li>
<li>IP can only send emails for its authorized domain</li>
<li>Useful for server-to-server email sending</li>
<li>Per Domain IP associated with a specific domain</li>
<li>Global IP can be used for all domains</li>
<li>Global IP whitelisting only Domains added to server, if domain is not added, global IP will not work</li>
<li>If IP is whitelisted, invalid username/password will still pass authentication</li>
</ul>
</div>
</div>
@@ -193,6 +289,7 @@
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}

View File

@@ -49,16 +49,22 @@ class EnhancedCombinedAuthenticator:
self.user_auth = EnhancedAuthenticator()
self.ip_auth = EnhancedIPAuthenticator()
def __call__(self, server, session, envelope, mechanism, auth_data):
async def __call__(self, server, session, envelope, mechanism, auth_data):
from aiosmtpd.smtp import LoginPassword
# If auth_data is provided (username/password), try user authentication first
if auth_data and isinstance(auth_data, LoginPassword):
result = self.user_auth(server, session, envelope, mechanism, auth_data)
if result.success:
return result
# If user auth fails, don't try IP auth - return the failure
return result
try:
result = self.user_auth(server, session, envelope, mechanism, auth_data)
if result.success:
return result
# If user auth fails, send immediate response
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
except Exception as e:
logger.error(f"Authentication error: {e}")
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
# If no auth_data provided, IP auth will be validated during MAIL FROM
# For now, allow the connection to proceed

View File

@@ -1,8 +1,8 @@
"""Initial migration
"""update ip whitelist
Revision ID: 3ce273a1be20
Revision ID: 8652d2ab8a26
Revises:
Create Date: 2025-06-07 15:25:35.603295
Create Date: 2025-06-10 02:17:23.718102
"""
from alembic import op
@@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3ce273a1be20'
revision = '8652d2ab8a26'
down_revision = None
branch_labels = None
depends_on = None
@@ -18,12 +18,12 @@ 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_senders')
op.drop_table('esrv_whitelisted_ips')
op.drop_table('esrv_dkim_keys')
op.drop_table('esrv_domains')
op.drop_table('esrv_custom_headers')
# ### end Alembic commands ###
@@ -37,8 +37,50 @@ def downgrade():
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.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
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_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.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('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.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('esrv_senders',
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.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('esrv_email_logs',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('message_id', sa.VARCHAR(), nullable=False),
@@ -57,33 +99,6 @@ def downgrade():
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),
@@ -94,15 +109,4 @@ def downgrade():
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 ###