86 lines
3.7 KiB
Python
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")
|