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?
- 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.
- Broad blast radius. Every email-sending event path is affected (5 listeners across user registration, password reset, email verification, and team invitations).
- 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.
- 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:
- 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.).
- 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.
- 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.
Problem
All email-sending event listeners (user registration, password reset, team invitation, etc.) currently receive an
AppEmailServiceinstance viaemit(... mailer=app_mailer), whereapp_maileris 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 theAppEmailServicehas already been closed during DI cleanup (__aexit__→backend.close()), resulting in:What Principle Does This Violate?
This is a violation of the resource ownership and lifetime scoping principle:
app_mailerobject 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.Why Is This Bad?
Connection losterror 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.app_mailerjust to pass it throughemit(), 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:
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.).async withblock, ensuring the SMTP session is opened, used, and closed entirely within the listener's own execution span.mailerfromemit()kwargs and drop the correspondingapp_mailerDI 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.