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.
Files changed (80) hide show
  1. fastsmtp/__init__.py +3 -0
  2. fastsmtp/alembic/env.py +69 -0
  3. fastsmtp/alembic/script.py.mako +26 -0
  4. fastsmtp/alembic/versions/.gitkeep +0 -0
  5. fastsmtp/alembic/versions/001_initial_schema.py +271 -0
  6. fastsmtp/alembic/versions/002_add_delivery_log_indexes.py +30 -0
  7. fastsmtp/alembic/versions/003_add_soft_delete_columns.py +66 -0
  8. fastsmtp/alembic/versions/004_add_raw_message_preservation.py +45 -0
  9. fastsmtp/alembic/versions/005_align_schema_with_models.py +90 -0
  10. fastsmtp/alembic/versions/006_exclude_soft_deleted_from_catchall.py +56 -0
  11. fastsmtp/alembic/versions/007_exclude_soft_deleted_from_local_part.py +59 -0
  12. fastsmtp/alembic/versions/008_exclude_soft_deleted_from_name_uniqueness.py +63 -0
  13. fastsmtp/api/__init__.py +5 -0
  14. fastsmtp/api/auth.py +223 -0
  15. fastsmtp/api/domains.py +384 -0
  16. fastsmtp/api/operations.py +366 -0
  17. fastsmtp/api/recipients.py +296 -0
  18. fastsmtp/api/router.py +15 -0
  19. fastsmtp/api/rules.py +459 -0
  20. fastsmtp/api/users.py +195 -0
  21. fastsmtp/api/validation.py +153 -0
  22. fastsmtp/auth/__init__.py +28 -0
  23. fastsmtp/auth/dependencies.py +361 -0
  24. fastsmtp/auth/keys.py +109 -0
  25. fastsmtp/cleanup/__init__.py +14 -0
  26. fastsmtp/cleanup/purge.py +127 -0
  27. fastsmtp/cleanup/service.py +125 -0
  28. fastsmtp/cleanup/worker.py +158 -0
  29. fastsmtp/cli.py +1332 -0
  30. fastsmtp/config.py +416 -0
  31. fastsmtp/crypto.py +295 -0
  32. fastsmtp/db/__init__.py +34 -0
  33. fastsmtp/db/bulk.py +37 -0
  34. fastsmtp/db/encrypted_types.py +83 -0
  35. fastsmtp/db/encryption_guard.py +234 -0
  36. fastsmtp/db/enums.py +57 -0
  37. fastsmtp/db/integrity.py +72 -0
  38. fastsmtp/db/migrations.py +124 -0
  39. fastsmtp/db/models.py +436 -0
  40. fastsmtp/db/session.py +101 -0
  41. fastsmtp/db/soft_delete.py +166 -0
  42. fastsmtp/db/startup.py +45 -0
  43. fastsmtp/encryption.py +39 -0
  44. fastsmtp/main.py +108 -0
  45. fastsmtp/metrics/__init__.py +25 -0
  46. fastsmtp/metrics/access.py +181 -0
  47. fastsmtp/metrics/definitions.py +70 -0
  48. fastsmtp/metrics/middleware.py +76 -0
  49. fastsmtp/middleware/__init__.py +12 -0
  50. fastsmtp/middleware/db_session.py +66 -0
  51. fastsmtp/middleware/logging.py +63 -0
  52. fastsmtp/middleware/rate_limit.py +160 -0
  53. fastsmtp/net.py +64 -0
  54. fastsmtp/rules/__init__.py +37 -0
  55. fastsmtp/rules/conditions.py +160 -0
  56. fastsmtp/rules/engine.py +248 -0
  57. fastsmtp/schemas/__init__.py +103 -0
  58. fastsmtp/schemas/common.py +40 -0
  59. fastsmtp/schemas/domain.py +99 -0
  60. fastsmtp/schemas/recipient.py +57 -0
  61. fastsmtp/schemas/rule.py +239 -0
  62. fastsmtp/schemas/user.py +91 -0
  63. fastsmtp/schemas/webhook.py +66 -0
  64. fastsmtp/smtp/__init__.py +47 -0
  65. fastsmtp/smtp/rate_limiter.py +184 -0
  66. fastsmtp/smtp/server.py +1032 -0
  67. fastsmtp/smtp/tls.py +224 -0
  68. fastsmtp/smtp/validation.py +344 -0
  69. fastsmtp/storage/__init__.py +10 -0
  70. fastsmtp/storage/raw_message.py +112 -0
  71. fastsmtp/storage/s3.py +255 -0
  72. fastsmtp/webhook/__init__.py +39 -0
  73. fastsmtp/webhook/dispatcher.py +356 -0
  74. fastsmtp/webhook/queue.py +560 -0
  75. fastsmtp/webhook/url_validator.py +311 -0
  76. fastsmtp-0.6.0.dist-info/METADATA +101 -0
  77. fastsmtp-0.6.0.dist-info/RECORD +80 -0
  78. fastsmtp-0.6.0.dist-info/WHEEL +4 -0
  79. fastsmtp-0.6.0.dist-info/entry_points.txt +2 -0
  80. fastsmtp-0.6.0.dist-info/licenses/LICENSE +661 -0
fastsmtp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FastSMTP - SMTP-to-Webhook Relay Server."""
2
+
3
+ __version__ = "0.6.0"
@@ -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
+ )