Files
saas_backend/alembic/versions/b6e3a1d9f42c_tenant_email_settings.py
T
2026-08-31 20:39:41 -04:00

86 lines
3.7 KiB
Python

"""Letting a workspace send from its own address.
Every message the platform sends — an invitation, a password code, a
subscription notice — goes out from one SMTP account and one `From` address for
the whole platform. For a customer that is wrong in two ways at once: their
people receive account mail from a company they have never heard of, and it
arrives with no SPF or DKIM alignment to their own domain, so a strict receiver
treats it as spoofing and files it accordingly.
The most visible symptom is the one that matters: **invitations land in spam.**
## Why the host is checked like a webhook URL
An SMTP host is a customer-supplied name that the *server* then connects to. It
is the same shape of hazard as a webhook destination — `127.0.0.1:25` is this
machine, and a name that resolves inside the network is a way to make the
platform talk to something it should not. It goes through the same
resolve-then-refuse check, on every send rather than only at configuration time.
## Why the password is encrypted rather than hashed
Unlike an API key or a recovery code, the platform has to *present* this
credential to somebody else's server. There is no version of this where a hash
would do.
Revision ID: b6e3a1d9f42c
Revises: a4d7f2c9e63b
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "b6e3a1d9f42c"
down_revision: Union[str, Sequence[str], None] = "a4d7f2c9e63b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tenant_email_settings",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("smtp_host", sa.String(255), nullable=False),
sa.Column("smtp_port", sa.Integer(), nullable=False, server_default="587"),
sa.Column("smtp_user", sa.String(255), nullable=True),
sa.Column("smtp_password_enc", sa.Text(), nullable=True),
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("from_address", sa.String(255), nullable=False),
sa.Column("from_name", sa.String(150), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.String(500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", name="uq_tenant_email_settings_tenant"),
)
op.execute("ALTER TABLE tenant_email_settings ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE tenant_email_settings FORCE ROW LEVEL SECURITY")
op.execute(
"""
CREATE POLICY tenant_isolation ON tenant_email_settings
USING (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
"""
)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON tenant_email_settings")
op.drop_table("tenant_email_settings")