Feature: wait-for-message Long-Poll Primitive
Overview
The wait-for-message long-poll endpoint (GET /api/v1/email/account/{mailbox}/messages:await) enables clients to block synchronously until a new email matching specified criteria arrives in a mailbox.
This is useful for:
- Confirmation workflows that wait for a verification email before proceeding
- Testing/automation scenarios that poll for arrival of system-generated messages
- Reactive user interfaces that update when a specific message lands
API Surface
GET /api/v1/email/account/{mailbox}/messages:await
?filter[subject]=...
&filter[from]=...
&filter[to]=...
&since=<ISO8601>
&timeout=30
Query Parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
filter[subject] | string | (none) | Subject line substring filter |
filter[from] | (none) | Sender email filter | |
filter[to] | (none) | Recipient email filter | |
since | ISO8601 datetime | (none) | Only match messages after this instant |
timeout | integer (seconds) | 30 | Max time to wait; server-capped at 120s |
All filters are optional and compose with AND logic (all must match).
Response
Success (200 OK):
- Returns the first matching message using the same response model as
GET /api/v1/email/account/{mailbox}/messages(index/show endpoints). - A single message in the
data.collectionarray.
Timeout (408 Request Timeout):
- Returns an RFC 9457 Problem Detail (
application/problem+json). - Indicates no message matched the filter within the timeout window.
Overloaded (503 Service Unavailable):
- Returns an RFC 9457 Problem Detail (
type … /await-overloaded). - The process is already at
AWAIT_MAX_CONCURRENT_WAITSin-flight waiters; rejected immediately (no IMAP work). Clients should retry shortly.
Authentication & Scopes
- Token Scope: Reuses the
messages:indexscope (no new scope required). - Authorization: Tokens without
messages:indexreceive a 403 Forbidden.
Example Usage
# Wait up to 30 seconds for a verification email from noreply@example.com
curl -H "Authorization: Bearer <token>" \
'https://email.squibble.ch/api/v1/email/account/mybox@example.com/messages:await?filter[from]=noreply@example.com&timeout=30'
# Response on success (200 OK):
{
"data": {
"collection": [
{
"email_id": "123",
"message_id": "<abc@example.com>",
"datetime": "2026-05-19T12:34:56+00:00",
"subject": "Verify your email",
"addresses": { ... },
"message": "Click the link to verify: ...",
...
}
]
},
"meta": {
"emails_count": 1,
"imap_search": "FROM \"noreply@example.com\""
}
}
# Response on timeout (408 Request Timeout):
{
"type": "https://email.squibble.ch/docs/errors/email/messages/await-timeout",
"title": "Request Timeout",
"status": 408,
"detail": "No matching messages found within 30 seconds.",
"instance": "https://email.squibble.ch/api/v1/..."
}
Design Constraints & Rationale
Async Bounded-Poll Architecture
The endpoint is an async handler running a bounded polling loop:
-
No threadpool worker held during waits. The route is
async def. Between polls itawait asyncio.sleep(interval), which yields the event loop and holds no threadpool worker — many thousands of waiters can sleep concurrently on one process. -
Threadpool used only for the IMAP round-trip. Each poll runs the blocking
imaplibconnect/search/fetch/logout in the threadpool viarun_in_threadpool, so a worker is occupied only for the ~1–2s of an actual IMAP round-trip — never for the full timeout. This is the explicit fix for the “naive long-poll starves the threadpool” foot-gun.- Reuses the existing two-pass selective fetch (headers + BODYSTRUCTURE, then text bodies) — memory-safety property preserved.
- The IMAP connection is opened and closed per iteration (existing
try/finallylifecycle); never held open across the wait.
-
Hard concurrency cap. Total in-flight waiters are capped at
settings.AWAIT_MAX_CONCURRENT_WAITS(default 50). Over the cap the endpoint returns an RFC 9457 503 immediately — no IMAP work, no unbounded connection/threadpool growth. The counter is mutated only on the event-loop thread (noawaitbetween check and increment), so it is race-free without a lock. -
Server-side timeout cap: hard 120s (
le=120Pydantic type + route annotation); larger client values are clamped, not errored. -
Poll interval: default 2s between IMAP searches; injectable via the processor’s
poll_interval_seconds(0 in tests). Balances responsiveness (~2s detection latency) against IMAP load.
Concurrency Safety
- No worker held while waiting: inter-poll waiting is
asyncio.sleepon the event loop; only the brief per-iteration IMAP call uses a worker. - Connection bound: ~1 IMAP connection per active iteration, closed in a
finally; total simultaneous waiters bounded by the hard cap. - Explicit backpressure: at the cap, new awaits get an immediate 503 problem doc rather than cascading IMAP failures. No deadlock.
Filter Composition
Filters compose with AND logic:
subject=InvoiceANDfrom=alice@example.comANDsince=2026-05-19T00:00:00Z
Maps directly to IMAP SEARCH syntax:
SUBJECT "Invoice" FROM "alice@example.com" SINCE 19-May-2026 ALL
Input Validation & Injection Prevention
- Mailbox names validated with regex
^[^\r\n]+$(no CRLF). - Subject filters validated to reject CRLF (prevents IMAP command injection).
- Special characters in filter values are escaped for IMAP (quotes and backslashes).
Testing
The implementation includes 17 comprehensive tests covering:
- ✅ Matching messages returned immediately on first poll
- ✅ 408 timeout returned when no match occurs
- ✅ Client timeout clamped to 120s cap
- ✅ Filters compose into IMAP SEARCH
- ✅ Multiple poll iterations until message arrives
- ✅ CRLF/injection protection in mailbox and filters
- ✅ IMAP connection cleanup on exception
- ✅
sincefilter using IMAP SINCE (date-only format)
Tests use mocked IMAP to avoid live server dependency and run in <3s.
Production Considerations
- Monitoring: Track the in-flight-waiter count and 503 rate; raise
AWAIT_MAX_CONCURRENT_WAITSif legitimate load is hitting the cap (it bounds simultaneous IMAP connections, so weigh against the IMAP server’s per-user limit). - IMAP Load: Poll interval (default 2s) can be adjusted if IMAP server is under strain.
- Timeout Tuning: Most use cases should use 30–60s; values near 120s should be rare.
- Rate Limits: Standard rate limit (30 requests/minute) applies; clients should batch waits or use shorter timeouts if needed.