fastsmtp 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastsmtp/__init__.py +3 -0
- fastsmtp/alembic/env.py +69 -0
- fastsmtp/alembic/script.py.mako +26 -0
- fastsmtp/alembic/versions/.gitkeep +0 -0
- fastsmtp/alembic/versions/001_initial_schema.py +271 -0
- fastsmtp/alembic/versions/002_add_delivery_log_indexes.py +30 -0
- fastsmtp/alembic/versions/003_add_soft_delete_columns.py +66 -0
- fastsmtp/alembic/versions/004_add_raw_message_preservation.py +45 -0
- fastsmtp/alembic/versions/005_align_schema_with_models.py +90 -0
- fastsmtp/alembic/versions/006_exclude_soft_deleted_from_catchall.py +56 -0
- fastsmtp/alembic/versions/007_exclude_soft_deleted_from_local_part.py +59 -0
- fastsmtp/alembic/versions/008_exclude_soft_deleted_from_name_uniqueness.py +63 -0
- fastsmtp/api/__init__.py +5 -0
- fastsmtp/api/auth.py +223 -0
- fastsmtp/api/domains.py +384 -0
- fastsmtp/api/operations.py +366 -0
- fastsmtp/api/recipients.py +296 -0
- fastsmtp/api/router.py +15 -0
- fastsmtp/api/rules.py +459 -0
- fastsmtp/api/users.py +195 -0
- fastsmtp/api/validation.py +153 -0
- fastsmtp/auth/__init__.py +28 -0
- fastsmtp/auth/dependencies.py +361 -0
- fastsmtp/auth/keys.py +109 -0
- fastsmtp/cleanup/__init__.py +14 -0
- fastsmtp/cleanup/purge.py +127 -0
- fastsmtp/cleanup/service.py +125 -0
- fastsmtp/cleanup/worker.py +158 -0
- fastsmtp/cli.py +1332 -0
- fastsmtp/config.py +416 -0
- fastsmtp/crypto.py +295 -0
- fastsmtp/db/__init__.py +34 -0
- fastsmtp/db/bulk.py +37 -0
- fastsmtp/db/encrypted_types.py +83 -0
- fastsmtp/db/encryption_guard.py +234 -0
- fastsmtp/db/enums.py +57 -0
- fastsmtp/db/integrity.py +72 -0
- fastsmtp/db/migrations.py +124 -0
- fastsmtp/db/models.py +436 -0
- fastsmtp/db/session.py +101 -0
- fastsmtp/db/soft_delete.py +166 -0
- fastsmtp/db/startup.py +45 -0
- fastsmtp/encryption.py +39 -0
- fastsmtp/main.py +108 -0
- fastsmtp/metrics/__init__.py +25 -0
- fastsmtp/metrics/access.py +181 -0
- fastsmtp/metrics/definitions.py +70 -0
- fastsmtp/metrics/middleware.py +76 -0
- fastsmtp/middleware/__init__.py +12 -0
- fastsmtp/middleware/db_session.py +66 -0
- fastsmtp/middleware/logging.py +63 -0
- fastsmtp/middleware/rate_limit.py +160 -0
- fastsmtp/net.py +64 -0
- fastsmtp/rules/__init__.py +37 -0
- fastsmtp/rules/conditions.py +160 -0
- fastsmtp/rules/engine.py +248 -0
- fastsmtp/schemas/__init__.py +103 -0
- fastsmtp/schemas/common.py +40 -0
- fastsmtp/schemas/domain.py +99 -0
- fastsmtp/schemas/recipient.py +57 -0
- fastsmtp/schemas/rule.py +239 -0
- fastsmtp/schemas/user.py +91 -0
- fastsmtp/schemas/webhook.py +66 -0
- fastsmtp/smtp/__init__.py +47 -0
- fastsmtp/smtp/rate_limiter.py +184 -0
- fastsmtp/smtp/server.py +1032 -0
- fastsmtp/smtp/tls.py +224 -0
- fastsmtp/smtp/validation.py +344 -0
- fastsmtp/storage/__init__.py +10 -0
- fastsmtp/storage/raw_message.py +112 -0
- fastsmtp/storage/s3.py +255 -0
- fastsmtp/webhook/__init__.py +39 -0
- fastsmtp/webhook/dispatcher.py +356 -0
- fastsmtp/webhook/queue.py +560 -0
- fastsmtp/webhook/url_validator.py +311 -0
- fastsmtp-0.6.0.dist-info/METADATA +101 -0
- fastsmtp-0.6.0.dist-info/RECORD +80 -0
- fastsmtp-0.6.0.dist-info/WHEEL +4 -0
- fastsmtp-0.6.0.dist-info/entry_points.txt +2 -0
- fastsmtp-0.6.0.dist-info/licenses/LICENSE +661 -0
fastsmtp/__init__.py
ADDED
fastsmtp/alembic/env.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Alembic migration environment."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from logging.config import fileConfig
|
|
5
|
+
|
|
6
|
+
from alembic import context
|
|
7
|
+
from fastsmtp.config import DatabaseSettings
|
|
8
|
+
from fastsmtp.db.models import Base
|
|
9
|
+
from sqlalchemy import pool
|
|
10
|
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
11
|
+
|
|
12
|
+
config = context.config
|
|
13
|
+
|
|
14
|
+
if config.config_file_name is not None:
|
|
15
|
+
fileConfig(config.config_file_name)
|
|
16
|
+
|
|
17
|
+
target_metadata = Base.metadata
|
|
18
|
+
|
|
19
|
+
# Only the URL is needed here. Loading the full Settings would demand a root
|
|
20
|
+
# API key and apply the S3 cross-field rules, none of which a migration run
|
|
21
|
+
# uses -- a one-off Job given just the database URL must be able to run.
|
|
22
|
+
config.set_main_option("sqlalchemy.url", DatabaseSettings().database_dsn)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_migrations_offline() -> None:
|
|
26
|
+
"""Run migrations in 'offline' mode."""
|
|
27
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
28
|
+
context.configure(
|
|
29
|
+
url=url,
|
|
30
|
+
target_metadata=target_metadata,
|
|
31
|
+
literal_binds=True,
|
|
32
|
+
dialect_opts={"paramstyle": "named"},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
with context.begin_transaction():
|
|
36
|
+
context.run_migrations()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def do_run_migrations(connection) -> None:
|
|
40
|
+
"""Run migrations with the given connection."""
|
|
41
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
42
|
+
|
|
43
|
+
with context.begin_transaction():
|
|
44
|
+
context.run_migrations()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def run_async_migrations() -> None:
|
|
48
|
+
"""Run migrations in 'online' mode with async engine."""
|
|
49
|
+
connectable = async_engine_from_config(
|
|
50
|
+
config.get_section(config.config_ini_section, {}),
|
|
51
|
+
prefix="sqlalchemy.",
|
|
52
|
+
poolclass=pool.NullPool,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async with connectable.connect() as connection:
|
|
56
|
+
await connection.run_sync(do_run_migrations)
|
|
57
|
+
|
|
58
|
+
await connectable.dispose()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_migrations_online() -> None:
|
|
62
|
+
"""Run migrations in 'online' mode."""
|
|
63
|
+
asyncio.run(run_async_migrations())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if context.is_offline_mode():
|
|
67
|
+
run_migrations_offline()
|
|
68
|
+
else:
|
|
69
|
+
run_migrations_online()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
${imports if imports else ""}
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
${upgrades if upgrades else "pass"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def downgrade() -> None:
|
|
26
|
+
${downgrades if downgrades else "pass"}
|
|
File without changes
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Initial schema.
|
|
2
|
+
|
|
3
|
+
Revision ID: 001
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2025-01-07
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
from alembic import op
|
|
13
|
+
from sqlalchemy.dialects import postgresql
|
|
14
|
+
|
|
15
|
+
# revision identifiers, used by Alembic.
|
|
16
|
+
revision: str = "001"
|
|
17
|
+
down_revision: str | None = None
|
|
18
|
+
branch_labels: str | Sequence[str] | None = None
|
|
19
|
+
depends_on: str | Sequence[str] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade() -> None:
|
|
23
|
+
# Users table
|
|
24
|
+
op.create_table(
|
|
25
|
+
"users",
|
|
26
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
27
|
+
sa.Column("username", sa.String(255), nullable=False),
|
|
28
|
+
sa.Column("email", sa.String(255), nullable=True),
|
|
29
|
+
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
|
30
|
+
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
|
31
|
+
sa.Column(
|
|
32
|
+
"created_at",
|
|
33
|
+
sa.DateTime(timezone=True),
|
|
34
|
+
nullable=False,
|
|
35
|
+
server_default=sa.func.now(),
|
|
36
|
+
),
|
|
37
|
+
sa.Column(
|
|
38
|
+
"updated_at",
|
|
39
|
+
sa.DateTime(timezone=True),
|
|
40
|
+
nullable=False,
|
|
41
|
+
server_default=sa.func.now(),
|
|
42
|
+
),
|
|
43
|
+
sa.PrimaryKeyConstraint("id"),
|
|
44
|
+
sa.UniqueConstraint("username"),
|
|
45
|
+
)
|
|
46
|
+
op.create_index("ix_users_username", "users", ["username"])
|
|
47
|
+
|
|
48
|
+
# API Keys table
|
|
49
|
+
op.create_table(
|
|
50
|
+
"api_keys",
|
|
51
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
52
|
+
sa.Column("user_id", sa.Uuid(), nullable=False),
|
|
53
|
+
sa.Column("key_hash", sa.String(255), nullable=False),
|
|
54
|
+
sa.Column("key_salt", sa.String(128), nullable=True),
|
|
55
|
+
sa.Column("key_prefix", sa.String(20), nullable=False),
|
|
56
|
+
sa.Column("name", sa.String(255), nullable=False),
|
|
57
|
+
sa.Column("scopes", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
58
|
+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
|
59
|
+
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
|
60
|
+
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
|
61
|
+
sa.Column(
|
|
62
|
+
"created_at",
|
|
63
|
+
sa.DateTime(timezone=True),
|
|
64
|
+
nullable=False,
|
|
65
|
+
server_default=sa.func.now(),
|
|
66
|
+
),
|
|
67
|
+
sa.Column(
|
|
68
|
+
"updated_at",
|
|
69
|
+
sa.DateTime(timezone=True),
|
|
70
|
+
nullable=False,
|
|
71
|
+
server_default=sa.func.now(),
|
|
72
|
+
),
|
|
73
|
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
74
|
+
sa.PrimaryKeyConstraint("id"),
|
|
75
|
+
)
|
|
76
|
+
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
|
|
77
|
+
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"])
|
|
78
|
+
|
|
79
|
+
# Domains table
|
|
80
|
+
op.create_table(
|
|
81
|
+
"domains",
|
|
82
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
83
|
+
sa.Column("domain_name", sa.String(255), nullable=False),
|
|
84
|
+
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
|
85
|
+
sa.Column("verify_dkim", sa.Boolean(), nullable=True),
|
|
86
|
+
sa.Column("verify_spf", sa.Boolean(), nullable=True),
|
|
87
|
+
sa.Column("reject_dkim_fail", sa.Boolean(), nullable=True),
|
|
88
|
+
sa.Column("reject_spf_fail", sa.Boolean(), nullable=True),
|
|
89
|
+
sa.Column(
|
|
90
|
+
"created_at",
|
|
91
|
+
sa.DateTime(timezone=True),
|
|
92
|
+
nullable=False,
|
|
93
|
+
server_default=sa.func.now(),
|
|
94
|
+
),
|
|
95
|
+
sa.Column(
|
|
96
|
+
"updated_at",
|
|
97
|
+
sa.DateTime(timezone=True),
|
|
98
|
+
nullable=False,
|
|
99
|
+
server_default=sa.func.now(),
|
|
100
|
+
),
|
|
101
|
+
sa.PrimaryKeyConstraint("id"),
|
|
102
|
+
sa.UniqueConstraint("domain_name"),
|
|
103
|
+
)
|
|
104
|
+
op.create_index("ix_domains_domain_name", "domains", ["domain_name"])
|
|
105
|
+
|
|
106
|
+
# Domain Members table
|
|
107
|
+
op.create_table(
|
|
108
|
+
"domain_members",
|
|
109
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
110
|
+
sa.Column("user_id", sa.Uuid(), nullable=False),
|
|
111
|
+
sa.Column("domain_id", sa.Uuid(), nullable=False),
|
|
112
|
+
sa.Column("role", sa.String(50), nullable=False, server_default=sa.text("'member'")),
|
|
113
|
+
sa.Column(
|
|
114
|
+
"created_at",
|
|
115
|
+
sa.DateTime(timezone=True),
|
|
116
|
+
nullable=False,
|
|
117
|
+
server_default=sa.func.now(),
|
|
118
|
+
),
|
|
119
|
+
sa.Column(
|
|
120
|
+
"updated_at",
|
|
121
|
+
sa.DateTime(timezone=True),
|
|
122
|
+
nullable=False,
|
|
123
|
+
server_default=sa.func.now(),
|
|
124
|
+
),
|
|
125
|
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
126
|
+
sa.ForeignKeyConstraint(["domain_id"], ["domains.id"], ondelete="CASCADE"),
|
|
127
|
+
sa.PrimaryKeyConstraint("id"),
|
|
128
|
+
sa.UniqueConstraint("user_id", "domain_id", name="uq_domain_member"),
|
|
129
|
+
)
|
|
130
|
+
op.create_index("ix_domain_members_user_domain", "domain_members", ["user_id", "domain_id"])
|
|
131
|
+
|
|
132
|
+
# Recipients table
|
|
133
|
+
op.create_table(
|
|
134
|
+
"recipients",
|
|
135
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
136
|
+
sa.Column("domain_id", sa.Uuid(), nullable=False),
|
|
137
|
+
sa.Column("local_part", sa.String(255), nullable=True),
|
|
138
|
+
sa.Column("webhook_url", sa.Text(), nullable=False),
|
|
139
|
+
sa.Column("webhook_headers", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
140
|
+
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
|
141
|
+
sa.Column(
|
|
142
|
+
"created_at",
|
|
143
|
+
sa.DateTime(timezone=True),
|
|
144
|
+
nullable=False,
|
|
145
|
+
server_default=sa.func.now(),
|
|
146
|
+
),
|
|
147
|
+
sa.Column(
|
|
148
|
+
"updated_at",
|
|
149
|
+
sa.DateTime(timezone=True),
|
|
150
|
+
nullable=False,
|
|
151
|
+
server_default=sa.func.now(),
|
|
152
|
+
),
|
|
153
|
+
sa.ForeignKeyConstraint(["domain_id"], ["domains.id"], ondelete="CASCADE"),
|
|
154
|
+
sa.PrimaryKeyConstraint("id"),
|
|
155
|
+
sa.UniqueConstraint("domain_id", "local_part", name="uq_recipient_local_part"),
|
|
156
|
+
)
|
|
157
|
+
op.create_index("ix_recipients_domain_id", "recipients", ["domain_id"])
|
|
158
|
+
op.create_index("ix_recipients_domain_local", "recipients", ["domain_id", "local_part"])
|
|
159
|
+
|
|
160
|
+
# RuleSets table
|
|
161
|
+
op.create_table(
|
|
162
|
+
"rulesets",
|
|
163
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
164
|
+
sa.Column("domain_id", sa.Uuid(), nullable=False),
|
|
165
|
+
sa.Column("name", sa.String(255), nullable=False),
|
|
166
|
+
sa.Column("priority", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
167
|
+
sa.Column("stop_on_match", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
|
168
|
+
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
|
169
|
+
sa.Column(
|
|
170
|
+
"created_at",
|
|
171
|
+
sa.DateTime(timezone=True),
|
|
172
|
+
nullable=False,
|
|
173
|
+
server_default=sa.func.now(),
|
|
174
|
+
),
|
|
175
|
+
sa.Column(
|
|
176
|
+
"updated_at",
|
|
177
|
+
sa.DateTime(timezone=True),
|
|
178
|
+
nullable=False,
|
|
179
|
+
server_default=sa.func.now(),
|
|
180
|
+
),
|
|
181
|
+
sa.ForeignKeyConstraint(["domain_id"], ["domains.id"], ondelete="CASCADE"),
|
|
182
|
+
sa.PrimaryKeyConstraint("id"),
|
|
183
|
+
sa.UniqueConstraint("domain_id", "name", name="uq_ruleset_name"),
|
|
184
|
+
)
|
|
185
|
+
op.create_index("ix_rulesets_domain_id", "rulesets", ["domain_id"])
|
|
186
|
+
op.create_index("ix_rulesets_domain_priority", "rulesets", ["domain_id", "priority"])
|
|
187
|
+
|
|
188
|
+
# Rules table
|
|
189
|
+
op.create_table(
|
|
190
|
+
"rules",
|
|
191
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
192
|
+
sa.Column("ruleset_id", sa.Uuid(), nullable=False),
|
|
193
|
+
sa.Column("order", sa.Integer(), nullable=False),
|
|
194
|
+
sa.Column("field", sa.String(100), nullable=False),
|
|
195
|
+
sa.Column("operator", sa.String(50), nullable=False),
|
|
196
|
+
sa.Column("value", sa.Text(), nullable=False),
|
|
197
|
+
sa.Column("case_sensitive", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
|
198
|
+
sa.Column("action", sa.String(50), nullable=False, server_default=sa.text("'forward'")),
|
|
199
|
+
sa.Column("webhook_url_override", sa.Text(), nullable=True),
|
|
200
|
+
sa.Column("add_tags", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
201
|
+
sa.Column(
|
|
202
|
+
"created_at",
|
|
203
|
+
sa.DateTime(timezone=True),
|
|
204
|
+
nullable=False,
|
|
205
|
+
server_default=sa.func.now(),
|
|
206
|
+
),
|
|
207
|
+
sa.Column(
|
|
208
|
+
"updated_at",
|
|
209
|
+
sa.DateTime(timezone=True),
|
|
210
|
+
nullable=False,
|
|
211
|
+
server_default=sa.func.now(),
|
|
212
|
+
),
|
|
213
|
+
sa.ForeignKeyConstraint(["ruleset_id"], ["rulesets.id"], ondelete="CASCADE"),
|
|
214
|
+
sa.PrimaryKeyConstraint("id"),
|
|
215
|
+
)
|
|
216
|
+
op.create_index("ix_rules_ruleset_id", "rules", ["ruleset_id"])
|
|
217
|
+
op.create_index("ix_rules_ruleset_order", "rules", ["ruleset_id", "order"])
|
|
218
|
+
|
|
219
|
+
# Delivery Log table
|
|
220
|
+
op.create_table(
|
|
221
|
+
"delivery_log",
|
|
222
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
223
|
+
sa.Column("domain_id", sa.Uuid(), nullable=True),
|
|
224
|
+
sa.Column("message_id", sa.String(255), nullable=False),
|
|
225
|
+
sa.Column("recipient_id", sa.Uuid(), nullable=True),
|
|
226
|
+
sa.Column("webhook_url", sa.Text(), nullable=False),
|
|
227
|
+
sa.Column("payload_hash", sa.String(64), nullable=False),
|
|
228
|
+
sa.Column("status", sa.String(50), nullable=False),
|
|
229
|
+
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
230
|
+
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
|
|
231
|
+
sa.Column("last_error", sa.Text(), nullable=True),
|
|
232
|
+
sa.Column("last_status_code", sa.Integer(), nullable=True),
|
|
233
|
+
sa.Column("instance_id", sa.String(50), nullable=False),
|
|
234
|
+
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
|
235
|
+
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
236
|
+
sa.Column("dkim_result", sa.String(50), nullable=True),
|
|
237
|
+
sa.Column("spf_result", sa.String(50), nullable=True),
|
|
238
|
+
sa.Column(
|
|
239
|
+
"created_at",
|
|
240
|
+
sa.DateTime(timezone=True),
|
|
241
|
+
nullable=False,
|
|
242
|
+
server_default=sa.func.now(),
|
|
243
|
+
),
|
|
244
|
+
sa.Column(
|
|
245
|
+
"updated_at",
|
|
246
|
+
sa.DateTime(timezone=True),
|
|
247
|
+
nullable=False,
|
|
248
|
+
server_default=sa.func.now(),
|
|
249
|
+
),
|
|
250
|
+
sa.ForeignKeyConstraint(["domain_id"], ["domains.id"], ondelete="SET NULL"),
|
|
251
|
+
sa.ForeignKeyConstraint(["recipient_id"], ["recipients.id"], ondelete="SET NULL"),
|
|
252
|
+
sa.PrimaryKeyConstraint("id"),
|
|
253
|
+
)
|
|
254
|
+
op.create_index("ix_delivery_log_domain_id", "delivery_log", ["domain_id"])
|
|
255
|
+
op.create_index("ix_delivery_log_message_id", "delivery_log", ["message_id"])
|
|
256
|
+
op.create_index("ix_delivery_log_status", "delivery_log", ["status"])
|
|
257
|
+
op.create_index("ix_delivery_log_next_retry_at", "delivery_log", ["next_retry_at"])
|
|
258
|
+
op.create_index("ix_delivery_log_status_retry", "delivery_log", ["status", "next_retry_at"])
|
|
259
|
+
op.create_index("ix_delivery_log_domain_created", "delivery_log", ["domain_id", "created_at"])
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def downgrade() -> None:
|
|
263
|
+
# Drop tables in reverse order (respecting foreign key dependencies)
|
|
264
|
+
op.drop_table("delivery_log")
|
|
265
|
+
op.drop_table("rules")
|
|
266
|
+
op.drop_table("rulesets")
|
|
267
|
+
op.drop_table("recipients")
|
|
268
|
+
op.drop_table("domain_members")
|
|
269
|
+
op.drop_table("domains")
|
|
270
|
+
op.drop_table("api_keys")
|
|
271
|
+
op.drop_table("users")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Add delivery log indexes.
|
|
2
|
+
|
|
3
|
+
Revision ID: 002
|
|
4
|
+
Revises: 001
|
|
5
|
+
Create Date: 2025-01-07
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from alembic import op
|
|
12
|
+
|
|
13
|
+
# revision identifiers, used by Alembic.
|
|
14
|
+
revision: str = "002"
|
|
15
|
+
down_revision: str | None = "001"
|
|
16
|
+
branch_labels: str | Sequence[str] | None = None
|
|
17
|
+
depends_on: str | Sequence[str] | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def upgrade() -> None:
|
|
21
|
+
# Add indexes for delivery_log table
|
|
22
|
+
op.create_index("ix_delivery_log_instance_id", "delivery_log", ["instance_id"])
|
|
23
|
+
op.create_index("ix_delivery_log_delivered_at", "delivery_log", ["delivered_at"])
|
|
24
|
+
op.create_index("ix_delivery_log_cleanup", "delivery_log", ["created_at", "status"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def downgrade() -> None:
|
|
28
|
+
op.drop_index("ix_delivery_log_cleanup", "delivery_log")
|
|
29
|
+
op.drop_index("ix_delivery_log_delivered_at", "delivery_log")
|
|
30
|
+
op.drop_index("ix_delivery_log_instance_id", "delivery_log")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Add soft delete columns to key models.
|
|
2
|
+
|
|
3
|
+
Revision ID: 003
|
|
4
|
+
Revises: 002
|
|
5
|
+
Create Date: 2025-01-07
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
from alembic import op
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = "003"
|
|
16
|
+
down_revision: str | None = "002"
|
|
17
|
+
branch_labels: str | Sequence[str] | None = None
|
|
18
|
+
depends_on: str | Sequence[str] | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
# Add deleted_at column to users table
|
|
23
|
+
op.add_column(
|
|
24
|
+
"users",
|
|
25
|
+
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
26
|
+
)
|
|
27
|
+
op.create_index("ix_users_deleted_at", "users", ["deleted_at"])
|
|
28
|
+
|
|
29
|
+
# Add deleted_at column to api_keys table
|
|
30
|
+
op.add_column(
|
|
31
|
+
"api_keys",
|
|
32
|
+
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
33
|
+
)
|
|
34
|
+
op.create_index("ix_api_keys_deleted_at", "api_keys", ["deleted_at"])
|
|
35
|
+
|
|
36
|
+
# Add deleted_at column to domains table
|
|
37
|
+
op.add_column(
|
|
38
|
+
"domains",
|
|
39
|
+
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
40
|
+
)
|
|
41
|
+
op.create_index("ix_domains_deleted_at", "domains", ["deleted_at"])
|
|
42
|
+
|
|
43
|
+
# Add deleted_at column to recipients table
|
|
44
|
+
op.add_column(
|
|
45
|
+
"recipients",
|
|
46
|
+
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
47
|
+
)
|
|
48
|
+
op.create_index("ix_recipients_deleted_at", "recipients", ["deleted_at"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def downgrade() -> None:
|
|
52
|
+
# Remove deleted_at from recipients
|
|
53
|
+
op.drop_index("ix_recipients_deleted_at", "recipients")
|
|
54
|
+
op.drop_column("recipients", "deleted_at")
|
|
55
|
+
|
|
56
|
+
# Remove deleted_at from domains
|
|
57
|
+
op.drop_index("ix_domains_deleted_at", "domains")
|
|
58
|
+
op.drop_column("domains", "deleted_at")
|
|
59
|
+
|
|
60
|
+
# Remove deleted_at from api_keys
|
|
61
|
+
op.drop_index("ix_api_keys_deleted_at", "api_keys")
|
|
62
|
+
op.drop_column("api_keys", "deleted_at")
|
|
63
|
+
|
|
64
|
+
# Remove deleted_at from users
|
|
65
|
+
op.drop_index("ix_users_deleted_at", "users")
|
|
66
|
+
op.drop_column("users", "deleted_at")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Add raw message preservation flags to domains and rules.
|
|
2
|
+
|
|
3
|
+
Revision ID: 004
|
|
4
|
+
Revises: 003
|
|
5
|
+
Create Date: 2026-08-22
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
from alembic import op
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = "004"
|
|
16
|
+
down_revision: str | None = "003"
|
|
17
|
+
branch_labels: str | Sequence[str] | None = None
|
|
18
|
+
depends_on: str | Sequence[str] | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
# NULL = inherit the global preserve_raw_message setting
|
|
23
|
+
op.add_column(
|
|
24
|
+
"domains",
|
|
25
|
+
sa.Column("preserve_raw_message", sa.Boolean(), nullable=True),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Orthogonal to Rule.action: a rule may preserve the raw message and still drop it.
|
|
29
|
+
# Added with a server default so existing rows backfill, then dropped so the
|
|
30
|
+
# application default is the only source of truth.
|
|
31
|
+
op.add_column(
|
|
32
|
+
"rules",
|
|
33
|
+
sa.Column(
|
|
34
|
+
"preserve_raw",
|
|
35
|
+
sa.Boolean(),
|
|
36
|
+
nullable=False,
|
|
37
|
+
server_default=sa.false(),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
op.alter_column("rules", "preserve_raw", server_default=None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def downgrade() -> None:
|
|
44
|
+
op.drop_column("rules", "preserve_raw")
|
|
45
|
+
op.drop_column("domains", "preserve_raw_message")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Align the migrated schema with the models.
|
|
2
|
+
|
|
3
|
+
Revision ID: 005
|
|
4
|
+
Revises: 004
|
|
5
|
+
Create Date: 2026-08-23
|
|
6
|
+
|
|
7
|
+
Three differences between what the chain builds and what the models declare,
|
|
8
|
+
all dating from 001 and all invisible until tests/test_migrations.py started
|
|
9
|
+
diffing the two (the rest of the suite builds its schema from the models with
|
|
10
|
+
Base.metadata.create_all, so migrated databases were never compared):
|
|
11
|
+
|
|
12
|
+
1. Four JSONB columns were created nullable although the models declare them
|
|
13
|
+
NOT NULL (``scopes``, ``webhook_headers``, ``add_tags``, ``payload``).
|
|
14
|
+
2. ``users.username`` and ``domains.domain_name`` got a UNIQUE constraint plus a
|
|
15
|
+
separate non-unique index, where ``mapped_column(unique=True, index=True)``
|
|
16
|
+
declares a single unique index. The redundant index is dropped here.
|
|
17
|
+
3. ``ix_recipients_domain_catchall`` was never created, so a migrated database
|
|
18
|
+
accepted two catch-all recipients for the same domain - the partial unique
|
|
19
|
+
index is the only thing enforcing that, because PostgreSQL lets
|
|
20
|
+
``uq_recipient_local_part`` hold any number of NULL ``local_part`` rows.
|
|
21
|
+
|
|
22
|
+
The chain has been PostgreSQL-specific since 001 (it creates ``postgresql.JSONB``
|
|
23
|
+
columns), so the partial index is expressed with dialect-specific WHERE clauses
|
|
24
|
+
exactly as the model does.
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
|
|
30
|
+
import sqlalchemy as sa
|
|
31
|
+
from alembic import op
|
|
32
|
+
from sqlalchemy.dialects import postgresql
|
|
33
|
+
|
|
34
|
+
# revision identifiers, used by Alembic.
|
|
35
|
+
revision: str = "005"
|
|
36
|
+
down_revision: str | None = "004"
|
|
37
|
+
branch_labels: str | Sequence[str] | None = None
|
|
38
|
+
depends_on: str | Sequence[str] | None = None
|
|
39
|
+
|
|
40
|
+
# (table, column, value to backfill NULLs with) for columns the models declare
|
|
41
|
+
# non-optional - Mapped[list[str]] and Mapped[dict], never Mapped[... | None].
|
|
42
|
+
NOT_NULL_JSONB: tuple[tuple[str, str, str], ...] = (
|
|
43
|
+
("api_keys", "scopes", "'[]'::jsonb"),
|
|
44
|
+
("recipients", "webhook_headers", "'{}'::jsonb"),
|
|
45
|
+
("rules", "add_tags", "'[]'::jsonb"),
|
|
46
|
+
("delivery_log", "payload", "'{}'::jsonb"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
JSONB_TYPE = postgresql.JSONB(astext_type=sa.Text())
|
|
50
|
+
|
|
51
|
+
# Auto-generated PostgreSQL names for the inline UNIQUE constraints in 001.
|
|
52
|
+
UNIQUE_TO_INDEX: tuple[tuple[str, str, str, str], ...] = (
|
|
53
|
+
("users", "username", "users_username_key", "ix_users_username"),
|
|
54
|
+
("domains", "domain_name", "domains_domain_name_key", "ix_domains_domain_name"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def upgrade() -> None:
|
|
59
|
+
for table, column, empty in NOT_NULL_JSONB:
|
|
60
|
+
op.execute(f"UPDATE {table} SET {column} = {empty} WHERE {column} IS NULL")
|
|
61
|
+
op.alter_column(table, column, existing_type=JSONB_TYPE, nullable=False)
|
|
62
|
+
|
|
63
|
+
for table, column, constraint, index in UNIQUE_TO_INDEX:
|
|
64
|
+
op.drop_constraint(constraint, table, type_="unique")
|
|
65
|
+
op.drop_index(index, table_name=table)
|
|
66
|
+
op.create_index(index, table, [column], unique=True)
|
|
67
|
+
|
|
68
|
+
# Fails if a domain already has two catch-all recipients. That is a data
|
|
69
|
+
# error the application has always rejected; deduplicating here would delete
|
|
70
|
+
# a recipient silently, so it is left to be resolved deliberately.
|
|
71
|
+
op.create_index(
|
|
72
|
+
"ix_recipients_domain_catchall",
|
|
73
|
+
"recipients",
|
|
74
|
+
["domain_id"],
|
|
75
|
+
unique=True,
|
|
76
|
+
postgresql_where=sa.text("local_part IS NULL"),
|
|
77
|
+
sqlite_where=sa.text("local_part IS NULL"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def downgrade() -> None:
|
|
82
|
+
op.drop_index("ix_recipients_domain_catchall", table_name="recipients")
|
|
83
|
+
|
|
84
|
+
for table, column, constraint, index in UNIQUE_TO_INDEX:
|
|
85
|
+
op.drop_index(index, table_name=table)
|
|
86
|
+
op.create_index(index, table, [column])
|
|
87
|
+
op.create_unique_constraint(constraint, table, [column])
|
|
88
|
+
|
|
89
|
+
for table, column, _empty in NOT_NULL_JSONB:
|
|
90
|
+
op.alter_column(table, column, existing_type=JSONB_TYPE, nullable=True)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Exclude soft-deleted recipients from the catch-all unique index.
|
|
2
|
+
|
|
3
|
+
Revision ID: 006
|
|
4
|
+
Revises: 005
|
|
5
|
+
Create Date: 2026-08-23
|
|
6
|
+
|
|
7
|
+
``ix_recipients_domain_catchall`` guarantees at most one catch-all recipient
|
|
8
|
+
per domain, but its predicate (``local_part IS NULL``) also counted
|
|
9
|
+
soft-deleted rows. ``Recipient`` uses ``SoftDeleteMixin``, so a deleted
|
|
10
|
+
catch-all stays in the table with ``deleted_at`` set - and kept blocking the
|
|
11
|
+
creation of its replacement, turning the routine delete-then-recreate admin
|
|
12
|
+
operation into a unique violation. The predicate now also requires
|
|
13
|
+
``deleted_at IS NULL``, matching the model.
|
|
14
|
+
|
|
15
|
+
The upgrade cannot fail on existing data: every set of rows that satisfied the
|
|
16
|
+
old, broader predicate satisfies the narrower one. The downgrade can - a live
|
|
17
|
+
catch-all plus a tombstoned one for the same domain is valid under this
|
|
18
|
+
revision but violates the old index. As with 005, failing loudly is deliberate;
|
|
19
|
+
deduplicating here would silently delete a recipient.
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
|
|
25
|
+
import sqlalchemy as sa
|
|
26
|
+
from alembic import op
|
|
27
|
+
|
|
28
|
+
# revision identifiers, used by Alembic.
|
|
29
|
+
revision: str = "006"
|
|
30
|
+
down_revision: str | None = "005"
|
|
31
|
+
branch_labels: str | Sequence[str] | None = None
|
|
32
|
+
depends_on: str | Sequence[str] | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def upgrade() -> None:
|
|
36
|
+
op.drop_index("ix_recipients_domain_catchall", table_name="recipients")
|
|
37
|
+
op.create_index(
|
|
38
|
+
"ix_recipients_domain_catchall",
|
|
39
|
+
"recipients",
|
|
40
|
+
["domain_id"],
|
|
41
|
+
unique=True,
|
|
42
|
+
postgresql_where=sa.text("local_part IS NULL AND deleted_at IS NULL"),
|
|
43
|
+
sqlite_where=sa.text("local_part IS NULL AND deleted_at IS NULL"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def downgrade() -> None:
|
|
48
|
+
op.drop_index("ix_recipients_domain_catchall", table_name="recipients")
|
|
49
|
+
op.create_index(
|
|
50
|
+
"ix_recipients_domain_catchall",
|
|
51
|
+
"recipients",
|
|
52
|
+
["domain_id"],
|
|
53
|
+
unique=True,
|
|
54
|
+
postgresql_where=sa.text("local_part IS NULL"),
|
|
55
|
+
sqlite_where=sa.text("local_part IS NULL"),
|
|
56
|
+
)
|