Files
FabledSteward/steward/models/outbox.py
T
bvandeusenandClaude Opus 5.5 5237ffc663
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m13s
CI / unit (push) Successful in 1m25s
CI / publish (push) Skipped
feat(alerts): alert email waits 2 minutes in the outbox and each alert records its outcome (#5507)
#5507 "Alert emails through the outbox, gathered for 2 minutes"
(milestone 501). Replaces the in-memory core/alert_burst.py.

- outbox.hold_alert: an alert joins each recipient's held alert email, or
  opens one due 2 minutes later (System 156 ruling). A Postgres advisory
  lock keeps two alerts at once from opening two windows for one reader.
- email_outbox_events.alert_json (migration 0043) keeps the alert as it
  fired, so the email written when the window closes says what it said.
- write_held writes it then, in the reader's zone: the usual alert email
  for one alert, the list for several (outbox.alert_words).
- after_settle records on each AlertEvent, once every email carrying it
  has settled: email_sent, or email_error naming each reader it didn't
  reach and the server's reason.
- A held email survives a restart and goes out when its window closes.
- A refusal of a message's one recipient no longer repeats the address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:59:17 -04:00

72 lines
3.7 KiB
Python

"""Outgoing email, one row per message per recipient, until the mail server
accepts it (#5506). Sent by steward/core/outbox.py; the System 156 ruling
"Every email goes through a queue on the record" is why it is a table and
not something held in memory: a restart picks up whatever is unsent.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Table,
Text)
from sqlalchemy.orm import Mapped, mapped_column
from steward.core.ids import time_ordered_id
from .base import Base
# held: an alert email gathering its burst until send_after (#5507)
# pending: due at next_attempt_at, first try or a retry after a temporary failure
# sending: claimed by a sender; back to pending if the claim goes stale
# sent: the mail server accepted it
# failed: refused outright, a test that didn't go, or an hour of retries
STATES = ("held", "pending", "sending", "sent", "failed")
class EmailOutbox(Base):
__tablename__ = "email_outbox"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
kind: Mapped[str] = mapped_column(String(16), nullable=False) # alert | report | invite | reset | test
recipient: Mapped[str] = mapped_column(String(320), nullable=False)
# How the recipient reads times, kept so an alert email rendered when its
# burst closes is still written in their zone.
tz: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
show_utc: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Rendered at enqueue, or (an alert burst) when the window closes; a retry
# sends the same words.
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
text: Mapped[str | None] = mapped_column(Text, nullable=True)
html: Mapped[str | None] = mapped_column(Text, nullable=True)
state: Mapped[str] = mapped_column(String(10), nullable=False, default="pending")
retry: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
send_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
first_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
# The sender's question each tick: what is due, in each open state.
Index("ix_email_outbox_state_due", "state", "next_attempt_at"),
Index("ix_email_outbox_created", "created_at"),
)
# Which alert events an email carries (#5507): a burst carries several, and
# each event is carried by one email per recipient.
email_outbox_events = Table(
"email_outbox_events", Base.metadata,
Column("outbox_id", String(36), ForeignKey("email_outbox.id", ondelete="CASCADE"),
primary_key=True),
Column("event_id", String(36), ForeignKey("alert_events.id", ondelete="CASCADE"),
primary_key=True, index=True),
# The alert as it fired (notifications' alert dict), so the email written
# when the window closes says what it said then, whatever the rule says now.
Column("alert_json", Text, nullable=False, server_default="{}"),
)