Card 07/ 08
Practice: Filtering Incoming Email
Spam, then known contacts, then newsletters, then the inbox.
An email filter is a natural Chain of Responsibility, and one where the order of the links changes the result. This exercise asks you to build the chain, order it, and say what happens to a message no filter recognises. Work out your answer before opening the solution.
Incoming email should go to one folder:
- spam if the subject mentions a prize, whoever sent it;
- priority if the sender is a known contact;
- newsletters if the sender's address starts with
news@; - inbox otherwise.
- Write each rule as a handler in Python, and link them in the order above.
- What should happen to a message from an unknown sender about something else?
- A known contact sends "Claim your prize". Which folder does it land in, and what would change if the contacts filter came first?
Show a solution
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Email:
sender: str
subject: str
class Filter(ABC):
def __init__(self, next_filter: "Filter | None" = None) -> None:
self.next_filter = next_filter
def route(self, email: Email) -> str:
folder = self.folder_for(email)
if folder is not None:
return folder
if self.next_filter is None:
raise LookupError("chain has no default")
return self.next_filter.route(email)
@abstractmethod
def folder_for(self, email: Email) -> str | None: ...
class SpamFilter(Filter):
def folder_for(self, email: Email) -> str | None:
return "spam" if "prize" in email.subject.lower() else None
class ContactsFilter(Filter):
def __init__(self, contacts: set[str], next_filter: Filter | None = None) -> None:
super().__init__(next_filter)
self.contacts = contacts
def folder_for(self, email: Email) -> str | None:
return "priority" if email.sender in self.contacts else None
class NewsletterFilter(Filter):
def folder_for(self, email: Email) -> str | None:
return "newsletters" if email.sender.startswith("news@") else None
class Inbox(Filter):
def folder_for(self, email: Email) -> str | None:
return "inbox"
chain = SpamFilter(ContactsFilter({"[email protected]"}, NewsletterFilter(Inbox())))
for email in (
Email("[email protected]", "You won a PRIZE"),
Email("[email protected]", "Lunch?"),
Email("[email protected]", "Spring sale"),
Email("[email protected]", "Question about your post"),
Email("[email protected]", "Claim your prize"),
):
print(f"{email.sender:22} {email.subject!r:28} -> {chain.route(email)}")[email protected] 'You won a PRIZE' -> spam
[email protected] 'Lunch?' -> priority
[email protected] 'Spring sale' -> newsletters
[email protected] 'Question about your post' -> inbox
[email protected] 'Claim your prize' -> spamEach filter returns a folder or None, and the base class passes on when it gets None. The unknown sender's message passes three filters and lands in the inbox, because Inbox is a default handler that never returns None. The base class also raises if a chain is built without one, so a missing default cannot pass silently.
The contact's "Claim your prize" lands in spam, because the spam filter comes first. With the contacts filter first, it would land in priority instead. The order of the links is part of the design: put the checks that must win first.
← → move through sections and cards