litestar-org / litestar-org/litestar-fullstack

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

Open
#255 0 comments 0 reactions 0 assignees View on GitHub
bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.