Files and notifications¶
Two subsystems built the same way as data providers: one interface, several backends, chosen in configuration rather than in code.
File storage¶
A FileField or ImageField on a resource is all it takes. The column records
a reference — storage key, original name, size, type, checksum — and the
bytes go to whichever backend is configured.
# connections.yaml
connections:
files:
type: files
backend: local # or s3
root: var/files
limits:
max_mb: 25
Moving to S3, MinIO, R2, Spaces or B2 is the same three lines with a different
backend:; they all speak one API, so only the endpoint differs.
files:
type: files
backend: s3
bucket: ${S3_BUCKET}
endpoint_url: ${S3_ENDPOINT:-} # set for MinIO, R2, Spaces
prefix: crm
signed_urls: true # serve from the bucket, not this app
Credentials are omitted deliberately where an instance role or the environment
can supply them. aioboto3 is imported lazily, so a deployment on local disk
never installs an AWS SDK — and a missing one produces a sentence saying what
to install rather than an ImportError at startup.
More than one store¶
Stores are named, and a field may say which one holds its column:
That is what a column populated by another system needs — a key in somebody else's bucket, attached read-only. Without it the key is looked up in the default store, which does not have it, and the image renders broken.
What the framework enforces¶
- A size limit applied while streaming. The point of a limit is to stop reading, not to discover afterwards that too much was read.
- Executable extensions are always refused —
.exe,.sh,.php, and also.htmland.svg, which would otherwise run as a page in this application's own origin. - Names are reduced before use.
../../etc/passwdbecomespasswd; the key is generated anyway, so the submitted name is decorative. - Keys cannot escape the root. Every local path is resolved and checked against the storage root before it is touched.
- A partial upload leaves nothing behind. Written to a temporary name and moved into place, so an interrupted upload never leaves a half-written file at the real key.
- A replaced file is deleted only after the record is saved. Deleting first would lose the old file if the write then failed.
Serving¶
Files are served through /files/{key} so an attachment is exactly as private
as the record it belongs to — an unguessable key is not the same as a protected
one. Documents download; images and PDFs display inline; nosniff and a
private cache are set on every response.
A backend able to issue signed URLs bypasses this route, which is the whole reason to use one.
Notifications¶
A notification is a stored record; delivering it anywhere else is a separate step. That split is the design: the in-app bell reads the row, so it always works, and a failing SMTP server loses nothing.
from app.notify import Notification, Kind, notifier
await notifier.send(
Notification(
recipient="sam@example.com",
kind=Kind.ASSIGNED,
title="You have been assigned the Acme renewal",
resource="deals",
record_id="7",
actor=ctx.identity.email, # so Sam is not told about their own action
),
ctx,
)
Channels¶
| Channel | Notes |
|---|---|
inapp |
The bell. The stored row is the delivery, so it cannot fail. |
email |
SMTP. Defaults to high and above — a CRM that emails everything gets filtered into a folder nobody reads. |
webhook |
Slack, Teams, or anything accepting a POST. style: slack or raw. |
console |
The log. For development, and a fallback worth having. |
Adding a channel is a decorator, exactly like a provider or a file backend.
When delivery happens¶
Storing is synchronous; delivering is not, and where it runs is a setting:
CRM_NOTIFY_DELIVERY |
Delivery | Survives a restart? |
|---|---|---|
background (default) |
a task on this worker's event loop | no — in-flight ones are lost |
queue |
a row, drained by crm worker |
yes |
inline |
before the request returns | n/a — what tests and CLI commands want |
background is immediate and costs nothing; in-flight deliveries are awaited
at shutdown, but a worker killed outright takes them with it. The notification
row survives either way — it is written before delivery is attempted — so the
in-app bell never loses anything. Only the outbound copy is at risk, and
queue is how you stop risking it. See
Background jobs.
Warning
queue needs crm worker running somewhere. Without it, notifications are
stored and never delivered, and nothing errors to tell you so.
Reminders¶
A reminder is a notification with a future due_at. It is stored immediately
and stays invisible to the bell until its time comes:
Notification(recipient=..., kind=Kind.DUE, title="Follow up",
due_at=datetime.now() + timedelta(days=1))
Delivery is a sweep, run from cron or a scheduler:
A sweep rather than a timer, so the application holds no long-lived state and
several workers can run it safely. crm notify-test <recipient> checks the
channels are configured before you rely on them.
Rules the service applies¶
- Nobody is told about their own action. A notification you caused is noise,
so an
actormatching therecipientis dropped. - Stored first, delivered second. A channel failing never loses the notification.
- One failing channel does not stop the others, and each records its own outcome against the row.
- A notification belongs to one person, and nobody else can dismiss it.