Inbox Rules Engine
MediumAI-assisted coding · AI-Assisted Feature Work
Inbox Rules Engine
You've joined the Mail team. inbox is the server-side mailbox behind the web and mobile clients: it files incoming mail with user rules (filters) and serves the paged, newest-first message list. This sprint you have a pagination bug from support, then two features: rule priorities with "stop processing", and a conversation view with muting.
Codebase
| File | What it does |
|---|---|
inbox/config.py | Folder names, page size limit |
inbox/models.py | Message and Rule dataclasses |
inbox/textutil.py | Address normalisation, case-insensitive matching, subject_key |
inbox/conditions.py | Registry of rule conditions (from_contains, ...) and validation |
inbox/actions.py | Registry of rule actions (move, add_labels, ...) and validation |
inbox/rules.py | RuleBook (storage, validation) and RuleEngine (runs rules at delivery) |
inbox/ordering.py | Listing order and pagination cursors |
inbox/threads.py | Conversation view (empty placeholder, Part 3) |
inbox/mailbox.py | Mailbox: delivery, paging, unread counts, per-message state |
inbox/app.py | run(operations) dispatcher |
tests/ | Existing unit tests (happy paths only) |
How your code is run
run(operations: list) -> list creates an empty mailbox, executes each operation in order and returns one output per operation.
A delivered message is {"id": int, "from": str, "to": [str], "subject": str, "received_at": int, "has_attachment": bool} (to, subject, has_attachment optional). Addresses may be "Name <addr>"; matching uses the lower-cased address. Several messages can share a received_at second. New mail lands in "Inbox" before rules run.
| Operation | Arguments | Returns | Errors |
|---|---|---|---|
deliver | message | {"folder", "labels" (sorted), "read", "starred"} after rules | "error: invalid message", "error: duplicate message id" |
page | folder, limit, cursor (string or null) | {"ids": [...], "next_cursor": str or null} | "error: invalid limit" (outside 1..100), "error: invalid cursor" |
unread_count | folder | int | |
mark_read | message_id, read | true, or false if no such message | |
move | message_id, folder | true, or false if no such message | |
add_rule | rule | new rule id "r1", "r2", ... | "error: ..." (see below) |
delete_rule | rule_id | true, or false if no such rule | |
explain | message_id | ids of the rules that ran on it at delivery, in run order | "error: unknown message" |
update_rule, threads, mute_thread | Parts 2-3 |
Unknown operations return "error: unknown operation: <name>".
Rules (existing behaviour). A rule is {"name": str (optional), "when": {...}, "then": {...}}. All conditions in when must match: from_contains, to_contains (any recipient), subject_contains (case-insensitive substrings, non-empty strings) and has_attachment (bool). Actions in then: move (folder name), add_labels (non-empty list of strings), mark_read: true, star: true. Every matching rule runs, in creation order; labels accumulate and when several rules move a message the last one wins. Rules only run at delivery. Validation errors: "error: unknown rule field: <key>", "error: unknown condition: <key>", "error: unknown action: <key>", "error: invalid value for <key>", "error: rule needs at least one condition", "error: rule needs at least one action".
Listing order (every listing): newest received_at first; messages with the same received_at are ordered by id descending (compared as integers). page returns up to limit messages of the folder in that order. next_cursor is "<received_at>:<id>" of the last message on the page, or null when nothing follows it. Passing a cursor back returns the messages that come strictly after that position in listing order; the position stays valid even if that message has since been moved.
Worked example (T = 1767225600):
[["add_rule", {"name": "receipts", "when": {"subject_contains": "receipt"}, "then": {"move": "Receipts", "add_labels": ["finance"]}}],
["deliver", {"id": 1, "from": "Store <orders@shop.example>", "to": ["me@example.com"], "subject": "Your receipt", "received_at": 1767225600}],
["deliver", {"id": 2, "from": "bob@example.com", "to": ["me@example.com"], "subject": "Lunch?", "received_at": 1767225660}],
["deliver", {"id": 3, "from": "carol@example.com", "to": ["me@example.com"], "subject": "Q3 plan", "received_at": 1767225720}],
["page", "Inbox", 10, null],
["unread_count", "Receipts"]]returns
["r1",
{"folder": "Receipts", "labels": ["finance"], "read": false, "starred": false},
{"folder": "Inbox", "labels": [], "read": false, "starred": false},
{"folder": "Inbox", "labels": [], "read": false, "starred": false},
{"ids": [3, 2], "next_cursor": null},
1]Part 1 — Scrolling shows some messages twice and skips others
Goal: make the listing order and cursor paging behave as specified above.
Support ticket: *"After a newsletter blast, everything arrived in the same second. When I scroll, a couple of messages show up twice, others never appear, and the order changes depending on when they were delivered."*
Example. Message 1 at T, messages 2, 3 and 4 at T+60, message 5 at T+120. Paging with limit 2: ["page", "Inbox", 2, null] → {"ids": [5, 4], "next_cursor": "1767225660:4"}, then ["page", "Inbox", 2, "1767225660:4"] → {"ids": [3, 2], "next_cursor": "1767225660:2"}, then ["page", "Inbox", 2, "1767225660:2"] → {"ids": [1], "next_cursor": null}.
Part 2 — Rule priorities and "stop processing more rules"
Goal: let users control the order rules run in and stop later rules from running.
Rules
- A rule may include
"priority"(integer, may be negative; default100). Rules run in ascending priority; equal priorities run in creation order (r2beforer10). A rule withoutprioritytherefore behaves exactly as today. - A rule may include
"stop"(boolean, defaultfalse). If a rule withstop: truematches, it applies its actions and no further rules run for that message. A stop rule that does not match has no effect. explainlists only the rules that actually ran.- A non-integer (or boolean)
priorityreturns"error: invalid value for priority"; a non-booleanstopreturns"error: invalid value for stop". - New operation
["update_rule", rule_id, changes]:changesholds any ofname,when,then,priority,stop; each given key replaces that field. The updated rule is validated with the same rules and messages asadd_rule; if invalid, the error is returned and the rule is unchanged. Unknownrule_idreturns"error: unknown rule". Returnstrue. Creation order never changes, and messages already delivered are not re-filed.
Example. r1 = from @corp.example → label work; r2 = subject urgent → star and move to Priority, priority: 10, stop: true; r3 = subject invoice → move to Finance. Delivering "URGENT: invoice overdue" from boss@corp.example returns {"folder": "Priority", "labels": [], "read": false, "starred": true} and explain gives ["r2"]. Delivering "Invoice for March" from billing@corp.example returns folder Finance with label work, and explain gives ["r1", "r3"].
Part 3 — Conversation view and mute (MAIL-221)
Goal: group a folder into conversations by subject and let users mute a conversation.
Rules
- A message's *thread key* is its subject with every leading reply/forward prefix removed, then whitespace collapsed and lower-cased. A prefix is
re,fworfwd(any case) followed by optional spaces and a colon; any number of prefixes may be chained, with optional whitespace before each one. Then every run of whitespace becomes one space, the result is trimmed and lower-cased."Re: RE: Fwd:Budget Q3"→"budget q3";"FW : budget q3"→"budget q3";"Reply: Budget"and"Meeting re: budget"keep their text (lower-cased). ["threads", folder, limit]groups the messages currently in that folder by thread key and returns up tolimitthreads (1..100, else"error: invalid limit"), each{"subject": key, "ids": [...], "unread": n}.idsare in listing order; threads are ordered by their first message in listing order.unreadcounts unread messages in the thread.- Messages whose thread key is empty (
"","Re:", whitespace...) are never grouped: each is a thread on its own. ["mute_thread", message_id]mutes that message's thread key. Every message with that key currently in"Inbox"(any message, not only the given one) is moved to"Archive"and marked read. Returns how many were moved. Messages in other folders are untouched. Unknown id →"error: unknown message". If the key is empty, only that message is affected and nothing is muted for the future.- When a message with a muted key is delivered, muting is applied after all rules have run: if the message is still in
"Inbox"it moves to"Archive"and is marked read. A rule that moved it elsewhere wins, and a rule'sstopdoes not prevent muting. Muting is not a rule and never appears inexplain.deliverreturns the final state. - Muting only acts at mute time and at delivery; moving a muted message back to Inbox by hand keeps it there.
Example. Deliver 1 "Budget Q3" (T), 2 "Re: budget q3" (T+60), 3 "Lunch?" (T+120), 4 "RE: Fwd: Budget Q3" (T+180). ["threads", "Inbox", 10] returns [{"subject": "budget q3", "ids": [4, 2, 1], "unread": 3}, {"subject": "lunch?", "ids": [3], "unread": 1}]. ["mute_thread", 2] returns 3. Delivering 5 "re:Budget Q3" then returns {"folder": "Archive", "labels": [], "read": true, "starred": false}, and threads now returns only the "lunch?" thread.
Notes
conditions.py,actions.pyandmailbox.parse_messageare known-good.- You may use the AI assistant freely. Say what you're asking it and check what it gives you.
- Later parts must not break earlier ones; hidden tests re-check Part 1 and 2 behaviour.
- The files in
tests/are not run by the grader. Use them, extend them, or ignore them.
Example 1
- Input:
- Five messages: id 1 at T, ids 2, 3, 4 at T+60, id 5 at T+120. ["page", "Inbox", 2, null], then ["page", "Inbox", 2, "<T+60>:4"], then ["page", "Inbox", 2, "<T+60>:2"]
- Output:
- {"ids": [5, 4], "next_cursor": "<T+60>:4"}, {"ids": [3, 2], "next_cursor": "<T+60>:2"}, {"ids": [1], "next_cursor": null}
- Explanation
- Newest first with ties broken by id descending, and each cursor resumes right after the message it names.
Example 2
- Input:
- Rules r1 (from @corp.example: label work), r2 (subject "urgent": star and move to Priority, priority 10, stop), r3 (subject "invoice": move to Finance). Deliver "URGENT: invoice overdue" from boss@corp.example.
- Output:
- {"folder": "Priority", "labels": [], "read": false, "starred": true}, and explain returns ["r2"]
- Explanation
- r2 runs first because of its lower priority, and its stop flag prevents r1 and r3 from running.
Example 3
- Input:
- Messages "Budget Q3", "Re: budget q3", "Lunch?", "RE: Fwd: Budget Q3"; ["threads", "Inbox", 10]
- Output:
- [{"subject": "budget q3", "ids": [4, 2, 1], "unread": 3}, {"subject": "lunch?", "ids": [3], "unread": 1}]
- Explanation
- Reply and forward prefixes, case and repeated whitespace are ignored when grouping.
Guidelines
- Python 3 standard library only
- Message ids are integers; received_at is integer epoch seconds and many messages can share one second
- Rule ids are strings "r1", "r2", ... in creation order
- Every operation returns a value; errors are "error: ..." strings, never exceptions