supercontest.comms.email

Email-related utilities. These functions all respect user notification preferences.

Built with the standard library’s email.message and delivered by Amazon SES – the v2 SendEmail call, with the finished MIME as raw content – from the league’s own domain, which SES signs with DKIM. SES authenticates the caller by its IAM role, so there is no mail credential anywhere: not in the settings, not in Secrets Manager. Every message names a configuration set, which is what routes bounces and complaints to their SNS topic.

One message per recipient, each addressed To that recipient alone. SES caps a message at 50 recipients, and mail addressed to its reader is what the mailbox providers’ bulk-sender rules expect; a Bcc blast is neither.

boto3 blocks, so the async senders below hand send_mail() to a worker thread; the sync ones are called from one.

A BROADCAST CAN OUTLAST ITS REQUEST. SES accepts this account’s mail at 14 messages a second (aws sesv2 get-account, MaxSendRate), and sends are paced under that (SEND_RATE_PER_SECOND), so a full list – 655 addresses at the most, in September 2026 – takes about 55 seconds however many are in flight, and the relays reach the app through CloudFront, which gives up on an answer after 60. So each broadcast takes defer: the route passes its BackgroundTasks.add_task, the recipients are read and the once-only claim is committed in the request, and the send runs after the response (202). Without defer – a script, a test – it sends in the call.

Module Attributes

LINK

A link in a message body, which send_mail() logs in dev instead of sending.

SES_CLIENT_CONFIG

How the SES client waits and retries.

SEND_CONCURRENCY

How many messages are in flight to SES at once, so one slow reply does not hold up the rest.

SEND_RATE_PER_SECOND

Messages started per second, by every send in the process.

REDIRECT_SUBJECT_PREFIX

What a redirected message's subject starts with (Settings.mail_redirect_to).

BODY_TAG

Where the HTML part's notice goes: just inside its <body>, if it has one.

Defer

Runs a function after the response: FastAPI's BackgroundTasks.add_task.

Functions

build_message

One finished message, addressed To one recipient.

claim_then_release

Ends the read before a broadcast, claiming it first when it must go out only once.

deliver

Sends each message to the one address in its To header, through the one client, SEND_CONCURRENCY at a time and no faster than SEND_RATE_PER_SECOND.

email_all_picks

Emails an image of the table of all the league picks for the week.

email_already_registered

Tells an address that registered again that it already has an account.

email_confirm_link

Sends the address-confirmation link.

email_picks

Emails a user a list of teams.

email_picks_closing

Emails all subscribed users that lockdown is about to happen.

email_picks_open

Emails the league's subscribers that new lines have been posted.

email_reset_link

Sends the password-reset link.

get_footer

The footer every notification carries.

redirect_notice

The line a redirected message starts with.

send_mail

Sends one message to each recipient.

ses_client

The SES client in the region the sending identity is verified in.

Classes

Pacer

Hands out start times at most rate a second, across threads.

Exceptions

MailDeliveryError

Not one message of a send was delivered: the transport itself is failing.

supercontest.comms.email.BODY_TAG = re.compile('<body\\b[^>]*>', re.IGNORECASE)

Where the HTML part’s notice goes: just inside its <body>, if it has one.

supercontest.comms.email.Defer

Runs a function after the response: FastAPI’s BackgroundTasks.add_task.

alias of Callable[[…], Any]

A link in a message body, which send_mail() logs in dev instead of sending.

exception supercontest.comms.email.MailDeliveryError

Bases: Exception

Not one message of a send was delivered: the transport itself is failing.

class supercontest.comms.email.Pacer(rate: float, clock: ~collections.abc.Callable[[], float] = <built-in function monotonic>, sleep: ~collections.abc.Callable[[float], None] = <built-in function sleep>)

Bases: object

Hands out start times at most rate a second, across threads.

Each wait() takes the next free slot – now, or one interval after the last slot taken – and sleeps until it, outside the lock, so the threads wait in parallel and start in turn.

wait() → None

Blocks until this caller’s slot.

supercontest.comms.email.REDIRECT_SUBJECT_PREFIX = '[us-west-2 shadow] '

What a redirected message’s subject starts with (Settings.mail_redirect_to).

supercontest.comms.email.SEND_CONCURRENCY = 8

How many messages are in flight to SES at once, so one slow reply does not hold up the rest. How many START each second is SEND_RATE_PER_SECOND.

supercontest.comms.email.SEND_RATE_PER_SECOND = 12

Messages started per second, by every send in the process. SES refuses calls past the account’s MaxSendRate – 14 a second (aws sesv2 get-account) – and the client’s five attempts, all spent against a steady excess, would leave recipients unsent. 12 leaves room for the one-off mails (a confirmation, a reset) sent meanwhile; the retries stay, for what pacing cannot see (another process, SES’s own jitter).

supercontest.comms.email.SES_CLIENT_CONFIG = <botocore.config.Config object>

How the SES client waits and retries. standard retries throttling, which is what exceeding the account’s per-second send rate returns – one call per recipient can reach it – and gives up after five attempts.

supercontest.comms.email.build_message(settings: Settings, subject: str, body: str, recipient: str, png_attachment: tuple[str, bytes] | None, html: str | None) → EmailMessage

One finished message, addressed To one recipient.

Parameters:
  • settings – The sender.

  • subject – The subject.

  • body – The plain text.

  • recipient – The one address.

  • png_attachment – A PNG to attach: its file name and its bytes.

  • html – The HTML alternative, if any.

Returns:

The message.

async supercontest.comms.email.claim_then_release(session: AsyncSession, job: Literal['picks_open', 'picks_closing', 'all_picks'], season: int, week: int, recipients: int, once: bool) → bool

Ends the read before a broadcast, claiming it first when it must go out only once.

The claim is committed before anything is sent, so a relay’s retry that arrives while the first attempt is still sending finds it and stops. A failed send is not retried: better one week without a reminder than the league mailed twice.

Parameters:
  • session – The session the recipients were read through.

  • job – Which broadcast.

  • season – Season year.

  • week – Week number.

  • recipients – How many addresses it is going to.

  • once – False to send regardless – an admin resending by hand.

Returns:

Whether to send.

supercontest.comms.email.deliver(settings: Settings, messages: list[EmailMessage]) → int

Sends each message to the one address in its To header, through the one client, SEND_CONCURRENCY at a time and no faster than SEND_RATE_PER_SECOND.

A message SES refuses is logged and skipped, so one bad address cannot stop the rest of a broadcast; the caller learns only if nothing got through.

Parameters:
  • settings – The sender, the region and the configuration set.

  • messages – The finished messages, one per recipient.

Raises:

MailDeliveryError – If every message failed.

Returns:

How many SES accepted.

async supercontest.comms.email.email_all_picks(session: AsyncSession, settings: Settings, season: int, week: int, png: bytes, defer: Callable[[...], Any] | None = None) → bool

Emails an image of the table of all the league picks for the week.

Parameters:
  • session – The session, for the subscribers.

  • settings – The app’s settings.

  • season – Goes in the subject of the email.

  • week – Goes in the subject of the email.

  • png – The image, as PNG bytes.

  • defer – Send after the response through this; see the module docstring.

Returns:

Whether it was sent, or queued to be; False if this week’s already went out.

supercontest.comms.email.email_already_registered(settings: Settings, recipient: str, link: str) → None

Tells an address that registered again that it already has an account.

Parameters:
  • settings – The app’s settings.

  • recipient – The address that registered.

  • link – A password-reset link, for an owner who has forgotten theirs.

Sends the address-confirmation link.

Parameters:
  • settings – The app’s settings.

  • recipient – The address being confirmed.

  • link – The confirmation link.

async supercontest.comms.email.email_picks(session: AsyncSession, settings: Settings, season: int, week: int, teams: list[str], emails: list[str], email_admin: bool = True) → None

Emails a user a list of teams.

This is usually to notify the single user that their late picks have been submitted.

Parameters:
  • session – The session, for the admins’ addresses.

  • settings – The app’s settings.

  • season – Goes in the subject of the email.

  • week – Goes in the subject of the email.

  • teams – The names of the teams to send.

  • emails – The regular recipients of the email.

  • email_admin – If True, sends an identical email to notify the admin.

async supercontest.comms.email.email_picks_closing(session: AsyncSession, settings: Settings, season: int, week: int, unfinished_pickers: list[str], defer: Callable[[...], Any] | None = None) → bool

Emails all subscribed users that lockdown is about to happen.

Parameters:
  • session – The session, for the subscribers and the admins.

  • settings – The app’s settings.

  • season – Goes in the subject of the email.

  • week – Goes in the subject of the email.

  • unfinished_pickers – All users who have not submitted 5 picks. This function does not calculate it, of course - it’s just passed.

  • defer – Send after the response through this; see the module docstring.

Returns:

Whether it was sent, or queued to be; False if this week’s already went out.

async supercontest.comms.email.email_picks_open(session: AsyncSession, settings: Settings, season: int, week: int, once: bool = True, defer: Callable[[...], Any] | None = None) → bool

Emails the league’s subscribers that new lines have been posted.

The league, here and in the other two broadcasts, is queries.get_users_with_email_pref()’s.

Parameters:
  • session – The session, for the subscribers.

  • settings – The app’s settings.

  • season – Goes in the subject of the email.

  • week – Goes in the subject of the email.

  • once – Send only if this week’s has not gone out; False to resend.

  • defer – Send after the response through this; see the module docstring.

Returns:

Whether it was sent, or queued to be.

Sends the password-reset link.

Parameters:
  • settings – The app’s settings.

  • recipient – The address whose password is being reset.

  • link – The reset link.

The footer every notification carries.

Parameters:

settings – The app’s settings, for the link.

Returns:

Common email footer for the app.

supercontest.comms.email.redirect_notice(recipients: int) → str

The line a redirected message starts with.

Parameters:

recipients – How many addresses the send was for.

Returns:

The line.

supercontest.comms.email.send_mail(settings: Settings, subject: str, body: str, recipients: list[str], png_attachment: tuple[str, bytes] | None = None, html: str | None = None) → None

Sends one message to each recipient.

With html, the message is multipart/alternative: body first, as the plain text every client can show, then the HTML (email_html).

With Settings.mail_redirect_to set, the whole send is ONE message, to that address, its subject prefixed REDIRECT_SUBJECT_PREFIX and both its parts opened by redirect_notice(). This is the one function that reaches SES (tests/tooling/test_mail_redirect.py), so nothing bypasses it.

Parameters:
  • settings – The app’s settings. Nothing is sent in dev, nor in tests.

  • subject – The subject of the email.

  • body – The body of the email, as plain text.

  • recipients – Email addresses of all desired recipients. Duplicates get one copy.

  • png_attachment – A PNG to attach, if desired: its file name and its bytes.

  • html – The same message as HTML, if it has one.

supercontest.comms.email.ses_client(settings: Settings) → SESV2Client

The SES client in the region the sending identity is verified in.

The region is the settings’, never the environment’s: an identity, its production access and its configuration set exist in one region only.

Parameters:

settings – The app’s settings.

Returns:

The client.