Skip to content

Check and record quotas for sends and local delivery - #50

Merged
markmnl merged 1 commit into
mainfrom
quota-usage
Oct 10, 2026
Merged

markmnl merged 1 commit into
mainfrom
quota-usage

Conversation

@markmnl

@markmnl markmnl commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Why

Mail between local addresses never passes through fmsgd: this service delivers it itself (resolveLocalDelivery). It checked only whether each recipient exists and accepts new messages, and recorded no usage. Nothing checked or recorded send limits either. This PR makes this service count everything fmsgd doesn't. The companion fmsgd PR (markmnl/fmsgd#50) makes fmsgd count attachments and use the accept time.

What

Local delivery (send, react, add-to on a sent message): each local recipient gets fmsgd's checks.

  • Unknown is 100 and not accepting is 102, as before.
  • New: if one more message of the message's stored size would exceed the recipient's messages per day, bytes per day or total bytes received, it gets 101 (user full).
  • A refused recipient isn't delivered (time_delivered stays null), and its code is recorded in response_code, so to_delivery/add_to show 101 the way fmsgd records rejected recipients.
  • Each delivered recipient is recorded with POST /fmsgid/recv, at the delivery time. Usage is recorded only when this request's update resolved the row, so a row that was already resolved is never counted twice.

Sending (POST /fmsg/:id/send, POST /fmsg/:id/react): before sending, the sender's current limits and usage are fetched from fmsgid. The message is refused, and left a draft, if it is:

Limit Status code error
bigger than the per-message send size 413 send_size_per_msg_limit message size N exceeds the sending limit of M bytes per message
over the messages sent per day 429 send_count_per_1d_limit daily message limit reached
over the bytes sent per day 429 send_size_per_1d_limit daily sending size limit reached
over the total bytes sent 422 send_size_total_limit storage limit reached
  • After the send commits, the message is recorded once with POST /fmsgid/send, however many recipients it has.
  • If fmsgid is unavailable, the send is refused with 503 instead of going out unchecked.
  • A sender fmsgid no longer knows, or that has stopped accepting new messages, gets 403, the same answer authentication gives.

Rules chosen:

  • Sizes are the stored size: body plus attachments, as fmsgd now records them.
  • Usage time is when this service sent or delivered the message.
  • A reaction is a message like any other. It counts against the reactor's send limits and each local recipient's receive limits. Reactions are federated as ordinary messages that other hosts count on receipt, so exempting them here would be inconsistent, and the rule stays simple.
  • Add-to doesn't send a new message, so it isn't counted against the caller's send limits. Its new local recipients are checked and counted on receipt like any recipient.
  • Quota pools need nothing here: fmsgid answers with the pool owner's limits and the pool's summed usage.
  • Failures to record usage are logged and never fail the request, since the message has already been sent.

Cache. The existing 30s lookup cache (CheckFmsgID) holds only whether an address exists and accepts new messages, for authentication. Quota checks use the new FetchFmsgIDDetail, which bypasses the cache every time, because usage changes with every message. As a side effect, a fresh detail also refreshes the cached lookup. The cost is one extra fmsgid GET per send and per local recipient, the same as fmsgd's per-recipient lookup.

Docs

The README has a new Quotas section, and the send, add-to and react sections list the new behaviour and errors.

Tests

  • Unit: quota_test.go covers recipient codes and every receive and send limit's boundaries and ordering. fmsgid_usage_test.go covers that the detail is never served from the cache, that missing limits default to unlimited, invalid responses, the usage payloads and a failed record.
  • PostgreSQL integration (quota_integration_test.go, run against a throwaway Postgres 17 with FMSG_TEST_DATABASE_URL):
    • local recipients over the count or storage limit get 101 and aren't delivered, an unknown one gets 100, and a remote one is left to fmsgd;
    • to_delivery shows 101;
    • recv and send usage are recorded once each, with the body-plus-attachment size and the send time;
    • add-to checks and counts its recipients but records no send;
    • reactions are counted;
    • each send limit refuses with its status and code, leaves a draft and records nothing;
    • a reaction over quota isn't stored;
    • fmsgid down gives 503.
  • go vet ./..., gofmt clean, and go test ./... pass, with and without the database.

Deploy

No schema change. It needs an fmsgid whose GET /fmsgid/:address returns limits and usage, which every fmsgid does; the counter fixes in markmnl/fmsgid#6 make those numbers right. Deploy after fmsgid and fmsgd.

Known gap (unchanged, follow-up)

The inbox (GET /fmsg and friends) lists every message an address is a recipient of, delivered or not. So a recipient refused with 101 (here, or by fmsgd for mail with another local recipient) still sees the message, though it isn't delivered, pushed or counted.

🤖 Generated with Claude Code

Mail between local addresses never passes through fmsgd, so it was
neither checked against the recipients' receive limits nor recorded,
and nothing checked or recorded send limits.

Local delivery now makes fmsgd's checks for each local recipient: one
more message of the message's stored size (body plus attachments) must
fit its messages per day, bytes per day and total bytes received. A
recipient over a limit isn't delivered and gets response code 101 (user
full), as fmsgd records it, alongside 100 and 102 as before. Each
delivered recipient is recorded with POST /fmsgid/recv.

Send and react check the sender's per-message size, messages per day,
bytes per day and total bytes sent before sending, refusing with 413,
429 or 422 and a code naming the limit, and record the message once with
POST /fmsgid/send. A reaction is a message like any other. Add-to
doesn't send a new message, so it isn't counted as a send, but its new
local recipients are checked and counted.

The checks fetch fmsgid's detail fresh each time instead of using the
30s lookup cache, since usage changes with every message. Failing to
record usage is logged and doesn't fail the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@markmnl
markmnl merged commit b3aa088 into main Oct 10, 2026
1 check passed
@markmnl
markmnl deleted the quota-usage branch October 10, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant