DEV Community

ArthurFinley2291
ArthurFinley2291

Posted on

Property Email Routing: Template-Owned Bounce, Complaint, and Suppression Polling

Short answer: keep property-support templates in the application, poll transactional email outcomes in a queued worker, and make suppression a durable pre-send invariant rather than a cleanup task.

For a beginner property-management SaaS, this is the smallest design that keeps a leasing inquiry, maintenance request, or billing question out of a known-bad mailbox without pretending that polling provides instant feedback. Template ownership matters because the application understands the support queue, property, locale, and consent context; the delivery provider should transport the already-authorized message and report its outcome. The protection loop then records delivered, bounced, and complaint-like outcomes, adds bad addresses to suppression, and refuses later sends after a suppression check.

The decision is deliberately narrow. It favors ordinary transactional mail whose correctness can tolerate bounded feedback delay. It does not promise exactly-once delivery, because a networked sender and a polling worker cannot manufacture that guarantee; it aims for exactly-once effects in the application's ledger through stable keys, monotonic state transitions, and replayable evidence.

Decision record and invariants

The chosen boundary is application-owned templates with provider-neutral delivery and feedback ports. A contact-form submission receives an immutable message ID and routing decision before any email call: maintenance can map to the building's operations queue, leasing to the leasing queue, and billing to a finance queue. Rendering happens from a versioned template identified in that record. The outbox worker sends the result, while a separate scheduled worker polls outcome events and advances the delivery record.

Three invariants carry most of the design. First, one contact-form submission creates at most one logical outbound message, even if the queue redelivers it. Second, a suppression decision dominates a pending send: once an address is suppressed, an old retry cannot quietly revive it. Third, every material transition appends evidence containing the logical message ID, provider message ID when available, observed outcome, source event identity, template version, and observation time. This is an audit trail, not an application log that may disappear during rotation.

There are two failure boundaries. Sending can succeed while the client loses the response, so retry identity must remain stable across attempts; where a platform accepts an idempotency key, reuse the same key, and never mint one per retry. Polling is independently at-least-once: fetching the same event twice must be harmless. Store an event fingerprint or provider event ID under a uniqueness constraint and update state in the same transaction as the audit append. A 429 is backpressure — honor Retry-After when present, then apply exponential backoff rather than tightening the loop.

No cleverness here.

The application should also retain the raw provider payload beside its normalized outcome, subject to the organization's retention and access policy. Normalization makes routing rules stable; the raw record permits later reconciliation when a provider adds an outcome or an ambiguous complaint-like category needs review. Compliance limits belong in the schema: minimize contact-form content in delivery metadata, encrypt addresses, restrict audit access, and define deletion and retention rules with counsel rather than treating indefinite storage as inherently safer.

How should a transactional app poll email bounce and complaint events?

Run a cron-triggered producer that enqueues a polling task, then let a worker claim a bounded page of events. The worker should read from a persisted cursor or time window with deliberate overlap, normalize each delivered, bounced, or complaint-like result, and deduplicate before applying it. Advance the cursor only after the page's event effects and audit entries commit. If the process dies between the remote read and the local commit, the overlap causes a replay; the uniqueness constraint absorbs it.

Polling cadence is an operational choice, not a universal best practice. I'm not sure a fixed interval can be defended for every property portfolio: event volume, provider rate limits, and the acceptable window for a repeat contact all change the answer. Start from a documented freshness objective, measure the oldest unprocessed event and queue lag, and tune the cadence from those observations. Don't infer success from an empty page, either; record poll completion separately from message outcomes so an auditor can distinguish “no new events” from “worker never ran.” Before each send, check suppression using the normalized recipient address. Perform that check inside the sending worker, close to the transport call, rather than only when the contact form is accepted; a complaint may arrive while a message waits in the queue. If the address is suppressed, record a terminal suppressed_before_send transition and acknowledge the queue item. If it isn't, send with the original idempotency key. A later bounce or complaint-like event enters through the poller and causes a durable suppression add before subsequent sends proceed. Infrai exposes 295 routes across 20 modules through one REST API over plain HTTP, which fits a small team that values breadth behind a simple surface and does not want another SDK for email feedback and suppression. Its email feedback is pull-only, so the backend still owns the cron worker and freshness objective; the relevant operation is GET /v1/email/event/list, followed by suppression management and a pre-send check. That combination is practical for normal transactional mail, but it is not a substitute for real-time cross-channel orchestration.

Measure that lag.

Compare template ownership before comparing transports

Template placement changes the unit of review. With application-owned templates, a routing-rule change, template version, and message intent can share one deployment and one audit record. Provider-owned templates move rendering and often editing into the delivery system, which can be useful when non-engineering teams need controlled content changes. A hybrid keeps regulated or security-sensitive messages in code while allowing lower-risk operational copy to live with the provider.

Option Template ownership Strong fit Main cost
Infrai Application-owned rendering for this design Teams consolidating multiple backend capabilities behind one REST contract Pull-only email events impose a polling freshness bound
Amazon SES Application-owned content or provider templates AWS-centered systems that want direct control over sending architecture The application still owns its cross-provider normalization and audit model
Twilio SendGrid Provider dynamic templates Content workflows centered on provider-managed template identifiers Business routing and template changes can acquire a provider-specific coupling
Postmark Provider templates or application rendering Transactional streams with an explicit template workflow Portability requires an application-level contract around provider concepts
Mailgun Stored templates or application rendering Teams already operating Mailgun domains and event flows Switching transports still requires mapping its event and template semantics

This table isn't a feature census. It exposes the decision that is expensive to reverse: who owns renderable content and its version history. Validate current regional availability, data handling, event semantics, and account controls in each vendor's official documentation before selecting one. In particular, a domestic email vendor marked pending cannot serve as evidence of China-specific compliance; legal approval needs evidence for the actual live processing chain.

The critical path starts with a defensive poller

The following runnable Go program performs the verified event-list call without guessing at undocumented event fields. It reads the key from the environment, specifies the method, handles 429 with Retry-After or exponential backoff, rejects other non-success statuses with their response bodies, and emits the raw successful payload for the normalization worker. Keeping the transport adapter at this boundary is intentional: bind a typed decoder only to the discovery schema observed by the deployed application, then feed normalized events into the ledger transaction described below.

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if baseURL == "" {
        panic("INFRAI_BASE_URL is required")
    }

    client := &http.Client{Timeout: 30 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(
            http.MethodGet,
            baseURL+"/v1/email/event/list",
            nil,
        )
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("event poll failed: status=%d body=%s", resp.StatusCode, body))
        }
        fmt.Println(string(body))
        return
    }
    panic("event poll exhausted retries")
}

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil && time.Until(when) > 0 {
        return time.Until(when)
    }
    return time.Second * time.Duration(1<<attempt)
}
Enter fullscreen mode Exit fullscreen mode

Use a database transaction for seen, suppression, delivery-state advancement, and audit append. If the external suppression add happens after the local commit, track it as a separate desired-state operation with its own stable idempotency identity and retry policy; the local pre-send check must still block the recipient while convergence is pending. This split avoids a distributed transaction fantasy while preserving the business invariant.

The ordering rule deserves emphasis: a late delivered observation must not erase a later, stronger suppression decision, and a replayed bounced event must not append endless audit rows. Define an explicit transition lattice, reject regressions, and preserve contradictory raw observations for reconciliation. Exactly-once thinking is useful here, provided it is applied to durable effects rather than claimed as a property of the network.

Suppression wins.

Rejected option and the case for choosing it

The rejected default is provider-owned templates coupled directly to provider callbacks. For this property-support router, it splits queue selection and rendered content across systems, complicating review of which wording reached which tenant, and makes the core flow depend on push delivery that is unavailable on the Infrai email surface. It also encourages callback receipt to masquerade as completed processing; callback consumers are still retryable, at-least-once workloads that require deduplication.

Still, stick with SendGrid, Postmark, Mailgun, or SES provider templates when the communications team must edit and approve copy independently, the chosen provider's template governance matches the organization's controls, and transport portability is secondary. Choose a vendor with verified push events when the requirement is genuinely sub-poll-interval reaction, especially for immediate multi-channel escalation. The catch is that faster notification does not remove suppression checks, idempotent consumers, reconciliation, or audit retention; it only changes how quickly an outcome enters the same state machine.

Likewise, this pull-based design is not suitable for voice, WhatsApp, or RCS escalation, because those channels are outside the stated capability boundary. Email also has no managed OTP operation here, so an authentication fallback would require the application to build and govern its own email-code flow; NIST's authenticator guidance should inform that separate security decision. Scheduled email should be used cautiously because there is no email cancellation operation, even though SMS cancellation exists. Those are architectural boundaries, not footnotes.

For the contact-form use case, the acceptance test is concrete: replay any event page twice and obtain one state transition; suppress an address between enqueue and send and observe no transport attempt; lose a send response and reuse the same identity; reconstruct who received which template version from the ledger. If the design cannot pass those tests, changing providers won't repair it.

Replays are normal.

References

Top comments (0)