Python 事件通知:按退信風險區分電郵與 SMS
Python Event Alerts: Separating Email and SMS by Bounce Risk
Python 事件通知通常應按政策分配電郵與 SMS,而非只選用一個 API:日常、非緊急通知用電郵,只有延誤會造成實際營運成本的事件才用 SMS。兩個通道應把標準化送達事件送入同一流程,並共用收件對象抑制狀態。電郵永久失敗應觸發抑制,暫時失敗則決定是否重試;SMS 使用相同內部狀態,但須按其送達及失敗回呼轉換。
Short answer: for startup event alerts, the most cost-effective choice is usually a policy, not a single API. Put routine, non-urgent notices on email, reserve SMS for events where delay has a real operational cost, and make recipient suppression a shared piece of state. The integration effort is lower when both channels emit normalized delivery events into the same Python pipeline.
The important comparison is not a price table. It is how much application code you need to keep bad addresses, hard bounces, stale phone numbers, retries, and opt-outs from becoming recurring work. I would measure those failure paths before choosing a provider or splitting traffic between two of them. Keep it boring. That is useful.
Why are bounces the real boundary between email and SMS?
An event-alert system has two different jobs hiding behind one send button: deliver a notification and learn whether the destination should be used again. Email makes the second job visible through delivery notifications and bounce data. SMTP defines how mail is transferred, while delivery-status notifications describe failed delivery and its status fields. SMS has its own delivery lifecycle, but a successful submission is not the same thing as a handset receiving the message.
That distinction changes the data model. A send request should create an immutable attempt with a channel, an event identifier, and a destination fingerprint. A later provider callback should update the attempt and the recipient's suppression state. Do not delete the destination from the customer record; keep the reason, timestamp, source event, and reactivation rule. Auditability matters when an operator asks why a user stopped receiving alerts.
For email, treat a permanent failure as a strong suppression signal and a temporary failure as a retry decision. The exact classification belongs at the adapter boundary because services expose different status vocabularies. For SMS, use the same internal states but map them from the channel's delivery and failure callbacks. The shared state is useful; pretending the underlying signals are identical is not.
A useful minimum state machine is small:
from dataclasses import dataclass
from enum import Enum
class DeliveryState(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
DELIVERED = "delivered"
RETRYABLE_FAILURE = "retryable_failure"
PERMANENT_FAILURE = "permanent_failure"
SUPPRESSED = "suppressed"
@dataclass(frozen=True)
class DeliveryEvent:
attempt_id: str
channel: str
state: DeliveryState
reason: str | None = None
This is deliberately boring. Boring state is easier to test than a collection of provider-specific booleans.
What should the event-alert pipeline actually own?
The application should own intent and policy. A channel adapter should own authentication, request formatting, and translation of external callbacks. A worker should own retry scheduling. A suppression store should answer one question before every send: is this destination currently eligible for this channel and event class?
That division prevents a common failure mode: a callback handler that both parses an external payload and directly edits business records. It becomes difficult to replay safely, and a duplicate callback can accidentally clear a suppression record. Store the raw callback, derive a normalized event, and apply it idempotently with a unique key such as (provider_event_id, attempt_id, state). The adapter can be swapped without rewriting alert policy.
I keep the decision table close to the application because it is where urgency is defined. A deployment-failed alert may justify an SMS fallback after email has a transient failure. A weekly usage digest does not. The fallback must also have a stopping rule: one email attempt, one SMS attempt, then an operator-visible failure. Otherwise a provider outage turns into a notification storm.
Here is the kind of policy I can exercise in a notebook before wiring a production queue:
def choose_channels(event_kind: str, email_ok: bool, sms_ok: bool) -> list[str]:
urgent = event_kind in {"deployment_failed", "security_lockout"}
if urgent:
channels = ["email"] if email_ok else []
if sms_ok:
channels.append("sms")
return channels
return ["email"] if email_ok else []
The function is not the system. It is an executable statement of policy. I would test it with a matrix containing missing destinations, suppressed destinations, duplicate events, and a temporary email failure. That catches more integration mistakes than starting with a live send.
What is the most cost-effective email API or SMS path for event alerts?
Compare the surfaces that your team must maintain, not just the first successful request. Email needs address validation, bounce classification, unsubscribe handling, and content rules. In the United States, the FTC's CAN-SPAM guidance covers commercial email obligations including accurate header information, identification, and an opt-out mechanism. Transactional event alerts still deserve a clear consent and preference model; calling every message transactional does not remove the need for careful policy.
SMS adds phone-number normalization, country and carrier constraints, consent records, delivery status interpretation, and a stricter expectation that messages are short and timely. A fallback can also surprise users: receiving an SMS after an email delay may be correct for a critical incident and annoying for a low-priority event. That is a product decision with an engineering consequence.
The integration with the lowest total effort usually has these properties:
- one internal send contract for both channels;
- one durable attempt record per channel;
- signed or authenticated callback handling;
- idempotent state transitions;
- separate suppression keys for email and phone destinations;
- metrics for accepted, delivered, retryable, permanent, and suppressed outcomes.
The last two are easy to miss. A person can have a bad email address and a valid phone number, so suppression must be channel-specific. Metrics must distinguish “accepted by the API” from “delivered to the destination,” or the dashboard will report success too early.
There is a real trade-off here. A single-channel design has fewer moving parts and is a better fit for low-urgency alerts, but it cannot provide a meaningful fallback when delivery is time-sensitive. The two-channel design is a poor fit for a tiny team that cannot review consent, callbacks, and suppression state; in that case, narrowing the alert policy is safer than adding SMS by default.
What should you measure before copying this design?
Start with a small replayable dataset, not a provider comparison spreadsheet. Include real-shaped but non-production destinations: valid addresses, malformed addresses, destinations that have already been suppressed, duplicate event IDs, and callbacks arriving out of order. The test harness should assert the final suppression state and the number of outbound attempts.
I care about three measurements first: integration change size, callback normalization coverage, and operator work per thousand alert events. Token cost matters in the AI features around the system too. If an agent is generating incident summaries or classifying callback reasons, pass it normalized fields rather than entire raw payloads, and evaluate the classifier against a fixed labeled set. The model should not decide whether a destination is permanently invalid when a channel-specific delivery signal already provides that fact.
A simple Python contract test keeps the boundary honest:
def test_permanent_email_failure_suppresses_only_email():
recipient = {"email": "old@example.test", "phone": "+15550100"}
event = DeliveryEvent(
attempt_id="attempt-17",
channel="email",
state=DeliveryState.PERMANENT_FAILURE,
reason="mailbox_unavailable",
)
updated = apply_delivery_event(recipient, event)
assert updated["email_suppressed"] is True
assert updated["phone_suppressed"] is False
The test uses a generic apply_delivery_event boundary on purpose. The adapter test should prove how a particular callback becomes PERMANENT_FAILURE; the policy test should remain independent of that callback's shape.
The decision rule
Choose email as the default for non-urgent event alerts when the audience can tolerate asynchronous delivery and your team can process bounce and opt-out signals. Add SMS for narrowly defined urgent events, with explicit consent, a separate suppression record, and a measured fallback limit. Choose the integration that exposes normalized status events cleanly enough for your team to operate, replay, and test.
Before production, verify the callback authentication model, retry behavior, retention requirements, and regional messaging rules in the documentation for the service you select. Then run the replay dataset and inspect the operator queue. The cheapest path on paper is not economical if every invalid recipient becomes a manual investigation.
Further reading
- FTC, “CAN-SPAM Act: A Compliance Guide for Business”: https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business
- RFC 5321, “Simple Mail Transfer Protocol”: https://www.rfc-editor.org/rfc/rfc5321
- RFC 3464, “An Extensible Message Format for Delivery Status Notifications”: https://www.rfc-editor.org/rfc/rfc3464
- RFC 9457, “Problem Details for HTTP APIs”: https://www.rfc-editor.org/rfc/rfc9457
來源:DEV Community · dev.to