"""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="{}"), )