litestar-org / litestar-org/litestar-fullstack
Bug: Event listeners rely on request-scoped DI-managed SMTP connection, causing SMTPServerDisconnected on async execution
- Dominant language
- Python
- Stars
- 610
- Forks
- 86
- PR merge metrics
- No merged PRs in 30d
Description
### 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.
Contributor guide
Research direction
Trace SimpleEventEmitter.emit(), AppEmailService, the five email-sending listeners, and the controllers that forward app_mailer. Identify how the request-scoped SMTP resource is created and cleaned up, then verify that each listener owns a short-lived connection, emit() no longer passes mailer, and forwarding-only controller dependencies are removed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100