Skip to content

Bug: Event listeners rely on request-scoped DI-managed SMTP connection, causing SMTPServerDisconnected on async execution #255

Description

@kyle-li-dev

Problem

All email-sending event listeners (user registration, password reset, team invitation, etc.) currently receive an AppEmailService instance via emit(... mailer=app_mailer), where app_mailer is resolved through the request-scoped Dependency Injection (DI) container.

However, SimpleEventEmitter.emit() is synchronous — it merely enqueues the event into an in-memory channel. The actual listener executes later in a separate async worker task, well after the originating HTTP request has completed and its DI scope has been torn down.

By the time the listener attempts to call mailer.send_verification_email(...), the underlying SMTP connection held by the AppEmailService has already been closed during DI cleanup (__aexit__backend.close()), resulting in:

aiosmtplib.errors.SMTPServerDisconnected: Connection lost

What Principle Does This Violate?

This is a violation of the resource ownership and lifetime scoping principle:

  • A resource must not outlive its owning scope. The SMTP connection is owned by the request DI scope. Passing a reference to it across an async boundary (the event queue) effectively creates a dangling reference — the consumer assumes the resource is alive, but the owner has already released it.
  • Shared mutable state across concurrency boundaries. The app_mailer object is shared between the request coroutine (which triggers cleanup) and the event worker coroutine (which tries to use it), with no synchronization or lifecycle guarantee.
  • Litestar's own documented contract. The litestar-email plugin documentation explicitly states: "Event listeners in Litestar execute outside request context and cannot receive DI-injected dependencies." The current code directly contradicts this guidance.

Why Is This Bad?

  1. Silent, timing-dependent failures. The bug only manifests when the worker dequeues the event after DI cleanup — which is virtually always in production, but may not reproduce in fast unit tests where the event loop processes events eagerly.
  2. Broad blast radius. Every email-sending event path is affected (5 listeners across user registration, password reset, email verification, and team invitations).
  3. Misleading error surface. The Connection lost error points toward network/SMTP issues, sending investigators down the wrong path (firewall, TLS config, server health) when the real cause is an application-level lifecycle mismatch.
  4. Fragile coupling. Controllers are forced to inject and forward app_mailer just to pass it through emit(), adding unnecessary DI dependencies to request handlers that don't directly use the mailer themselves.

Suggested Fix Direction

Event listeners should own their own resource lifecycle rather than borrowing a request-scoped one. Concretely:

  1. Provide a factory / context manager (e.g. provide_mailer()) that creates a fresh, short-lived SMTP connection on demand. This can live in the email service module and be reused by any out-of-scope consumer (event listeners, background tasks, CLI commands, etc.).
  2. Each listener acquires and releases its own connection within an async with block, ensuring the SMTP session is opened, used, and closed entirely within the listener's own execution span.
  3. Remove mailer from emit() kwargs and drop the corresponding app_mailer DI parameter from controllers that only existed to forward it, reducing unnecessary coupling.

This approach follows the principle of "acquire late, release early" and ensures each async consumer is self-contained with respect to stateful resources.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions