From 23d004e5e53397e7b81cfa8b22d66b2dd0478160 Mon Sep 17 00:00:00 2001
From: Otitodev <198799600+Otitodev@users.noreply.github.com>
Date: Mon, 5 Oct 2026 08:43:49 +0100
Subject: [PATCH 01/10] fix(core): give the domain back when AWS refuses to set
up receiving
setup_receiving claims the domain before it calls AWS, so that the unique
index can decide a race between two requests enabling the same domain. If
AWS then refused - a missing permission is the likeliest - the claim stayed
on the identity, and the identity said it received mail when nothing did.
That is worse than it sounds because of how the routes that call this are
written. They deliberately do not roll back on an APIError: a rollback expires
every loaded object and the page then 500s instead of showing the message, which
was seen on a real host for the event routes. So the same render would have
shown "Receiving: on" beside the very reason it was not.
On failure the identity's rule fields are now put back to what they were before
the attempt, and flushed. "What they were" and not "blank": a repair run on a
domain that already receives must not leave it blank because the second attempt
failed - it was receiving, and it still is. The record of the bucket, topic and
queue is kept when only the rule step fails, because they really are in the
account and a row that forgot them is one teardown could never find.
Tests cover a refusal before anything is built, a refusal after the plumbing
exists, and a failed repair. They need the database, so they run in CI; the
first would fail on the old code on `receives_mail is False`.
---
.../core/src/seskit_core/services/inbound.py | 49 +++++++---
tests/test_inbound_service.py | 89 +++++++++++++++++++
2 files changed, 127 insertions(+), 11 deletions(-)
diff --git a/packages/core/src/seskit_core/services/inbound.py b/packages/core/src/seskit_core/services/inbound.py
index 9e67057..af82ad9 100644
--- a/packages/core/src/seskit_core/services/inbound.py
+++ b/packages/core/src/seskit_core/services/inbound.py
@@ -210,20 +210,47 @@ async def setup_receiving(
resource_prefix, account_id=connection.aws_account_id, region=connection.region
)
+ before = (
+ identity.inbound_rule_name,
+ identity.inbound_rule_set,
+ identity.inbound_rule_set_created,
+ )
await _claim_domain(session, identity, rule_name)
- provisioner = factory(connection.region, credentials)
- infrastructure = await provisioner.provision_inbound(
- bucket_name=bucket,
- topic_name=topic_name_for(resource_prefix),
- queue_name=queue_name_for(resource_prefix),
- retention_days=retention_days,
- )
- connection.record_inbound_infrastructure(infrastructure)
+ try:
+ provisioner = factory(connection.region, credentials)
+ infrastructure = await provisioner.provision_inbound(
+ bucket_name=bucket,
+ topic_name=topic_name_for(resource_prefix),
+ queue_name=queue_name_for(resource_prefix),
+ retention_days=retention_days,
+ )
+ # Recorded as soon as it exists, and kept if the rule below then fails:
+ # the bucket, topic and queue are really in the account, and a row that
+ # forgot them is one teardown could never find.
+ connection.record_inbound_infrastructure(infrastructure)
+
+ rule = await provisioner.add_inbound_rule(
+ infrastructure, domain=identity.value, rule_name=rule_name
+ )
+ except Exception:
+ # Give the domain back. The claim above was written before AWS was
+ # called, so that the unique index could decide a race; if AWS then
+ # refuses, nothing is receiving, and the identity must not say it is.
+ #
+ # This matters beyond tidiness. The routes that call this deliberately
+ # do not roll back on an error - a rollback expires every loaded object
+ # and the page then 500s instead of showing the message - so without
+ # this the same render would show "Receiving: on" beside the reason it
+ # is not. A repair run puts back what was there, not blank.
+ (
+ identity.inbound_rule_name,
+ identity.inbound_rule_set,
+ identity.inbound_rule_set_created,
+ ) = before
+ await session.flush()
+ raise
- rule = await provisioner.add_inbound_rule(
- infrastructure, domain=identity.value, rule_name=rule_name
- )
identity.inbound_rule_name = rule.name
identity.inbound_rule_set = rule.rule_set
identity.inbound_rule_set_created = rule.created_rule_set
diff --git a/tests/test_inbound_service.py b/tests/test_inbound_service.py
index 4e39175..c083521 100644
--- a/tests/test_inbound_service.py
+++ b/tests/test_inbound_service.py
@@ -62,6 +62,11 @@ class FakeInbound:
bucket_removed: ClassVar[bool] = True
#: Raised by ``remove_inbound_rule`` when set.
remove_rule_error: ClassVar[APIError | None] = None
+ #: Raised by ``provision_inbound`` when set, to stand in for AWS refusing -
+ #: a missing permission, say.
+ setup_error: ClassVar[APIError | None] = None
+ #: Raised by ``add_inbound_rule`` alone, after the plumbing was built.
+ rule_error: ClassVar[APIError | None] = None
def __init__(self, region: str, credentials: AWSCredentials | None = None) -> None:
self.region = region
@@ -75,6 +80,8 @@ async def provision_inbound(
queue_name: str,
retention_days: int,
) -> InboundInfrastructure:
+ if FakeInbound.setup_error is not None:
+ raise FakeInbound.setup_error
FakeInbound.calls.append(
("provision", {"bucket": bucket_name, "retention_days": retention_days})
)
@@ -89,6 +96,8 @@ async def provision_inbound(
async def add_inbound_rule(
self, infrastructure: InboundInfrastructure, *, domain: str, rule_name: str
) -> InboundRule:
+ if FakeInbound.rule_error is not None:
+ raise FakeInbound.rule_error
FakeInbound.calls.append(("add_rule", domain))
return InboundRule(name=rule_name, rule_set="seskit-inbound", created_rule_set=True)
@@ -107,6 +116,8 @@ def _reset() -> None:
FakeInbound.calls = []
FakeInbound.bucket_removed = True
FakeInbound.remove_rule_error = None
+ FakeInbound.setup_error = None
+ FakeInbound.rule_error = None
def factory(region: str, credentials: AWSCredentials) -> InboundProvisioner:
@@ -308,6 +319,84 @@ async def test_a_domain_already_receiving_elsewhere_is_refused_before_aws_is_tou
assert mine.receives_mail is True
+# --------------------------------------------------- when AWS refuses ---
+
+
+async def test_a_refusal_from_aws_leaves_the_domain_not_receiving(
+ db_session: AsyncSession,
+) -> None:
+ """The claim is written before AWS is called, so that the unique index can
+ decide a race. If AWS then refuses, the identity must not say it receives: the
+ routes that call this do not roll back, so the same render would show
+ "Receiving: on" beside the reason it is not.
+ """
+ connection = await _project_connection(db_session, email="a@example.com")
+ identity = await _domain(db_session, connection)
+ FakeInbound.setup_error = APIError(
+ ErrorType.AUTHORIZATION_FAILED, "not permitted to call s3:CreateBucket"
+ )
+
+ with pytest.raises(APIError):
+ await _enable(db_session, connection, identity)
+
+ assert identity.receives_mail is False
+ assert identity.inbound_rule_name is None
+ assert identity.inbound_rule_set is None
+ assert identity.inbound_rule_set_created is False
+ # And it is not left holding the domain against a second attempt.
+ FakeInbound.setup_error = None
+ await _enable(db_session, connection, identity)
+ assert identity.receives_mail is True
+
+
+async def test_a_refusal_after_the_plumbing_exists_keeps_what_was_built(
+ db_session: AsyncSession,
+) -> None:
+ """The bucket, topic and queue are really in the account. Forgetting them
+ would leave resources teardown can never find; the identity is what is given
+ back, not the record of what was created.
+ """
+ connection = await _project_connection(db_session, email="a@example.com")
+ identity = await _domain(db_session, connection)
+ FakeInbound.rule_error = APIError(
+ ErrorType.AUTHORIZATION_FAILED, "not permitted to call ses:CreateReceiptRule"
+ )
+
+ with pytest.raises(APIError):
+ await _enable(db_session, connection, identity)
+
+ assert identity.receives_mail is False
+ assert connection.inbound_enabled is True
+
+
+async def test_a_failed_repair_puts_back_the_rule_that_was_there(
+ db_session: AsyncSession,
+) -> None:
+ """Running setup again on a domain that already receives must not leave it
+ blank if the second attempt fails. It was receiving before; it still is.
+ """
+ connection = await _project_connection(db_session, email="a@example.com")
+ identity = await _domain(db_session, connection)
+ await _enable(db_session, connection, identity)
+ before = (
+ identity.inbound_rule_name,
+ identity.inbound_rule_set,
+ identity.inbound_rule_set_created,
+ )
+ FakeInbound.setup_error = APIError(ErrorType.PROVIDER_ERROR, "AWS is down")
+
+ with pytest.raises(APIError):
+ await _enable(db_session, connection, identity)
+
+ after = (
+ identity.inbound_rule_name,
+ identity.inbound_rule_set,
+ identity.inbound_rule_set_created,
+ )
+ assert after == before
+ assert identity.receives_mail is True
+
+
# ---------------------------------------------------------------- teardown ---
From eccf6d1331d5678b68aa095428a81a9f8ccc8b0f Mon Sep 17 00:00:00 2001
From: Otitodev <198799600+Otitodev@users.noreply.github.com>
Date: Mon, 5 Oct 2026 08:45:38 +0100
Subject: [PATCH 02/10] refactor(api): share the download code between /v1 and
the dashboard
The dashboard inbox needs the same downloads as /v1, with session auth in place
of an API key. Both serve the same hostile thing from the same origin, so the
part that makes that safe is written once: the header set, the filename
handling, the order of refusal, and building the response.
A header added to one surface and forgotten on the other is the failure this
prevents, and it would look like nothing until somebody pointed a browser at the
wrong route. The previous change in this series was that exact shape - a policy
set on a route and silently discarded - so this is not a hypothetical.
What stays in the routes is what genuinely differs: how a caller is authenticated
and how a refusal is shown. An API key and a JSON envelope on one side, a session
and a page on the other.
No behaviour change. The regenerated OpenAPI document is identical, and the
download tests are unchanged apart from where they import the shared names from.
---
apps/api/src/seskit_api/downloads.py | 134 +++++++++++++++++++
apps/api/src/seskit_api/routes/v1/inbound.py | 110 +++------------
tests/test_v1_inbound_downloads.py | 2 +-
3 files changed, 150 insertions(+), 96 deletions(-)
create mode 100644 apps/api/src/seskit_api/downloads.py
diff --git a/apps/api/src/seskit_api/downloads.py b/apps/api/src/seskit_api/downloads.py
new file mode 100644
index 0000000..7fedcad
--- /dev/null
+++ b/apps/api/src/seskit_api/downloads.py
@@ -0,0 +1,134 @@
+"""Serving a stranger's bytes: shared by ``/v1`` and the dashboard.
+
+Both surfaces let a signed-in person fetch an attachment or the original of a
+message somebody sent to them, and both are serving the same hostile thing from the
+same origin. Written once so the two cannot drift - a header added to one and
+forgotten on the other is exactly the failure this exists to prevent, and it would
+look like nothing at all until somebody pointed a browser at the wrong route.
+
+Whatever the sender declared, the response is a download and not a document. A
+browser pointed at one must not render it, sniff it, run script in it, or keep it.
+
+What is here is the part that is the same: the headers, the filename, the order of
+refusal, and building the response. What is *not* here is how a caller is
+authenticated or how a failure is shown - an API key and a JSON envelope on one
+side, a session and a page on the other - because those are what differ.
+"""
+
+from __future__ import annotations
+
+import re
+from urllib.parse import quote
+
+from fastapi import Response
+from seskit_core.config import Settings
+from seskit_core.email.parse import read_attachment
+from seskit_core.errors import APIError, ErrorType
+from seskit_core.models import InboundEmail
+from seskit_core.models.base import utcnow
+from seskit_core.services import InboundStoreFactory, get_connection, stored_credentials
+from seskit_provider_aws_ses import EXPIRED_MESSAGE
+from sqlalchemy.ext.asyncio import AsyncSession
+
+#: Applied to every download.
+#:
+#: ``nosniff`` stops a browser deciding the bytes are HTML because they look like
+#: it. The CSP ``sandbox`` is what is left if one renders it anyway: no script, no
+#: forms, no same-origin access to anything. ``no-store`` because this is somebody's
+#: private mail and should not sit in a shared cache.
+DOWNLOAD_HEADERS = {
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy": "sandbox; default-src 'none'",
+ "Cache-Control": "private, no-store",
+}
+
+NO_SUCH_ATTACHMENT = "That message has no attachment with that index."
+
+_NOT_SAFE = re.compile(r"[^A-Za-z0-9._ -]")
+
+
+def content_disposition(filename: str) -> str:
+ """A ``Content-Disposition`` that is always a download, for any filename.
+
+ Two forms, because old clients read the plain one and current ones read the
+ encoded one. The plain form is reduced to characters that cannot end the
+ quoted string or start a new header; the encoded form carries the name with
+ everything outside the unreserved set percent-encoded.
+
+ Both are built from the same basename, so neither can carry a path or a name
+ that is only dots, and an empty name becomes ``attachment`` in both. A name
+ that came through the parser already has all of that - this does not rely on
+ it, because it is the last thing between a sender's string and a header, and
+ a client saving the file should not have to sanitise what it was given.
+ """
+ base = filename.replace("\\", "/").rsplit("/", 1)[-1].strip(" .") or "attachment"
+ fallback = _NOT_SAFE.sub("_", base)
+ return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(base, safe='')}"
+
+
+def require_attachment(row: InboundEmail, index: int) -> None:
+ """Refuse an attachment that does not exist, before anything is fetched.
+
+ A request for an attachment the message does not have should not cost a
+ download of the message to find that out.
+ """
+ if index < 0 or index >= len(row.attachments):
+ raise APIError(ErrorType.NOT_FOUND, NO_SUCH_ATTACHMENT)
+
+
+async def fetch_original(
+ db: AsyncSession,
+ row: InboundEmail,
+ factory: InboundStoreFactory,
+ settings: Settings,
+) -> bytes:
+ """The stored message, or the right error for why not.
+
+ The retention date is checked before anything is fetched. After it the original
+ is gone or about to be, and an S3 call to confirm costs a round trip that can
+ only produce the same answer - so a request past it is refused at once, and one
+ with no known expiry is left to the fetch to settle.
+ """
+ if row.raw_expires_at is not None and row.raw_expires_at <= utcnow():
+ raise APIError(ErrorType.NOT_FOUND, EXPIRED_MESSAGE)
+
+ connection = await get_connection(db, row.project_id)
+ if connection is None or not connection.has_credentials:
+ raise APIError(
+ ErrorType.INVALID_REQUEST,
+ "This project is not connected to AWS, so the original message cannot be "
+ "fetched. Connect it on the AWS page.",
+ )
+
+ store = factory(
+ connection.region, stored_credentials(connection, secret_key=settings.SECRET_KEY)
+ )
+ return await store.fetch_message(bucket=row.storage_bucket, key=row.storage_key)
+
+
+def attachment_response(raw: bytes, index: int) -> Response:
+ """One attachment out of a stored message, as a download.
+
+ Always ``application/octet-stream`` however it was declared. The filename comes
+ from the stored message, not from anything the caller passed.
+ """
+ content = read_attachment(raw, index)
+ if content is None:
+ raise APIError(ErrorType.NOT_FOUND, NO_SUCH_ATTACHMENT)
+ return Response(
+ content=content.data,
+ media_type="application/octet-stream",
+ headers={**DOWNLOAD_HEADERS, "Content-Disposition": content_disposition(content.filename)},
+ )
+
+
+def raw_response(raw: bytes, inbound_id: str) -> Response:
+ """The original message as an ``.eml`` download."""
+ return Response(
+ content=raw,
+ media_type="message/rfc822",
+ headers={
+ **DOWNLOAD_HEADERS,
+ "Content-Disposition": content_disposition(f"{inbound_id}.eml"),
+ },
+ )
diff --git a/apps/api/src/seskit_api/routes/v1/inbound.py b/apps/api/src/seskit_api/routes/v1/inbound.py
index a406397..c202bb6 100644
--- a/apps/api/src/seskit_api/routes/v1/inbound.py
+++ b/apps/api/src/seskit_api/routes/v1/inbound.py
@@ -10,19 +10,14 @@
from __future__ import annotations
-import re
from typing import Annotated, Any
-from urllib.parse import quote
from fastapi import APIRouter, Depends, Path, Query, Response
from seskit_core.config import Settings
from seskit_core.db import get_session
-from seskit_core.email.parse import read_attachment
from seskit_core.errors import APIError, ErrorType
from seskit_core.models import InboundEmail
-from seskit_core.models.base import utcnow
-from seskit_core.services import InboundStoreFactory, get_connection, stored_credentials
-from seskit_provider_aws_ses import EXPIRED_MESSAGE
+from seskit_core.services import InboundStoreFactory
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -32,6 +27,12 @@
get_inbound_store_factory,
require_api_key,
)
+from seskit_api.downloads import (
+ attachment_response,
+ fetch_original,
+ raw_response,
+ require_attachment,
+)
from seskit_api.routes.v1.api_keys import API_RESPONSES, apply_rate_limit_headers
from seskit_api.schemas.inbound import (
InboundList,
@@ -164,22 +165,10 @@ async def list_inbound(
# ------------------------------------------------------------- downloads ---
#
-# Everything below serves bytes a stranger wrote, from the API's own origin. That
-# is the whole risk, and it is handled the same way for both routes: whatever the
-# sender declared, the response is a download and not a document. A browser that
-# is pointed at one must not render it, sniff it, run script in it, or keep it.
-
-#: Applied to every download.
-#:
-#: ``nosniff`` stops a browser deciding the bytes are HTML because they look like
-#: it. The CSP ``sandbox`` is what is left if one renders it anyway: no script, no
-#: forms, no same-origin access to anything. ``no-store`` because this is somebody's
-#: private mail and should not sit in a shared cache.
-DOWNLOAD_HEADERS = {
- "X-Content-Type-Options": "nosniff",
- "Content-Security-Policy": "sandbox; default-src 'none'",
- "Cache-Control": "private, no-store",
-}
+# Everything below serves bytes a stranger wrote, from the API's own origin. How
+# that is made safe lives in ``seskit_api.downloads``, shared with the dashboard so
+# the two cannot drift; what is here is only what is specific to ``/v1``: the API
+# key, the rate-limit headers, and how a refusal is reported.
_BINARY_OK: dict[int | str, dict[str, Any]] = {
200: {
@@ -188,57 +177,6 @@ async def list_inbound(
}
}
-_NOT_SAFE = re.compile(r"[^A-Za-z0-9._ -]")
-
-
-def content_disposition(filename: str) -> str:
- """A ``Content-Disposition`` that is always a download, for any filename.
-
- Two forms, because old clients read the plain one and current ones read the
- encoded one. The plain form is reduced to characters that cannot end the
- quoted string or start a new header; the encoded form carries the name with
- everything outside the unreserved set percent-encoded.
-
- Both are built from the same basename, so neither can carry a path or a name
- that is only dots, and an empty name becomes ``attachment`` in both. A name
- that came through the parser already has all of that - this does not rely on
- it, because it is the last thing between a sender's string and a header, and
- a client saving the file should not have to sanitise what it was given.
- """
- base = filename.replace("\\", "/").rsplit("/", 1)[-1].strip(" .") or "attachment"
- fallback = _NOT_SAFE.sub("_", base)
- return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(base, safe='')}"
-
-
-async def _original(
- db: AsyncSession,
- row: InboundEmail,
- factory: InboundStoreFactory,
- settings: Settings,
-) -> bytes:
- """The stored message, or the right error for why not.
-
- The retention date is checked before anything is fetched. After it the original
- is gone or about to be, and an S3 call to confirm costs a round trip that can
- only produce the same answer - so a request past it is refused at once, and one
- with no known expiry is left to the fetch to settle.
- """
- if row.raw_expires_at is not None and row.raw_expires_at <= utcnow():
- raise APIError(ErrorType.NOT_FOUND, EXPIRED_MESSAGE)
-
- connection = await get_connection(db, row.project_id)
- if connection is None or not connection.has_credentials:
- raise APIError(
- ErrorType.INVALID_REQUEST,
- "This project is not connected to AWS, so the original message cannot be "
- "fetched. Connect it on the AWS page.",
- )
-
- store = factory(
- connection.region, stored_credentials(connection, secret_key=settings.SECRET_KEY)
- )
- return await store.fetch_message(bucket=row.storage_bucket, key=row.storage_key)
-
@router.get(
"/inbound/{inbound_id}/attachments/{index}",
@@ -271,21 +209,9 @@ async def download_attachment(
row = await _owned(db, inbound_id, context.project.id)
if row is None:
raise APIError(ErrorType.NOT_FOUND, "No received email with that id.")
- if index >= len(row.attachments):
- # Before the fetch: a request for an attachment that does not exist should
- # not cost a download of the message to find that out.
- raise APIError(ErrorType.NOT_FOUND, "That message has no attachment with that index.")
-
- raw = await _original(db, row, factory, settings)
- content = read_attachment(raw, index)
- if content is None:
- raise APIError(ErrorType.NOT_FOUND, "That message has no attachment with that index.")
-
- out = Response(
- content=content.data,
- media_type="application/octet-stream",
- headers={**DOWNLOAD_HEADERS, "Content-Disposition": content_disposition(content.filename)},
- )
+ require_attachment(row, index)
+
+ out = attachment_response(await fetch_original(db, row, factory, settings), index)
apply_rate_limit_headers(out, context)
return out
@@ -322,12 +248,6 @@ async def download_raw(
if row is None:
raise APIError(ErrorType.NOT_FOUND, "No received email with that id.")
- raw = await _original(db, row, factory, settings)
-
- out = Response(
- content=raw,
- media_type="message/rfc822",
- headers={**DOWNLOAD_HEADERS, "Content-Disposition": content_disposition(f"{row.id}.eml")},
- )
+ out = raw_response(await fetch_original(db, row, factory, settings), row.id)
apply_rate_limit_headers(out, context)
return out
diff --git a/tests/test_v1_inbound_downloads.py b/tests/test_v1_inbound_downloads.py
index 6274de5..8cac634 100644
--- a/tests/test_v1_inbound_downloads.py
+++ b/tests/test_v1_inbound_downloads.py
@@ -20,7 +20,7 @@
from fakes.inbound import EXPIRED, FakeInboundStore
from fakes.ses import FAKE_CREDENTIALS, connect_project
from httpx import AsyncClient
-from seskit_api.routes.v1.inbound import DOWNLOAD_HEADERS, content_disposition
+from seskit_api.downloads import DOWNLOAD_HEADERS, content_disposition
from seskit_core.errors import APIError, ErrorType
from seskit_core.models import InboundEmail
from sqlalchemy.ext.asyncio import AsyncSession
From 448bad13cd7dd452ee25e3ef72f29ac823f866c9 Mon Sep 17 00:00:00 2001
From: Otitodev <198799600+Otitodev@users.noreply.github.com>
Date: Mon, 5 Oct 2026 08:51:04 +0100
Subject: [PATCH 03/10] feat(ui): turn receiving on and off from the Domains
page
A verified domain's card gains a Receiving row and a panel to start it. Starting
creates the bucket, topic and queue in the user's AWS account and adds the
domain's rule; the card then shows the MX record to add, with a Copy on each value
like the DNS records, and a Stop button.
Before it is pressed the card says exactly what it will create in the user's own
account and that SESKit never replaces a rule set they already have - the same
rule the event reporting panel follows, because both put resources somewhere that
then appears on somebody's bill. The confirmation names them again.
The warning that matters most is about the MX record, and it is on the card and
not in the docs. An MX record sends all of a domain's mail to Amazon SES, so a
domain that receives mail somewhere else today stops receiving it there. The card
says so and points at a subdomain, which works because the rule is per identity:
add mail.example.com as its own domain.
Every reason receiving is unavailable is stated and none is hidden - an
unverified domain, no AWS connection, a domain in a different region from the
connection, a region SES cannot receive in, an instance reading events over HTTPS
only, a domain another project already receives for. A button that is simply absent
leaves somebody wondering whether the feature exists. The refusal for the last one
does not say whose domain it is.
The server refuses for every reason the page disables the button for. The
button is a courtesy and a form can be posted by hand; the same check runs again
in the route, and tests post to a blocked domain directly.
The most important test is a regression guard from earlier in this series. The
claim on a domain is written before AWS is called, and these routes do not roll
back on an error, so an AWS refusal would have rendered "Receiving is on" beside
the reason it was not. The test makes AWS refuse and asserts on the HTML the user is
shown. A refused stop leaves it on and says why, for the converse reason: AWS first,
the row second.
The page is rendered offline against the real templates in all three states
before the tests were written - on, available, blocked with the button actually
disabled - which caught nothing, and is how it is known the card still renders for
callers that never pass the new variable. Removing a receiving domain and
disconnecting AWS are tested through their real routes, so a user pressing either
cannot strand a rule in their account.
---
apps/api/src/seskit_api/receiving.py | 136 ++++++
apps/api/src/seskit_api/routes/domains.py | 176 +++++++-
.../templates/partials/identity_row.html | 16 +
.../templates/partials/receiving.html | 86 ++++
tests/fakes/inbound.py | 15 +
tests/test_domains_receiving.py | 398 ++++++++++++++++++
tests/test_receiving_view.py | 215 ++++++++++
7 files changed, 1040 insertions(+), 2 deletions(-)
create mode 100644 apps/api/src/seskit_api/receiving.py
create mode 100644 apps/api/src/seskit_api/templates/partials/receiving.html
create mode 100644 tests/test_domains_receiving.py
create mode 100644 tests/test_receiving_view.py
diff --git a/apps/api/src/seskit_api/receiving.py b/apps/api/src/seskit_api/receiving.py
new file mode 100644
index 0000000..a90931c
--- /dev/null
+++ b/apps/api/src/seskit_api/receiving.py
@@ -0,0 +1,136 @@
+"""What the Domains page shows about receiving mail for one domain.
+
+A view model and nothing more: it decides whether receiving can be turned on for a
+domain *right now*, and if not, says why in words a person can act on. The page
+renders it, and the route that turns receiving on asks it again before doing
+anything - the disabled button is a courtesy, and this is the check, because a
+form can be posted by hand.
+
+**Every reason is stated rather than hidden.** A button that is simply absent leaves
+somebody wondering whether the feature exists. Each of these is a precondition they
+can fix, and the page says which.
+
+The MX record is built here and not in a template so that a test can assert on it.
+It is the one thing a user has to do by hand, outside SESKit, and a record with the
+wrong host would send their mail nowhere without an error anywhere.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from seskit_core.config import Settings
+from seskit_core.models import AWSConnection, Identity
+from seskit_provider_aws_ses import supports_receiving
+
+#: SES takes mail at the lowest-preference host. One host, so one value; the number
+#: only matters when a domain lists several, and nobody should be listing others
+#: beside this one.
+MX_PRIORITY = 10
+
+#: Where Amazon SES takes mail in a region. Published by AWS in the General
+#: Reference's "Email Receiving endpoints" table, one per receiving region.
+MX_HOST = "inbound-smtp.{region}.amazonaws.com"
+
+
+@dataclass(frozen=True, slots=True)
+class MxRecord:
+ """The record to add at the user's DNS provider."""
+
+ name: str
+ priority: int
+ value: str
+
+ @property
+ def zone_line(self) -> str:
+ """As one line, for a provider that takes a zone file."""
+ return f"{self.name} MX {self.priority} {self.value}"
+
+
+@dataclass(frozen=True, slots=True)
+class ReceivingView:
+ """Receiving for one domain, as the page needs it."""
+
+ enabled: bool
+ #: Why it cannot be turned on right now, or ``None`` if it can. Always ``None``
+ #: when it is already on - a running thing is not blocked, and the controls
+ #: that matter then are the ones to stop it.
+ blocked: str | None
+ mx: MxRecord
+ retention_days: int
+
+
+def mx_record(identity: Identity) -> MxRecord:
+ return MxRecord(
+ name=identity.value,
+ priority=MX_PRIORITY,
+ value=MX_HOST.format(region=identity.region),
+ )
+
+
+def blocked_reason(
+ identity: Identity,
+ connection: AWSConnection | None,
+ settings: Settings,
+ *,
+ taken_elsewhere: bool,
+) -> str | None:
+ """The first thing in the way of turning receiving on, in the order a person
+ would fix them, or ``None`` if nothing is.
+ """
+ if not identity.is_verified:
+ return "Verify this domain first. Receiving needs a domain Amazon SES knows is yours."
+
+ if connection is None or not connection.is_connected or not connection.has_credentials:
+ return "Connect AWS first, on the AWS page."
+
+ if identity.region != connection.region:
+ return (
+ f"This domain is verified in {identity.region}, but this project is connected to "
+ f"{connection.region}. Mail is received in the connection's region."
+ )
+
+ if not supports_receiving(identity.region):
+ return (
+ f"Amazon SES cannot receive mail in {identity.region}. It can in regions such as "
+ "us-east-1, eu-west-1 and ap-southeast-2."
+ )
+
+ if not settings.polls_sqs:
+ return (
+ "Receiving needs this instance to read from SQS, and EVENT_INGESTION is set to "
+ "https. Set it to sqs or both."
+ )
+
+ if taken_elsewhere:
+ return (
+ "This domain already receives mail for another project or region. A domain's MX "
+ "record can only point at one place."
+ )
+
+ return None
+
+
+def receiving_view(
+ identity: Identity,
+ connection: AWSConnection | None,
+ settings: Settings,
+ *,
+ taken_elsewhere: bool = False,
+) -> ReceivingView | None:
+ """``None`` for something that cannot receive at all - an email address has no
+ MX record to point anywhere - so the page shows nothing rather than a control
+ that could never work.
+ """
+ if not identity.is_domain:
+ return None
+
+ enabled = identity.receives_mail
+ return ReceivingView(
+ enabled=enabled,
+ blocked=None
+ if enabled
+ else blocked_reason(identity, connection, settings, taken_elsewhere=taken_elsewhere),
+ mx=mx_record(identity),
+ retention_days=settings.INBOUND_RETENTION_DAYS,
+ )
diff --git a/apps/api/src/seskit_api/routes/domains.py b/apps/api/src/seskit_api/routes/domains.py
index 931ffbe..ddb085b 100644
--- a/apps/api/src/seskit_api/routes/domains.py
+++ b/apps/api/src/seskit_api/routes/domains.py
@@ -21,7 +21,7 @@
from seskit_core.db import get_session
from seskit_core.errors import APIError, ErrorType
from seskit_core.logging import get_logger
-from seskit_core.models import Project
+from seskit_core.models import AWSConnection, Identity, Project
from seskit_core.redis import get_redis
from seskit_core.services import (
InboundProvisionerFactory,
@@ -33,7 +33,10 @@
list_projects,
refresh_identity,
remove_identity,
+ setup_receiving,
+ teardown_receiving,
)
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from seskit_api.dependencies import (
@@ -45,6 +48,7 @@
require_user,
verify_csrf,
)
+from seskit_api.receiving import ReceivingView, receiving_view
from seskit_api.templating import render
logger = get_logger(__name__)
@@ -68,6 +72,7 @@ async def _page(
status_code: int = 200,
) -> HTMLResponse:
connection = await get_connection(db, project.id)
+ identities = await list_identities(db, project.id)
return render(
request,
"pages/domains.html",
@@ -78,11 +83,57 @@ async def _page(
project=project,
projects=await list_projects(db, current.user.id),
connection=connection,
- identities=await list_identities(db, project.id),
+ identities=identities,
+ receiving=await _receiving_views(request, db, project, connection, identities),
error=error,
)
+async def _receiving_views(
+ request: Request,
+ db: AsyncSession,
+ project: Project,
+ connection: AWSConnection | None,
+ identities: list[Identity],
+) -> dict[str, ReceivingView]:
+ """Receiving for each domain, keyed by identity id.
+
+ Computed here, once, so every handler that re-renders the page - add, refresh,
+ remove, and the two below - shows the same answer without each having to ask.
+ """
+ settings: Settings = request.app.state.settings
+ taken = await _receiving_elsewhere(db, project.id, [i.value for i in identities if i.is_domain])
+ views: dict[str, ReceivingView] = {}
+ for identity in identities:
+ view = receiving_view(
+ identity,
+ connection,
+ settings,
+ taken_elsewhere=identity.value in taken,
+ )
+ if view is not None:
+ views[identity.id] = view
+ return views
+
+
+async def _receiving_elsewhere(db: AsyncSession, project_id: str, values: list[str]) -> set[str]:
+ """Which of these domains already receive mail through another project.
+
+ The database refuses a second receiver for a domain outright; this is so the
+ page can say so before somebody presses a button that cannot work.
+ """
+ if not values:
+ return set()
+ rows = await db.scalars(
+ select(Identity.value).where(
+ Identity.value.in_(values),
+ Identity.inbound_rule_name.is_not(None),
+ Identity.project_id != project_id,
+ )
+ )
+ return set(rows)
+
+
@router.get("/domains", response_class=HTMLResponse, summary="Sending identities")
async def domains_page(
request: Request,
@@ -241,3 +292,124 @@ def _status_for(error: APIError) -> int:
if error.error_type in {ErrorType.AUTHORIZATION_FAILED, ErrorType.AUTHENTICATION_FAILED}:
return 400
return error.status_code
+
+
+@router.post(
+ "/domains/{identity_id}/receiving",
+ response_class=HTMLResponse,
+ dependencies=[Depends(verify_csrf)],
+ summary="Start receiving mail for a domain",
+)
+async def start_receiving(
+ request: Request,
+ identity_id: str,
+ db: Annotated[AsyncSession, Depends(get_session)],
+ current: Annotated[CurrentUser, Depends(require_user)],
+ project: Annotated[Project, Depends(require_project)],
+ inbound: Annotated[InboundProvisionerFactory, Depends(get_inbound_provisioner_factory)],
+ settings: Annotated[Settings, Depends(get_app_settings)],
+) -> HTMLResponse:
+ """Create the receiving plumbing in the user's AWS account and add this
+ domain's rule.
+
+ Refuses on the server for every reason the page shows a disabled button for:
+ the button is a courtesy, and a form can be posted by hand.
+
+ No rollback on an error, as on the AWS page. A rollback expires every loaded
+ object and the template then lazy-loads from inside sync Jinja, so the user
+ sees a 500 instead of the message. ``setup_receiving`` gives the domain back
+ itself when AWS refuses, which is what makes that safe.
+ """
+ identity = await get_owned_identity(db, identity_id=identity_id, project_id=project.id)
+ if identity is None:
+ return await _page(request, db, current, project)
+
+ connection = await get_connection(db, project.id)
+ taken = await _receiving_elsewhere(db, project.id, [identity.value])
+ view = receiving_view(identity, connection, settings, taken_elsewhere=identity.value in taken)
+ if view is None:
+ return await _page(
+ request,
+ db,
+ current,
+ project,
+ error="Only a domain can receive mail.",
+ status_code=400,
+ )
+ if view.blocked and connection is not None:
+ return await _page(request, db, current, project, error=view.blocked, status_code=400)
+ if connection is None:
+ return await _page(
+ request, db, current, project, error=NO_CONNECTION_MESSAGE, status_code=400
+ )
+
+ try:
+ await setup_receiving(
+ db,
+ inbound,
+ connection,
+ identity,
+ resource_prefix=settings.EVENT_RESOURCE_PREFIX,
+ retention_days=settings.INBOUND_RETENTION_DAYS,
+ secret_key=settings.SECRET_KEY,
+ )
+ except APIError as error:
+ return await _page(
+ request, db, current, project, error=error.message, status_code=_status_for(error)
+ )
+
+ await db.commit()
+ return await _page(
+ request,
+ db,
+ current,
+ project,
+ flash=(
+ f"Receiving is on for {identity.value}. SESKit created a bucket, a topic, a queue "
+ "and one receipt rule. Add the MX record below to start receiving."
+ ),
+ )
+
+
+@router.post(
+ "/domains/{identity_id}/receiving/stop",
+ response_class=HTMLResponse,
+ dependencies=[Depends(verify_csrf)],
+ summary="Stop receiving mail for a domain",
+)
+async def stop_receiving(
+ request: Request,
+ identity_id: str,
+ db: Annotated[AsyncSession, Depends(get_session)],
+ current: Annotated[CurrentUser, Depends(require_user)],
+ project: Annotated[Project, Depends(require_project)],
+ inbound: Annotated[InboundProvisionerFactory, Depends(get_inbound_provisioner_factory)],
+ settings: Annotated[Settings, Depends(get_app_settings)],
+) -> HTMLResponse:
+ """Remove this domain's receipt rule, and the plumbing if nothing else uses it.
+
+ Mail already received is kept. Stopping receiving is not deleting what arrived.
+ """
+ identity = await get_owned_identity(db, identity_id=identity_id, project_id=project.id)
+ connection = await get_connection(db, project.id)
+ if identity is None or connection is None:
+ return await _page(request, db, current, project)
+
+ try:
+ await teardown_receiving(db, inbound, connection, identity, secret_key=settings.SECRET_KEY)
+ except APIError as error:
+ return await _page(
+ request, db, current, project, error=error.message, status_code=_status_for(error)
+ )
+
+ await db.commit()
+ return await _page(
+ request,
+ db,
+ current,
+ project,
+ flash=(
+ f"Stopped receiving for {identity.value}. Mail already received is kept. "
+ "Remember to remove the MX record."
+ ),
+ )
diff --git a/apps/api/src/seskit_api/templates/partials/identity_row.html b/apps/api/src/seskit_api/templates/partials/identity_row.html
index afd172e..6c56037 100644
--- a/apps/api/src/seskit_api/templates/partials/identity_row.html
+++ b/apps/api/src/seskit_api/templates/partials/identity_row.html
@@ -47,6 +47,18 @@
Region
{{ identity.region }}
+ {% if receiving is defined and receiving.get(identity.id) %}
+
@@ -81,6 +93,10 @@
{% endif %}
{% endif %}
+ {% if receiving is defined and receiving.get(identity.id) %}
+ {% include "partials/receiving.html" %}
+ {% endif %}
+
' in page.text
+ assert f'href="/domains/{identity.id}/receiving' not in page.text
+
+
+async def test_an_unverified_domain_has_a_disabled_button_and_a_reason(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ await _connect(app_client)
+ await _domain(db_session, await _project(db_session), verified=False)
+
+ page = await app_client.get("/domains")
+
+ assert "Verify this domain first" in page.text
+ button = page.text[
+ page.text.index("Start receiving") - 120 : page.text.index("Start receiving")
+ ]
+ assert "disabled" in button
+
+
+async def test_an_email_address_has_no_receiving_row(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ await _connect(app_client)
+ await _domain(db_session, await _project(db_session), "me@example.com", kind="email_address")
+
+ page = await app_client.get("/domains")
+
+ assert "Receiving" not in page.text
+ assert "Start receiving" not in page.text
+
+
+# ----------------------------------------------------------------- starting ---
+
+
+async def test_starting_shows_the_mx_record_to_add(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ token = await _connect(app_client)
+ identity = await _domain(db_session, await _project(db_session))
+
+ page = await app_client.post(_start(identity), data={"csrf_token": token})
+
+ assert page.status_code == 200
+ assert "Receiving is on" in page.text
+ assert MX_VALUE in page.text
+ assert f'data-copy="{MX_VALUE}"' in page.text
+ assert "10" in page.text
+ # And the warning that matters most about an MX record.
+ assert "replaces wherever the domain receives mail now" in page.text
+ assert f"mail.{DOMAIN}" in page.text
+ assert FakeInboundProvisioner.calls == ["provision", "add_rule"]
+ assert (await _fresh(db_session, identity)).receives_mail is True
+
+
+async def test_starting_names_what_was_created(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ """Because it is in the user's own account and now on their bill."""
+ token = await _connect(app_client)
+ identity = await _domain(db_session, await _project(db_session))
+
+ page = await app_client.post(_start(identity), data={"csrf_token": token})
+
+ assert "a bucket, a topic, a queue" in page.text
+ assert "one receipt rule" in page.text
+
+
+async def test_an_aws_refusal_does_not_leave_the_page_saying_receiving_is_on(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ """The regression guard. The claim on the domain is written before AWS is
+ called, and these routes do not roll back on an error - so without the service
+ giving the domain back, this page would render "Receiving is on" beside the
+ reason it is not. Checked in the HTML the user is shown, which is the only place
+ it can be seen.
+ """
+ token = await _connect(app_client)
+ identity = await _domain(db_session, await _project(db_session))
+ FakeInboundProvisioner.error = APIError(
+ ErrorType.AUTHORIZATION_FAILED,
+ "The AWS identity is not permitted to call s3:CreateBucket. Add it to its IAM policy.",
+ )
+
+ page = await app_client.post(_start(identity), data={"csrf_token": token})
+
+ assert page.status_code == 403
+ assert "not permitted to call s3:CreateBucket" in page.text
+ assert "Receiving is on" not in page.text
+ assert "Start receiving" in page.text
+ assert (await _fresh(db_session, identity)).receives_mail is False
+
+
+async def test_a_post_without_a_csrf_token_creates_nothing(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ await _connect(app_client)
+ identity = await _domain(db_session, await _project(db_session))
+
+ response = await app_client.post(_start(identity), data={})
+
+ assert response.status_code == 403
+ assert FakeInboundProvisioner.calls == []
+ assert (await _fresh(db_session, identity)).receives_mail is False
+
+
+async def test_the_server_refuses_what_the_disabled_button_refuses(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ """A form can be posted by hand. The button being disabled protects nobody."""
+ token = await _connect(app_client)
+ identity = await _domain(db_session, await _project(db_session), verified=False)
+
+ page = await app_client.post(_start(identity), data={"csrf_token": token})
+
+ assert page.status_code == 400
+ assert "Verify this domain first" in page.text
+ assert FakeInboundProvisioner.calls == []
+ assert (await _fresh(db_session, identity)).receives_mail is False
+
+
+async def test_a_region_that_cannot_receive_is_refused_on_the_server(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ token = await _connect(app_client)
+ project = await _project(db_session)
+ connection = await db_session.scalar(select(AWSConnection))
+ assert connection is not None
+ connection.region = "ap-south-2"
+ identity = await _domain(db_session, project, region="ap-south-2")
+ await db_session.flush()
+
+ page = await app_client.post(_start(identity), data={"csrf_token": token})
+
+ assert page.status_code == 400
+ assert "cannot receive mail in ap-south-2" in page.text
+ assert FakeInboundProvisioner.calls == []
+
+
+async def test_an_email_address_cannot_be_made_to_receive(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ token = await _connect(app_client)
+ identity = await _domain(
+ db_session, await _project(db_session), "me@example.com", kind="email_address"
+ )
+
+ page = await app_client.post(_start(identity), data={"csrf_token": token})
+
+ assert page.status_code == 400
+ assert "Only a domain can receive mail" in page.text
+ assert FakeInboundProvisioner.calls == []
+
+
+async def test_a_domain_already_receiving_for_another_project_is_refused(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ token = await _connect(app_client)
+ mine = await _domain(db_session, await _project(db_session))
+ other_user = await register_user(
+ db_session, email="other@example.com", password="correct-horse-battery", allow_signup=True
+ )
+ other = await create_project(db_session, user_id=other_user.id, name="Other")
+ await _domain(db_session, other, rule="seskit-theirs")
+
+ page = await app_client.post(_start(mine), data={"csrf_token": token})
+
+ assert page.status_code == 400
+ assert "already receives mail for another project or region" in page.text
+ assert "other@example.com" not in page.text
+ assert "Other" not in page.text.replace("Otherwise", "")
+ assert FakeInboundProvisioner.calls == []
+ assert (await _fresh(db_session, mine)).receives_mail is False
+
+
+async def test_somebody_elses_domain_cannot_be_started(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ """An id from another project resolves to nothing, not to somebody's domain."""
+ token = await _connect(app_client)
+ await _domain(db_session, await _project(db_session), "mine.example")
+ other_user = await register_user(
+ db_session, email="other@example.com", password="correct-horse-battery", allow_signup=True
+ )
+ other = await create_project(db_session, user_id=other_user.id, name="Other")
+ theirs = await _domain(db_session, other, "theirs.example")
+
+ page = await app_client.post(_start(theirs), data={"csrf_token": token})
+
+ assert page.status_code == 200
+ assert FakeInboundProvisioner.calls == []
+ assert (await _fresh(db_session, theirs)).receives_mail is False
+ assert "theirs.example" not in page.text
+
+
+# ----------------------------------------------------------------- stopping ---
+
+
+async def test_stopping_removes_the_rule_and_says_what_is_kept(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ token = await _connect(app_client)
+ identity = await _domain(db_session, await _project(db_session))
+ await app_client.post(_start(identity), data={"csrf_token": token})
+ FakeInboundProvisioner.calls = []
+
+ page = await app_client.post(_stop(identity), data={"csrf_token": token})
+
+ assert page.status_code == 200
+ assert FakeInboundProvisioner.calls == ["remove_rule", "remove"]
+ assert "Mail already received is kept" in page.text
+ assert "remove the MX record" in page.text
+ assert "Receiving is on" not in page.text
+ assert "Start receiving" in page.text
+ assert (await _fresh(db_session, identity)).receives_mail is False
+
+
+async def test_a_refused_stop_leaves_it_on_and_says_why(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ """AWS first, the row second. If the rule cannot be removed it is still there,
+ and a page that said it had stopped would be wrong about somebody's account.
+ """
+ token = await _connect(app_client)
+ identity = await _domain(db_session, await _project(db_session))
+ await app_client.post(_start(identity), data={"csrf_token": token})
+ FakeInboundProvisioner.remove_error = APIError(
+ ErrorType.AUTHORIZATION_FAILED, "not permitted to call ses:DeleteReceiptRule"
+ )
+
+ page = await app_client.post(_stop(identity), data={"csrf_token": token})
+
+ assert page.status_code == 403
+ assert "not permitted to call ses:DeleteReceiptRule" in page.text
+ assert "Receiving is on" in page.text
+ assert (await _fresh(db_session, identity)).receives_mail is True
+
+
+async def test_stopping_somebody_elses_domain_does_nothing(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ token = await _connect(app_client)
+ await _domain(db_session, await _project(db_session), "mine.example")
+ other_user = await register_user(
+ db_session, email="other@example.com", password="correct-horse-battery", allow_signup=True
+ )
+ other = await create_project(db_session, user_id=other_user.id, name="Other")
+ theirs = await _domain(db_session, other, "theirs.example", rule="seskit-theirs")
+
+ await app_client.post(_stop(theirs), data={"csrf_token": token})
+
+ assert FakeInboundProvisioner.calls == []
+ assert (await _fresh(db_session, theirs)).receives_mail is True
+
+
+# --------------------------------------------- what else removes a receiver ---
+
+
+async def test_removing_a_receiving_domain_removes_its_rule_first(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ """Through the real route, not just the service: a user pressing Remove on a
+ domain that receives mail must not strand a rule in their AWS account.
+ """
+ token = await _connect(app_client)
+ identity = await _domain(db_session, await _project(db_session))
+ await app_client.post(_start(identity), data={"csrf_token": token})
+ FakeInboundProvisioner.calls = []
+
+ page = await app_client.post(f"/domains/{identity.id}/delete", data={"csrf_token": token})
+
+ assert page.status_code == 200
+ assert "remove_rule" in FakeInboundProvisioner.calls
+ assert await db_session.scalar(select(Identity).where(Identity.id == identity.id)) is None
+
+
+async def test_disconnecting_aws_removes_the_receipt_rules_first(
+ app_client: AsyncClient, db_session: AsyncSession
+) -> None:
+ """Once the connection is gone there is no key left to remove them with."""
+ token = await _connect(app_client)
+ identity = await _domain(db_session, await _project(db_session))
+ await app_client.post(_start(identity), data={"csrf_token": token})
+ FakeInboundProvisioner.calls = []
+
+ page = await app_client.post("/aws/disconnect", data={"csrf_token": token})
+
+ assert page.status_code == 200
+ assert "remove_rule" in FakeInboundProvisioner.calls
+ assert (await _fresh(db_session, identity)).receives_mail is False
diff --git a/tests/test_receiving_view.py b/tests/test_receiving_view.py
new file mode 100644
index 0000000..a0f0aee
--- /dev/null
+++ b/tests/test_receiving_view.py
@@ -0,0 +1,215 @@
+"""What the Domains page says about receiving mail (inbound email, Phase D).
+
+The view model decides whether receiving can be turned on for a domain right now,
+and says why in words a person can act on when it cannot. The route that turns it
+on asks it again before doing anything - the disabled button is a courtesy and
+this is the check, since a form can be posted by hand - so what is asserted here is
+the contract both rely on. None of it needs a database.
+
+The MX record is worth its own tests. It is the one thing a user has to do by hand,
+outside SESKit, and one with the wrong host would send their mail nowhere without
+an error anywhere.
+"""
+
+from __future__ import annotations
+
+import pytest
+from seskit_api.receiving import MX_PRIORITY, mx_record, receiving_view
+from seskit_core.config import Settings
+from seskit_core.models import AWSConnection, ConnectionStatus, Identity
+from seskit_provider_aws_ses import RECEIVING_REGION_CODES
+
+REGION = "us-east-1"
+
+
+def _settings(**overrides: str) -> Settings:
+ fields = {
+ "SECRET_KEY": "a-real-secret",
+ "DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/db",
+ "REDIS_URL": "redis://localhost:6379/0",
+ "SMTP_HOST": None,
+ "EMAILS_FROM_EMAIL": None,
+ }
+ return Settings(_env_file=None, **(fields | overrides)) # type: ignore[arg-type]
+
+
+def _identity(
+ value: str = "example.com",
+ *,
+ kind: str = "domain",
+ verified: bool = True,
+ region: str = REGION,
+ rule: str | None = None,
+) -> Identity:
+ return Identity(
+ id="dom_1",
+ project_id="proj_1",
+ identity_type=kind,
+ value=value,
+ region=region,
+ verification_status="success" if verified else "pending",
+ dkim_tokens=[],
+ inbound_rule_name=rule,
+ )
+
+
+def _connection(
+ *, region: str = REGION, connected: bool = True, keyed: bool = True
+) -> AWSConnection:
+ return AWSConnection(
+ project_id="proj_1",
+ aws_account_id="123456789012",
+ region=region,
+ status=(ConnectionStatus.CONNECTED if connected else ConnectionStatus.ERROR).value,
+ aws_access_key_id="AKIAEXAMPLE" if keyed else None,
+ aws_secret_access_key_encrypted="enc" if keyed else None,
+ )
+
+
+def _blocked(identity: Identity, connection: AWSConnection | None, **kwargs: object) -> str | None:
+ settings = kwargs.pop("settings", None) or _settings()
+ view = receiving_view(identity, connection, settings, **kwargs) # type: ignore[arg-type]
+ assert view is not None
+ return view.blocked
+
+
+# ----------------------------------------------------------- the MX record ---
+
+
+def test_the_mx_record_points_at_the_ses_endpoint_for_the_domains_own_region() -> None:
+ """Mail is received in the region the domain is verified in. A record naming
+ any other would deliver nowhere, silently.
+ """
+ record = mx_record(_identity(region="eu-west-1"))
+
+ assert record.name == "example.com"
+ assert record.value == "inbound-smtp.eu-west-1.amazonaws.com"
+ assert record.priority == MX_PRIORITY == 10
+ assert record.zone_line == "example.com MX 10 inbound-smtp.eu-west-1.amazonaws.com"
+
+
+@pytest.mark.parametrize("region", sorted(RECEIVING_REGION_CODES))
+def test_every_receiving_region_has_the_endpoint_ses_publishes(region: str) -> None:
+ assert mx_record(_identity(region=region)).value == f"inbound-smtp.{region}.amazonaws.com"
+
+
+# ---------------------------------------------------------- when it is on ---
+
+
+def test_a_domain_that_can_receive_is_not_blocked() -> None:
+ view = receiving_view(_identity(), _connection(), _settings())
+
+ assert view is not None
+ assert view.enabled is False
+ assert view.blocked is None
+ assert view.retention_days == 30
+
+
+def test_a_domain_already_receiving_is_never_blocked() -> None:
+ """A running thing is not "blocked", whatever has changed since. Reporting an
+ unsupported region over a working rule would hide the control that matters,
+ which is the one to stop it.
+ """
+ view = receiving_view(
+ _identity(rule="seskit-x", region="ap-south-2"),
+ _connection(connected=False),
+ _settings(),
+ taken_elsewhere=True,
+ )
+
+ assert view is not None
+ assert view.enabled is True
+ assert view.blocked is None
+
+
+def test_the_retention_shown_is_the_configured_one() -> None:
+ view = receiving_view(_identity(), _connection(), _settings(INBOUND_RETENTION_DAYS="45"))
+
+ assert view is not None
+ assert view.retention_days == 45
+
+
+def test_an_email_address_gets_no_receiving_panel_at_all() -> None:
+ """An address verifies a sender and has no MX record to point anywhere. Showing
+ a control that could never work would be a puzzle.
+ """
+ assert (
+ receiving_view(_identity("a@example.com", kind="email_address"), _connection(), _settings())
+ is None
+ )
+
+
+# ----------------------------------------------------- why it cannot be on ---
+
+
+def test_an_unverified_domain_is_told_to_verify_first() -> None:
+ reason = _blocked(_identity(verified=False), _connection())
+
+ assert reason is not None
+ assert "Verify this domain first" in reason
+
+
+@pytest.mark.parametrize(
+ "connection",
+ [None, _connection(connected=False), _connection(keyed=False)],
+ ids=["no connection", "errored connection", "no stored key"],
+)
+def test_a_project_that_cannot_reach_aws_is_told_to_connect(
+ connection: AWSConnection | None,
+) -> None:
+ reason = _blocked(_identity(), connection)
+
+ assert reason is not None
+ assert "Connect AWS first" in reason
+
+
+def test_a_domain_in_another_region_than_the_connection_says_both() -> None:
+ reason = _blocked(_identity(region="eu-west-1"), _connection(region="us-east-1"))
+
+ assert reason is not None
+ assert "eu-west-1" in reason
+ assert "us-east-1" in reason
+
+
+@pytest.mark.parametrize("region", ["ap-south-2", "ap-southeast-5", "eu-central-2", "ca-west-1"])
+def test_a_region_that_can_send_but_not_receive_says_so_and_offers_ones_that_can(
+ region: str,
+) -> None:
+ reason = _blocked(_identity(region=region), _connection(region=region))
+
+ assert reason is not None
+ assert f"cannot receive mail in {region}" in reason
+ assert "us-east-1" in reason
+
+
+def test_an_instance_that_only_takes_events_over_https_is_told_what_to_change() -> None:
+ settings = _settings(EVENT_INGESTION="https", PUBLIC_BASE_URL="https://seskit.example.com")
+
+ reason = _blocked(_identity(), _connection(), settings=settings)
+
+ assert reason is not None
+ assert "EVENT_INGESTION" in reason
+ assert "sqs" in reason
+
+
+def test_a_domain_receiving_for_somebody_else_says_a_domain_can_receive_in_one_place() -> None:
+ reason = _blocked(_identity(), _connection(), taken_elsewhere=True)
+
+ assert reason is not None
+ assert "another project or region" in reason
+ assert "MX" in reason
+
+
+def test_the_first_thing_in_the_way_is_the_one_reported() -> None:
+ """In the order a person would fix them: verification before connection before
+ region. Reporting the last problem first would send them to fix something that
+ was never the obstacle.
+ """
+ reason = _blocked(
+ _identity(verified=False, region="ap-south-2"),
+ None,
+ taken_elsewhere=True,
+ )
+
+ assert reason is not None
+ assert "Verify this domain first" in reason
From 4150be8ae394c66f377cff56040d0732a6f66b9a Mon Sep 17 00:00:00 2001
From: Otitodev <198799600+Otitodev@users.noreply.github.com>
Date: Mon, 5 Oct 2026 08:58:38 +0100
Subject: [PATCH 04/10] feat(ui): an Inbox for received mail
A new Inbox page lists what the project received, newest first, with a domain
filter that swaps the table over HTMX like the Emails page, and a detail page
for one message: who it is from and to, who it was actually delivered to, what
Amazon SES concluded, the text body, the HTML as source, the attachments, the
headers, and the original.
Everything on these pages was written by a stranger, and that is what most of
the tests are about. Each sender-controlled field - subject, display name, both
bodies, every address list, the message id, an attachment's filename, a header's
name and value - is given a script tag and an onerror handler, and the assertion
is that none reaches the response as markup. The page holds a signed-in person's
session; a stranger's HTML does not belong beside it. The HTML body is shown as
source here, and a test sends an iframe and a paragraph and asserts neither exists
in the page while both survive as visible text. The templates were rendered
offline with the same hostile input before any test was written.
A verdict SES did not give reads "Not checked" and is deliberately not styled
like a pass; a test asserts five of them on a message with none. The macro that
does this lives in ui.html with the other components. The page says plainly that
SESKit shows the verdicts and acts on none.
An attachment's declared content type is labelled "declared", because the
download is opaque bytes whatever it says. Downloads use the shared module, so
they carry the same headers as /v1, and a test asserts the sandbox CSP on the
response: the middleware keeps a route's own policy now, which is what an earlier
commit in this series had to fix to make this true.
A failed download lands on the message page and says why, instead of a bare JSON
envelope in a browser tab - the person clicked a link, and an error object tells
them nothing. A message past retention, a missing attachment index and a
permission failure each render a page, and the first two fetch nothing. As on the
other dashboard pages a permission problem answers 400, not 403: it is the user's
AWS configuration and not their session.
The id of another project's message and one that never existed give the same
answer on every route, so a stranger cannot probe for real ids. Older mail is
reached by cursor and not by offset, for the reason /v1/inbound is. The
raw_available rule moves onto the model so the API and the dashboard cannot
disagree about whether to offer a link.
---
apps/api/src/seskit_api/main.py | 2 +
apps/api/src/seskit_api/routes/inbox.py | 250 +++++++++
apps/api/src/seskit_api/schemas/inbound.py | 7 +-
apps/api/src/seskit_api/templates/base.html | 1 +
.../seskit_api/templates/components/ui.html | 20 +
.../src/seskit_api/templates/pages/inbox.html | 28 +
.../templates/pages/inbox_detail.html | 190 +++++++
.../templates/partials/inbox_table.html | 82 +++
.../src/seskit_core/models/inbound_email.py | 12 +-
.../core/src/seskit_core/services/__init__.py | 4 +
.../core/src/seskit_core/services/inbound.py | 51 +-
tests/test_inbox.py | 490 ++++++++++++++++++
12 files changed, 1129 insertions(+), 8 deletions(-)
create mode 100644 apps/api/src/seskit_api/routes/inbox.py
create mode 100644 apps/api/src/seskit_api/templates/pages/inbox.html
create mode 100644 apps/api/src/seskit_api/templates/pages/inbox_detail.html
create mode 100644 apps/api/src/seskit_api/templates/partials/inbox_table.html
create mode 100644 tests/test_inbox.py
diff --git a/apps/api/src/seskit_api/main.py b/apps/api/src/seskit_api/main.py
index 488ffc9..c881154 100644
--- a/apps/api/src/seskit_api/main.py
+++ b/apps/api/src/seskit_api/main.py
@@ -34,6 +34,7 @@
domains,
emails,
health,
+ inbox,
suppressions,
unsubscribe,
webhooks,
@@ -258,6 +259,7 @@ async def _unhandled(request: Request, exc: Exception) -> JSONResponse | Respons
app.include_router(aws.router)
app.include_router(domains.router)
app.include_router(emails.router)
+ app.include_router(inbox.router)
app.include_router(webhooks.router)
app.include_router(suppressions.router)
# Public and unauthenticated, unlike everything above it.
diff --git a/apps/api/src/seskit_api/routes/inbox.py b/apps/api/src/seskit_api/routes/inbox.py
new file mode 100644
index 0000000..9ddc75a
--- /dev/null
+++ b/apps/api/src/seskit_api/routes/inbox.py
@@ -0,0 +1,250 @@
+"""The Inbox: mail the project received, in the dashboard.
+
+Session-authenticated and out of the OpenAPI schema, like the rest of the
+dashboard. The read side only - a message is recorded by the worker as it arrives,
+and what can be done here is look at it, and fetch an attachment or the original.
+
+**Everything on these pages was written by somebody else.** Jinja escapes it, and
+there is deliberately no ``|safe`` anywhere near a value from a message. The HTML
+body is shown as source here; rendering it is a separate, sandboxed document so
+that the page a signed-in person is looking at never contains a stranger's markup.
+
+Ownership is part of every query. An id from another project resolves to the same
+answer as one that does not exist, so a stranger cannot probe for real ids.
+"""
+
+from __future__ import annotations
+
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, Request, Response
+from fastapi.responses import HTMLResponse
+from seskit_core.config import Settings
+from seskit_core.db import get_session
+from seskit_core.errors import APIError, ErrorType
+from seskit_core.models import InboundEmail, Project
+from seskit_core.services import (
+ InboundStoreFactory,
+ get_received,
+ list_identities,
+ list_projects,
+ list_received,
+)
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from seskit_api.dependencies import (
+ AuthenticationRequired,
+ CurrentUser,
+ get_app_settings,
+ get_inbound_store_factory,
+ require_project,
+ require_user,
+)
+from seskit_api.downloads import (
+ attachment_response,
+ fetch_original,
+ raw_response,
+ require_attachment,
+)
+from seskit_api.templating import render
+
+router = APIRouter(tags=["inbox"], include_in_schema=False)
+
+#: Rows on a page. A cap and not a suggestion, for the reason the Emails page has
+#: one: without it a project with a year of mail renders all of it into a template.
+PAGE_SIZE = 50
+
+
+def _status_for(error: APIError) -> int:
+ """HTTP status for a page that could not complete a download.
+
+ As on the Domains and AWS pages: a credential or permission problem is the
+ user's AWS configuration, not their SESKit session, and answering 401 or 403
+ would read as "you are not signed in".
+ """
+ if error.error_type in {ErrorType.AUTHORIZATION_FAILED, ErrorType.AUTHENTICATION_FAILED}:
+ return 400
+ return error.status_code
+
+
+async def _receiving_domains(db: AsyncSession, project_id: str) -> list[str]:
+ """The domains this project receives for, for the filter and the empty state."""
+ return [i.value for i in await list_identities(db, project_id) if i.receives_mail]
+
+
+async def _list_page(
+ request: Request,
+ db: AsyncSession,
+ current: CurrentUser,
+ project: Project,
+ *,
+ domain: str | None,
+ before: str | None,
+ template: str,
+) -> HTMLResponse:
+ domains = await _receiving_domains(db, project.id)
+ # An unrecognised domain narrows to nothing rather than raising: a stale or
+ # hand-edited URL should render the page it was clearly asking for.
+ selected = domain if domain in domains else None
+ rows, has_more = await list_received(
+ db, project.id, domain=selected, before=before, limit=PAGE_SIZE
+ )
+ return render(
+ request,
+ template,
+ current=current,
+ nav_active="inbox",
+ project=project,
+ projects=await list_projects(db, current.user.id),
+ messages=rows,
+ has_more=has_more,
+ older=rows[-1].id if has_more and rows else None,
+ domains=domains,
+ selected_domain=selected,
+ )
+
+
+@router.get("/inbox", response_class=HTMLResponse, summary="Received email")
+async def inbox_page(
+ request: Request,
+ db: Annotated[AsyncSession, Depends(get_session)],
+ current: Annotated[CurrentUser, Depends(require_user)],
+ project: Annotated[Project, Depends(require_project)],
+ domain: str | None = None,
+ before: str | None = None,
+) -> HTMLResponse:
+ """The project's received mail, newest first."""
+ return await _list_page(
+ request, db, current, project, domain=domain, before=before, template="pages/inbox.html"
+ )
+
+
+@router.get("/partials/inbox", response_class=HTMLResponse, summary="Inbox table fragment")
+async def inbox_partial(
+ request: Request,
+ db: Annotated[AsyncSession, Depends(get_session)],
+ current: Annotated[CurrentUser, Depends(require_user)],
+ project: Annotated[Project, Depends(require_project)],
+ domain: str | None = None,
+ before: str | None = None,
+) -> HTMLResponse:
+ """The table on its own, for the filter's HTMX swap. Authenticated like the
+ page it belongs to - this is a project's mail.
+ """
+ return await _list_page(
+ request,
+ db,
+ current,
+ project,
+ domain=domain,
+ before=before,
+ template="partials/inbox_table.html",
+ )
+
+
+async def _detail_page(
+ request: Request,
+ db: AsyncSession,
+ current: CurrentUser,
+ project: Project,
+ message: InboundEmail,
+ *,
+ error: str | None = None,
+ status_code: int = 200,
+) -> HTMLResponse:
+ """One renderer for the detail page, so a failed download lands on the page
+ the person was looking at and says why, instead of a bare JSON error in a tab.
+ """
+ return render(
+ request,
+ "pages/inbox_detail.html",
+ status_code=status_code,
+ current=current,
+ nav_active="inbox",
+ project=project,
+ projects=await list_projects(db, current.user.id),
+ message=message,
+ error=error,
+ )
+
+
+async def _owned(db: AsyncSession, project: Project, inbound_id: str) -> InboundEmail:
+ message = await get_received(db, project.id, inbound_id)
+ if message is None:
+ # The dashboard's own convention for "not yours or not there" - the same
+ # answer either way, so a stranger cannot probe for real ids.
+ raise AuthenticationRequired("/inbox")
+ return message
+
+
+@router.get("/inbox/{inbound_id}", response_class=HTMLResponse, summary="One received email")
+async def inbox_detail(
+ request: Request,
+ inbound_id: str,
+ db: Annotated[AsyncSession, Depends(get_session)],
+ current: Annotated[CurrentUser, Depends(require_user)],
+ project: Annotated[Project, Depends(require_project)],
+) -> HTMLResponse:
+ """One message in full."""
+ message = await _owned(db, project, inbound_id)
+ return await _detail_page(request, db, current, project, message)
+
+
+@router.get("/inbox/{inbound_id}/attachments/{index}", summary="Download an attachment")
+async def inbox_attachment(
+ request: Request,
+ inbound_id: str,
+ index: int,
+ db: Annotated[AsyncSession, Depends(get_session)],
+ current: Annotated[CurrentUser, Depends(require_user)],
+ project: Annotated[Project, Depends(require_project)],
+ settings: Annotated[Settings, Depends(get_app_settings)],
+ factory: Annotated[InboundStoreFactory, Depends(get_inbound_store_factory)],
+) -> Response:
+ """One attachment, as a download.
+
+ A GET, because it changes nothing. A failure re-renders the message with the
+ reason rather than answering with a JSON envelope the browser would show raw.
+ """
+ message = await _owned(db, project, inbound_id)
+ try:
+ require_attachment(message, index)
+ raw = await fetch_original(db, message, factory, settings)
+ return attachment_response(raw, index)
+ except APIError as error:
+ return await _detail_page(
+ request,
+ db,
+ current,
+ project,
+ message,
+ error=error.message,
+ status_code=_status_for(error),
+ )
+
+
+@router.get("/inbox/{inbound_id}/raw", summary="Download the original message")
+async def inbox_raw(
+ request: Request,
+ inbound_id: str,
+ db: Annotated[AsyncSession, Depends(get_session)],
+ current: Annotated[CurrentUser, Depends(require_user)],
+ project: Annotated[Project, Depends(require_project)],
+ settings: Annotated[Settings, Depends(get_app_settings)],
+ factory: Annotated[InboundStoreFactory, Depends(get_inbound_store_factory)],
+) -> Response:
+ """The original message as an ``.eml`` download."""
+ message = await _owned(db, project, inbound_id)
+ try:
+ raw = await fetch_original(db, message, factory, settings)
+ except APIError as error:
+ return await _detail_page(
+ request,
+ db,
+ current,
+ project,
+ message,
+ error=error.message,
+ status_code=_status_for(error),
+ )
+ return raw_response(raw, message.id)
diff --git a/apps/api/src/seskit_api/schemas/inbound.py b/apps/api/src/seskit_api/schemas/inbound.py
index 242bcf8..4833eef 100644
--- a/apps/api/src/seskit_api/schemas/inbound.py
+++ b/apps/api/src/seskit_api/schemas/inbound.py
@@ -22,7 +22,6 @@
from pydantic import BaseModel, Field
from seskit_core.models import InboundEmail
-from seskit_core.models.base import utcnow
class InboundVerdicts(BaseModel):
@@ -213,10 +212,6 @@ class InboundList(BaseModel):
)
-def _raw_available(row: InboundEmail) -> bool:
- return row.raw_expires_at is None or row.raw_expires_at > utcnow()
-
-
def summary_of(row: InboundEmail) -> InboundSummary:
return InboundSummary(
id=row.id,
@@ -239,7 +234,7 @@ def summary_of(row: InboundEmail) -> InboundSummary:
),
truncated=row.truncated,
parse_failed=row.parse_failed,
- raw_available=_raw_available(row),
+ raw_available=row.raw_available,
raw_expires_at=row.raw_expires_at,
)
diff --git a/apps/api/src/seskit_api/templates/base.html b/apps/api/src/seskit_api/templates/base.html
index 6ff7876..da9bc4c 100644
--- a/apps/api/src/seskit_api/templates/base.html
+++ b/apps/api/src/seskit_api/templates/base.html
@@ -70,6 +70,7 @@
{%- endmacro %}
+{# What Amazon SES concluded about a received message.
+
+ Verbatim states, never a judgement: SESKit shows these and acts on none. A verdict
+ that was not given is "Not checked" and is deliberately not styled like a pass -
+ reading an unchecked message as a clean one is the one mistake this exists to
+ prevent. #}
+{% macro verdict(status) -%}
+ {%- if status == "PASS" -%}
+ {{ badge("Pass", tone="success") }}
+ {%- elif status == "FAIL" -%}
+ {{ badge("Fail", tone="danger") }}
+ {%- elif status == "GRAY" -%}
+ {{ badge("Inconclusive", tone="warning") }}
+ {%- elif status == "PROCESSING_FAILED" -%}
+ {{ badge("Could not check", tone="warning") }}
+ {%- else -%}
+ Not checked
+ {%- endif -%}
+{%- endmacro %}
+
{# A single value with its own Copy. For a table of things that each go
into a different field somewhere else - DNS records, mostly - where one
Copy for the whole block still leaves the user picking the lines apart. #}
diff --git a/apps/api/src/seskit_api/templates/pages/inbox.html b/apps/api/src/seskit_api/templates/pages/inbox.html
new file mode 100644
index 0000000..7f07791
--- /dev/null
+++ b/apps/api/src/seskit_api/templates/pages/inbox.html
@@ -0,0 +1,28 @@
+{% extends "base.html" %}
+{% from "components/ui.html" import empty %}
+
+{% block title %}Inbox · SESKit{% endblock %}
+
+{% block content %}
+
+
Inbox
+
+ Mail sent to {{ project.name }}'s domains, newest first.
+
+
+
+ {% if domains or messages %}
+ {% include "partials/inbox_table.html" %}
+ {% else %}
+ {# Its own state. A project that has never turned receiving on has nothing to
+ wait for, and saying "no mail yet" would hide the one thing it has to do. #}
+ {{ empty(
+ "Not receiving mail yet",
+ "Turn receiving on for a verified domain and mail sent to it appears here, and
+ your application is told as it arrives.",
+ icon_name="inbox",
+ action_label="Go to Domains",
+ action_href="/domains",
+ ) }}
+ {% endif %}
+{% endblock %}
diff --git a/apps/api/src/seskit_api/templates/pages/inbox_detail.html b/apps/api/src/seskit_api/templates/pages/inbox_detail.html
new file mode 100644
index 0000000..bc40a11
--- /dev/null
+++ b/apps/api/src/seskit_api/templates/pages/inbox_detail.html
@@ -0,0 +1,190 @@
+{% extends "base.html" %}
+{% from "components/ui.html" import card_open, card_close, badge, button, code_block, verdict %}
+
+{% block title %}{{ message.subject or "(no subject)" }} · SESKit{% endblock %}
+
+{# Everything about this message was written by somebody else. It is escaped by
+ Jinja and there is no |safe anywhere in this file. The HTML body is shown as
+ source and never rendered into this page: a signed-in person's session is the
+ most valuable thing here, and a stranger's markup does not belong next to it. #}
+
+{% block content %}
+
+ Everything below is empty. The original is still stored
+ {% if message.raw_available %}and can be downloaded{% else %}but has passed its retention period{% endif %}.
+
+
+ {% elif message.truncated %}
+
+
Part of this message was left out
+
+ It hit a limit while being read - a very long body, or a very large number of
+ parts or headers. The original has all of it.
+
+
+ {% endif %}
+
+ {{ card_open("Message") }}
+
+
+
From
+
+ {% if message.from_name %}{{ message.from_name }} {% endif %}{{ message.from_address or "unknown sender" }}
+
+
+
+
To
+
{{ message.to_addresses | join(", ") or "—" }}
+
+ {% if message.cc_addresses %}
+
+
Cc
+
{{ message.cc_addresses | join(", ") }}
+
+ {% endif %}
+ {% if message.reply_to %}
+
+
Reply-To
+
{{ message.reply_to | join(", ") }}
+
+ {% endif %}
+
+ {# Who it was addressed to on the wire - the address that received it. It
+ can be one the headers never mention, such as a blind copy. #}
+
+ {{ verdict(message.dmarc_verdict) }}
+ {% if message.dmarc_policy %}sender asks receivers to {{ message.dmarc_policy }}{% endif %}
+
+
+
Spam
{{ verdict(message.spam_verdict) }}
+
Virus
{{ verdict(message.virus_verdict) }}
+
+
+ Amazon SES checked these. SESKit shows them and acts on none: whether to trust a
+ message is your decision. Not checked means SES did not say, which is
+ not the same as a pass.
+
+ {{ attachment.filename }}
+ {# What the sender *declared*, labelled as such. It is not checked, and
+ a download is always served as opaque bytes whatever it says. #}
+
+ declared {{ attachment.content_type }} · {{ (attachment.size / 1024) | round(1) }} KB{% if attachment.inline %} · inline{% endif %}
+
+ {% if message.raw_available %}
+ {# A plain link: it changes nothing, and a download is what it is for. #}
+ Download
+ {% endif %}
+
+ {% endfor %}
+
+ {% if not message.raw_available %}
+
+ The original has passed its retention period, so attachments can no longer be
+ downloaded.
+
+ The message exactly as Amazon SES received it, every header and every part.
+ {% if message.raw_expires_at %}
+ It is removed after {{ message.raw_expires_at.strftime("%Y-%m-%d") }}; the parsed
+ message above stays.
+ {% endif %}
+
+ The original was removed when its retention period passed. The parsed message
+ above stays readable.
+
+ {% endif %}
+ {{ card_close() }}
+
+ {% if message.headers %}
+ {{ card_open("Headers", collapsible=True, open=False, meta=message.headers | length ~ " headers") }}
+ {%- set lines = [] %}
+ {%- for header in message.headers %}
+ {%- set _ = lines.append(header[0] ~ ": " ~ header[1]) %}
+ {%- endfor %}
+ {{ code_block(lines | join("
+"), language="headers") }}
+ {{ card_close(collapsible=True) }}
+ {% endif %}
+{% endblock %}
diff --git a/apps/api/src/seskit_api/templates/partials/inbox_table.html b/apps/api/src/seskit_api/templates/partials/inbox_table.html
new file mode 100644
index 0000000..8a70d6f
--- /dev/null
+++ b/apps/api/src/seskit_api/templates/partials/inbox_table.html
@@ -0,0 +1,82 @@
+{# The Inbox table and its domain filter.
+
+ A partial so the filter can swap just this fragment over HTMX rather than
+ reloading, the pattern the Emails page uses. The filter is links, not buttons:
+ a filtered list is a view, and a view should be linkable and survive a refresh.
+ Without JavaScript they are ordinary navigation that still works.
+
+ Every value from a message below is somebody else's. It is escaped, and there
+ is no |safe anywhere in this file. #}
+
+
+ {% else %}
+ {# Receiving is on and nothing has come in. The likeliest reason is the one
+ thing SESKit cannot check, so it says what it can: where mail would go. #}
+
+
Waiting for the first message
+
+ Mail sent to {{ domains | join(", ") }} appears here once the MX record is in
+ your DNS. The Domains page shows the record to add.
+
+
+ {% endif %}
+
diff --git a/packages/core/src/seskit_core/models/inbound_email.py b/packages/core/src/seskit_core/models/inbound_email.py
index 13fce1b..0b09505 100644
--- a/packages/core/src/seskit_core/models/inbound_email.py
+++ b/packages/core/src/seskit_core/models/inbound_email.py
@@ -46,7 +46,7 @@
from seskit_core.db import Base
from seskit_core.ids import IDPrefix, generate_id
-from seskit_core.models.base import TimestampMixin
+from seskit_core.models.base import TimestampMixin, utcnow
if TYPE_CHECKING:
from seskit_core.models.project import Project
@@ -184,6 +184,16 @@ class InboundEmail(Base, TimestampMixin):
project: Mapped[Project] = relationship(back_populates="inbound_emails")
+ @property
+ def raw_available(self) -> bool:
+ """Whether the original is expected to still be in storage.
+
+ True when the expiry is unknown, because unknown is not gone: the download
+ settles it. The one definition, so the API and the dashboard cannot
+ disagree about whether a link should be offered.
+ """
+ return self.raw_expires_at is None or self.raw_expires_at > utcnow()
+
@property
def has_attachments(self) -> bool:
return bool(self.attachments)
diff --git a/packages/core/src/seskit_core/services/__init__.py b/packages/core/src/seskit_core/services/__init__.py
index f3557ef..49e4e90 100644
--- a/packages/core/src/seskit_core/services/__init__.py
+++ b/packages/core/src/seskit_core/services/__init__.py
@@ -63,6 +63,8 @@
bucket_name_for,
count_other_receivers,
distinct_inbound_queues,
+ get_received,
+ list_received,
rule_name_for,
setup_receiving,
teardown_all_receiving,
@@ -186,6 +188,7 @@
"get_owned_endpoint",
"get_owned_identity",
"get_owned_project",
+ "get_received",
"get_user_by_email",
"get_user_by_id",
"identities_due",
@@ -198,6 +201,7 @@
"list_events",
"list_identities",
"list_projects",
+ "list_received",
"list_suppressions",
"payload_bytes",
"pending_delivery_ids",
diff --git a/packages/core/src/seskit_core/services/inbound.py b/packages/core/src/seskit_core/services/inbound.py
index af82ad9..19b0e87 100644
--- a/packages/core/src/seskit_core/services/inbound.py
+++ b/packages/core/src/seskit_core/services/inbound.py
@@ -41,7 +41,7 @@
from seskit_core.errors import APIError, ErrorType
from seskit_core.logging import get_logger
-from seskit_core.models import AWSConnection, Identity
+from seskit_core.models import AWSConnection, Identity, InboundEmail
from seskit_core.providers import (
AWSCredentials,
InboundInfrastructure,
@@ -495,3 +495,52 @@ async def distinct_inbound_queues(session: AsyncSession, *, secret_key: str) ->
)
for (region, url), creds in credentials.items()
]
+
+
+# --------------------------------------------------------------------- reading ---
+
+
+async def get_received(
+ session: AsyncSession, project_id: str, inbound_id: str
+) -> InboundEmail | None:
+ """One received message, if it belongs to this project.
+
+ Ownership is part of the query, so an id from another project is
+ indistinguishable from one that never existed.
+ """
+ found: InboundEmail | None = await session.scalar(
+ select(InboundEmail).where(
+ InboundEmail.id == inbound_id, InboundEmail.project_id == project_id
+ )
+ )
+ return found
+
+
+async def list_received(
+ session: AsyncSession,
+ project_id: str,
+ *,
+ domain: str | None = None,
+ before: str | None = None,
+ limit: int = 50,
+) -> tuple[list[InboundEmail], bool]:
+ """A page of received mail, newest first, and whether there is another page.
+
+ By cursor and not by offset, for the reason ``/v1/inbound`` is: a message
+ arriving between two pages shifts every row of an offset down one and the
+ reader skips the one that moved across the boundary. Ids sort in the order they
+ were created, so ``before`` names a fixed point.
+
+ One more than asked for is read, so "is there another page?" is answered by the
+ query that fetched this one. A ``before`` that is not an id of this project
+ simply matches by comparison and returns what is older than it - the comparison
+ is lexical, but everything returned is still scoped to the project.
+ """
+ query = select(InboundEmail).where(InboundEmail.project_id == project_id)
+ if domain is not None:
+ query = query.where(InboundEmail.domain == domain)
+ if before is not None:
+ query = query.where(InboundEmail.id < before)
+
+ rows = list(await session.scalars(query.order_by(InboundEmail.id.desc()).limit(limit + 1)))
+ return rows[:limit], len(rows) > limit
diff --git a/tests/test_inbox.py b/tests/test_inbox.py
new file mode 100644
index 0000000..168a9bf
--- /dev/null
+++ b/tests/test_inbox.py
@@ -0,0 +1,490 @@
+"""The Inbox, over HTTP (inbound email, Phase D).
+
+What a signed-in person sees of mail somebody sent to them. The thing worth
+asserting over and over is the same: **everything on these pages was written by a
+stranger.** Every sender-controlled field is fed a script tag and the assertion is
+that it is never in the response as markup - the page a signed-in person looks at
+holds their session, and a stranger's HTML does not belong next to it.
+
+The second thing is that a failed download lands on the page the person was looking
+at and says why, instead of a bare JSON envelope in a browser tab.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+import pytest
+from fakes.inbound import FakeInboundStore
+from httpx import AsyncClient
+from seskit_core.models import InboundEmail, Project
+from seskit_core.services import create_project, register_user
+from sqlalchemy.ext.asyncio import AsyncSession
+from test_domains_page import _connect, _sign_in
+from test_domains_receiving import _domain, _project
+from test_v1_inbound import _message
+from test_v1_inbound_downloads import ATTACHMENTS, HTML_ATTACHMENT, PDF, _raw
+
+HOSTILE = ""
+
+
+async def _seed(session: AsyncSession, project: Project, **overrides: Any) -> InboundEmail:
+ return await _message(session, project_id=project.id, **overrides)
+
+
+async def _signed_in_with_project(
+ client: AsyncClient, session: AsyncSession
+) -> tuple[str, Project]:
+ token = await _connect(client)
+ return token, await _project(session)
+
+
+def _never_as_markup(text: str) -> None:
+ assert "