#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>
72 lines
3.7 KiB
Python
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="{}"),
|
|
)
|