Prima Get an API key

Module kit / Service desk

Email Intake Channel

service-desk/email-intake-channel

Kind backend
Domain service-desk
Maturity broken
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, apscheduler-3.11, pydantic-2.12, python-3.13-stdlib-email-imaplib
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/scheduler-and-background-work, _shared/alembic-migrations
Frontends service-desk/ui/ticket-detail
Reference implementation
  • alchemy_labs/backend/services/service_desk/email_intake.py
  • alchemy_labs/backend/services/service_desk/inbox_config.py
  • alchemy_labs/backend/services/scheduled_jobs.py
  • alchemy_labs/backend/api/routers/workspace_router.py
  • alchemy_labs/backend/models/service_desk_model.py
  • alchemy_labs/backend/migrations/versions/20260906_0002_desk_email_ingest.py
  • alchemy_labs/backend/tests/test_desk_email_threading.py
  • alchemy_labs/backend/tests/test_desk_email_attachments.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

When a workspace connects a mailbox, every inbound email becomes exactly one desk ticket or exactly one reply on the ticket it already belongs to, never a duplicate and never a second ticket for the same conversation, because dedup runs first, before anything else, keyed by the email's Message-ID (module docstring, email_intake.py 10 to 13). The invariant that makes it trustworthy is that a reply threads onto its own ticket even when the parent mail was one of OUR outbound replies (never recorded in the dedup ledger) or when the client's mailer strips References on reply: header threading (In-Reply-To/References) is tried first, and a [PREFIX-N] key stamped into every outbound subject is the fallback that stops an ordinary "thanks" reply from spawning a fresh ticket every time (the DTH-0016 fix, email_intake.py 571 to 577).

When to use

Read it before touching parse_email, extract_reference_ids, extract_ticket_keys, synthesize_message_id, _process_message, poll_workspace, or poll_all; before changing what counts as a noise sender or an accepted domain; and when someone asks "why did that reply open a new ticket instead of threading", "why isn't the mailbox picking up new mail", or "did we lose an attachment from that email". Trigger phrases: "email intake", "IMAP poll", "Message-ID dedup", "DTH-0016", "the [PREFIX-N] subject fallback", "desk_email_ingest", "accepted_domains", "noise sender".

Not this recipe: the mailbox connection settings panel itself, GET/PUT/DELETE/POST .../test /api/workspaces/{workspace_id}/imap (workspace_router.py 784 to 876), the credential CRUD and connectivity probe this recipe's poll depends on but does not implement, cited under API surface for completeness; the AI intake-check / Nachfragen pass a newly created ticket runs through (_enrich_email_ticket calling services.service_desk.intake_check, email_intake.py 465 to 536), a separate recipe on the AI question-asking pipeline; the outbound send path that replies through the same mailbox (inbox_config.send_via_mailbox, 125 to 157), a sibling of this recipe; the surface a created or threaded message renders on (service-desk/ui/ticket-detail, see Frontend).

Stack assumptions

At 62ba4fc4: fastapi==0.118.2, SQLAlchemy==2.0.43 on a synchronous Session, APScheduler==3.11.0 for the poll job's own IntervalTrigger, pydantic==2.12.0 (only indirectly, the sibling config route's request body), psycopg2-binary==2.9.10, alembic==1.16.5. MIME parsing and the mailbox connection itself use only the Python 3.13 standard library: email (policy.default, the modern EmailMessage API), imaplib.IMAP4_SSL, hashlib, re, nothing else (email_intake.py 29 to 42 imports nothing outside stdlib plus sqlalchemy; there is no third-party MIME or IMAP library in this recipe). Foundations assumed: S4 (fastapi-project-layout) for the app and router mount; S5 (auth-dependency-pattern) for get_current_user on the mailbox-connection routes; S6 (workspace-scoping) for IsolationService.verify_workspace_access; S8 (alembic-migrations) for the one chained migration under Data model; S12 (scheduler-and-background-work) for the APScheduler singleton the poll job is meant to register on, except that registration is currently broken (Gotchas).

Environment: none dedicated to this module. The mailbox host/port/username/password/folder/accepted_domains/auto_ask live per workspace in workspace_settings.settings.imap (JSONB), not in an env var (inbox_config.py 1 to 14); the one credential this recipe touches, the mailbox password, is encrypted with CALENDAR_ENCRYPTION_KEY, the same Fernet key the calendar integration already uses (inbox_config.py 4 to 6, 104), so connecting a mailbox needs no new credential provisioned. Fail behaviour: poll_workspace never raises (module docstring 14 to 15) - a missing or disabled config returns a zero-stats dict before any connection is attempted (632 to 633), a config-load failure is logged and returns the same (638 to 640), and a connection/auth/select failure is caught around the whole IMAP block and logged, never propagated (698 to 700); poll_all (716 to 732) has no try/except of its own around each workspace's call, it relies entirely on poll_workspace's own documented guarantee never to raise.

Data model

workspace_settings.settings.imap (JSONB per workspace, no migration - inbox_config.py 1 to 3 says so explicitly, "exactly mirroring services.smtp_config"): host, port (default 993), smtp_port (default 465, used only by the sibling send_via_mailbox, not by this recipe's poll), username, folder (default INBOX), enabled (bool), accepted_domains (list of lower-cased, @-stripped domains; empty means accept every domain), auto_ask (bool, default off), password_encrypted (Fernet, via calendar_service.encrypt_token, 104). get_imap_config never returns the password unless with_password=True is passed explicitly (45 to 49, 73 to 75); the poll job is the only caller in this recipe that passes it (email_intake.py 631).

desk_email_ingest, the dedup and threading ledger, one row per email ever taken in (migrations/versions/20260906_0002_desk_email_ingest.py 22 to 44; models/service_desk_model.py 419 to 448):

desk_email_ingest (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  message_id   text NOT NULL,
  ticket_id    uuid REFERENCES desk_tickets(id) ON DELETE SET NULL,  -- NULL for a noise/off-domain mail
  created_at   timestamptz NOT NULL DEFAULT now()
)
UNIQUE (workspace_id, message_id)   -- uq_desk_email_ingest_ws_msg, the dedup guarantee itself
INDEX  (ticket_id)                  -- ix_desk_email_ingest_ticket, the threading lookup

What is NOT stored anywhere in this recipe: the raw RFC822 message. parse_email (257 to 279) reads it once and only its derived fields (from, subject, body, message_id, reference_ids, attachments) ever reach a table; a message with no Message-ID header gets a deterministic synthetic id, a sha256 of the lower-cased from address plus subject plus date header (synthesize_message_id, 248 to 254), so it still dedups on a re-scan without the raw bytes ever being kept. Also not stored: the mailbox password in plaintext (encrypted at rest, above); the body or headers of a noise or off-domain email - a dedup row is written with ticket_id NULL and message_id only, before the mail is dropped (554 to 557, 563 to 568), so a re-scan does not re-evaluate it, but no ticket, message, or attachment is ever created for it.

API surface

The mailbox-connection routes (workspace_router.py, router mounted at /api/workspaces, line 29):

RouteAuthTenancyCodesNotes
GET /{workspace_id}/imap (784 to 796)get_current_userIsolationService.verify_workspace_access - any member200 {"configured": false} when unset, else the config minus the password; 403 not a memberNever returns password_encrypted or the decrypted password (45 to 49)
PUT /{workspace_id}/imap (799 to 815)get_current_userverify_workspace_access(..., required_role="admin")200 saved config (minus password); 403 non-adminBody ImapConfigPut (771 to 781); a re-save without password keeps the stored one (103)
DELETE /{workspace_id}/imap (818 to 829)get_current_userverify_workspace_access - any member, not admin200 {"configured": false}Asymmetric with PUT: any member can disconnect a mailbox, only an admin can connect or reconfigure one (824 to 826 vs 812)
POST /{workspace_id}/imap/test (832 to 877)get_current_userverify_workspace_access - any member200 {"ok": true, "folder", "message_count"} or {"ok": false, "error"}, always 200Live IMAP login + read-only select, run in a threadpool (856 to 874); no state is written

This recipe's own surface is not HTTP, it is the poll job and the pure functions the scheduler calls:

FunctionCommits its own workNever raisesReturns
poll_workspace(db, workspace_id) (614 to 713)yes, per messageyes (module docstring 14 to 15)stats dict: created, seen, skipped_dupe, skipped_noise, skipped_off_domain, errors, capped
poll_all(db) (716 to 732)delegates to poll_workspaceyes, so long as poll_workspace holds (no own try/except)totals dict: workspaces, created, skipped_dupe, errors
_process_message(db, workspace_id, ds, raw, system_principal_id, accepted_domains, auto_ask) (539 to 611)yes, per branch"never raises for content reasons" (541 to 543); a DB-layer exception is caught by poll_workspace's per-UID try/except (687 to 697), not by this function itselfone of "created"|"threaded"|"dupe"|"noise"|"off_domain"

Application logic

poll_workspace, in commit order (614 to 713):

  1. Resolve workspace_id to a UUID; a bad id is logged and returns empty stats, nothing else runs (624 to 629).
  2. Load the IMAP config with the password; missing, disabled, no host, or no password stored returns zero stats without attempting a connection (631 to 633).
  3. Resolve the desk's settings (prefix, task types, classes) and the system principal (workspace owner, else earliest admin) that the audit trail names (636 to 640, _system_principal_id 352 to 356).
  4. imaplib.IMAP4_SSL(host, port), login, select the configured folder, UID SEARCH ALL (643 to 649).
  5. For each UID, in server order: UID FETCH ... BODY.PEEK[] so fetching never itself marks the message \Seen (660 to 662); call _process_message; on "created"/"threaded" mark \Seen; on "noise"/"off_domain" also mark \Seen so it is not re-fetched; on "dupe" leave it alone (672 to 686).
  6. Stop after MAX_PER_RUN=200 created-or-threaded messages in one run; the remainder waits for the next tick, safe because of the dedup guarantee (652 to 658, MAX_PER_RUN 46 to 49).
  7. Any per-message exception rolls the session back and moves to the next UID rather than aborting the run (687 to 697).
  8. The IMAP connection is always closed and logged out in a finally, even after a connection-level exception (701 to 710).

_process_message, per email, in order (539 to 611):

  1. Parse the raw bytes, pure, no IO, into from/subject/body/message_id/reference_ids/attachments (546, parse_email 257 to 279).
  2. Dedup: message_id already recorded for this workspace → "dupe", nothing else runs (549 to 550, _already_ingested 287 to 295).
  3. Noise filter: the sender's local-part is in a fixed no-reply/bounce/postmaster set or matches one of its prefixes → record the ingest row with ticket_id NULL, commit, "noise" (358 to 375, 554 to 557).
  4. Allowlist: when the workspace configured accepted_domains, a sender domain not in it → the same NULL-ticket ingest record, commit, "off_domain" (563 to 568).
  5. Thread by header: any In-Reply-To/References id that is itself a desk_email_ingest row with a non-null ticket_id wins (570, _thread_ticket_id 298 to 311).
  6. Thread by subject fallback, only tried when step 5 found nothing: extract every bracketed [PREFIX-N] key from the subject, left-most (outermost) first, resolve the first one naming a real ticket in this workspace (571 to 577, _subject_thread_ticket_id 314 to 331, extract_ticket_keys 218 to 245) - the DTH-0016 fix (module docstring 20 to 26; test_desk_email_threading.py 1 to 7).
  7. If either threading step found a ticket: append the mail as an inbound message, release waiting_on_client back to in_progress if that was the status, store the reply's own attachments against its own message id, record the ingest row against this ticket, commit, "threaded" (578 to 596, append_inbound_email 528 to 562).
  8. Otherwise: create a new ticket from the email - title is the subject, else the first non-empty body line, else a placeholder; the requester resolves to an existing workspace member if the from-address matches one, else stays email-only - record the ingest row, commit, "created" (598 to 611, create_ticket_from_email 429 to 525).
  9. Only on the create branch, best-effort, its own transaction(s): estimate effort, run the AI intake-check with up to 3 image attachments fed to vision, and - only when the caller passed auto_ask=True and the sender's domain was accepted - email any Nachfragen back and park the ticket on waiting_on_client (465 to 536).

Invariants

  • A given (workspace, Message-ID) is ingested at most once, ever: the UNIQUE index is the actual guarantee, _already_ingested is only the fast-path check the application makes before touching anything else (287 to 295, uq_desk_email_ingest_ws_msg).
  • A header-less email still dedups across re-scans: its synthetic id is a deterministic hash of from plus subject plus date, not of any server-side state, so re-fetching the same message twice always resolves to the same id (248 to 254; no test in this repo exercises this path directly, see Tests).
  • Noise and off-domain mail are recorded, ticket_id NULL, before they are dropped, so neither is ever re-evaluated on the next poll and neither ever produces a ticket, a message, or a stored attachment (554 to 568).
  • Header threading is tried before the subject fallback, never the reverse: a reply whose References survived intact threads correctly even if its subject also happens to carry a stale or foreign [PREFIX-N] key (571 to 577; test_header_threading_still_wins_when_present).
  • The subject-key matcher requires brackets and resolves the left-most (current) thread first, so a quoted-reply chain naming several older tickets does not thread onto the wrong one (218 to 231; test_extract_keys_from_the_real_dth_0016_subject).
  • Every attachment the sender attached is persisted, including on a threaded reply and not only on ticket creation, and the body text is never re-stored as a phantom attachment (iter_attachments() already excludes the parts get_body() selects, 120 to 128; test_parse_email_extracts_pdf_and_inline_image, test_parse_email_plain_text_has_no_phantom_attachment).
  • A mislabeled attachment (a PDF sent as application/octet-stream) is recovered from its filename extension rather than dropped; an oversized (over 20 MB) or disallowed-type part is skipped, never fatal to the rest of the message (104 to 117, 144; test_parse_email_recovers_mislabeled_octet_stream_pdf, test_parse_email_skips_oversize_and_disallowed).

Frontend

The module that renders what this recipe produces, by id:

service-desk/ui/ticket-detail   renders the threaded inbound message (channel="email") and its
                                 attachments on a ticket's timeline, and, via ticket creation,
                                 the ticket itself; at 62ba4fc4 nothing in this repo's frontend
                                 calls poll_workspace or the underlying service behind the /imap
                                 routes directly - connecting a mailbox is a settings action,
                                 reading the result is purely the ticket-detail surface

Minimum admin/debug surface (no dedicated route exists for these, a human runs them by hand): POST /api/workspaces/{workspace_id}/imap/test to prove host/port/credentials/folder are reachable without ingesting anything (832 to 877, read-only select); SELECT count(*) FROM desk_email_ingest WHERE workspace_id = :w AND ticket_id IS NULL to see how much mail a workspace's noise/allowlist filters have dropped; the log line desk email intake ws %s: %s (the stats dict), emitted only when created or errors is nonzero (711 to 712); SCHEDULER: desk email intake {totals} once per tick, when the scheduler runs the job at all (scheduled_jobs.py 584 to 585 - see Gotchas for why it currently does not).

Security & tenancy

Auth dependency: the four mailbox-connection routes all require get_current_user (787, 803, 821, 836); GET, DELETE, and POST .../test accept any workspace member (IsolationService.verify_workspace_access(user, workspace_id), 792, 825, 842), PUT requires required_role="admin" (812) - so any member can disconnect or probe a mailbox but only an admin can connect or reconfigure one, an asymmetry as written that is not documented as deliberate anywhere in the file. The poll job itself has no request-level auth at all: it is a leader-only background job (_shared/scheduler-and-background-work), never reachable over HTTP, and its only "credential" is the encrypted password it reads out of the workspace's own settings row.

Tenancy: every query in this recipe is scoped by workspace_id - the desk_email_ingest lookups (287 to 295, 298 to 311), the subject-key ticket lookup (314 to 331, filtered on DeskTicket.workspace_id), enabled_workspace_ids's own WHERE clause (inbox_config.py 172 to 185). A thread match never crosses a workspace: both _thread_ticket_id and _subject_thread_ticket_id filter on workspace_id, so a Message-ID or ticket key that happens to collide with another workspace's ticket can never thread onto it.

Fail direction: closed on double-ingestion (the UNIQUE index, not application logic, is the actual guarantee); open on a poll failure - a broken mailbox, a bad password, a network error all just skip that workspace for this tick and log a warning, the module docstring calls this "best-effort" by design (14 to 15), the alternative being a crashed scheduler run taking every other workspace's mail down with it.

Secrets and PII at rest: the mailbox password is Fernet-encrypted with CALENDAR_ENCRYPTION_KEY (inbox_config.py 104) and is only ever read back with with_password=True, which only the poll job and the /test probe pass (email_intake.py 631; workspace_router.py 850); GET /imap never returns it (45 to 49). Email bodies and attachment bytes ARE stored, that is the feature, but the module docstring is explicit that this content is "data, never control": it is never parsed for commands and never influences branching (16 to 19), and is only later read by the AI intake-check as data. Attachment ids (dsk_<uuid><ext>) derive their extension only from the trusted MIME allowlist, never from the client-supplied filename, so a crafted filename cannot inject an unexpected extension (395).

Attacker-controlled inputs, all of them reachable by whoever can send to the connected mailbox: From/Subject/body/every header (parsed, never executed); attachment filename, content-type and bytes (mapped through a fixed allowlist and a size cap before anything is trusted, 93 to 101); the [PREFIX-N] subject token (only ever used to look up an existing ticket by key in this workspace, never to create or authorize anything, 314 to 331).

Tests

Run 2026-09-12 against the docker-compose.test.yml postgres-test service (localhost:5434, confirmed reachable), using alchemy_labs/backend/.venv:

cd alchemy_labs/backend && .venv/bin/python -m pytest tests/test_desk_email_threading.py tests/test_desk_email_attachments.py \
    -q --no-cov -o addopts="" -p no:cacheprovider

prints 10 passed (0 failed; deprecation warnings only, unrelated to this recipe).

Known-answer vectors: extract_ticket_keys("Re: [DTH-0015] Antwort: Re: [DTH-0014] ...", "DTH") == ["DTH-0015", "DTH-0014"] (left-most first, test_extract_keys_from_the_real_dth_0016_subject); "[dth-16]" resolves to "DTH-0016" (case-insensitive, re-padded), a bare "DTH-0016 ohne klammern" resolves to [] (brackets required), a foreign prefix "[DT-0001]" resolves to [] (test_extract_keys_needs_brackets_repads_and_is_case_insensitive).

Hermetic vs live: every test here uses the real Postgres in a transactional db_session fixture (tests/desk_support.py 1 to 9: real rows, dependency-override clients, no JWT, no middleware), not a mocked database. Nothing in this repo's tests connects to a real mailbox: grep -rl "poll_workspace\|poll_all\|imaplib" backend/tests/, run 2026-09-12, returns no file. poll_workspace's own IMAP loop (connect/login/select/UID search/fetch/mark-Seen, 614 to 713) and the four /imap config routes (workspace_router.py 784 to 877) are both untested at this commit; the two test files here cover exactly _process_message's routing decision, parse_email, and _store_attachments - the pure and DB layer below the network. This is a genuine gap, stated plainly rather than inferred as covered.

Attachment coverage: test_parse_email_extracts_pdf_and_inline_image, test_parse_email_plain_text_has_no_phantom_attachment, test_parse_email_recovers_mislabeled_octet_stream_pdf, test_parse_email_skips_oversize_and_disallowed (all pure, no DB) and test_store_attachments_writes_deskattachment_rows (@pytest.mark.database, asserts a dsk_<uuid>.<ext> file-id pattern and the pdf/photo kind mapping).

Deploy & ops

Config keys: none dedicated to this recipe (no new env var; the mailbox is configured per workspace via the PUT route above); the shared CALENDAR_ENCRYPTION_KEY must already be set for a saved password to decrypt (inbox_config.py 4 to 6).

Migration: one, desk_email_ingest_001 (migrations/versions/20260906_0002_desk_email_ingest.py), down-revision desk_estimate_001, creates the table, the UNIQUE(workspace_id, message_id) constraint, and the ticket_id index; no data backfill.

Scheduler: the intended registration is scheduler.add_job(run_desk_email_intake, IntervalTrigger(minutes=5), id='desk_email_intake', ...) inside init_scheduler() (scheduled_jobs.py 465 to 469), commented "Leader-only so one mailbox is never polled twice across workers." At the pinned commit this call's own parenthesis is never closed before the very next job's scheduler.add_job(run_device_code_sweep, ...) call begins (465 to 472); see Gotchas, this is not hypothetical, py_compile fails on the file.

Argus-style checks: none exist for this recipe specifically. The closest available signal is the log line SCHEDULER: desk email intake {totals} (584 to 585), which at this commit never fires because the job is not registered; once fixed, "created > 0 or errors > 0 at least once per N ticks" is the natural check, mirroring how the other jobs in this file report.

Rollback: nothing deployable beyond the code and the one migration; desk_email_ingest is written to only by this recipe, so rolling back the code does not require unwinding data - a stray dedup row with ticket_id NULL is inert.

Gotchas

  • 2026-09-12 (measured at 62ba4fc4): backend/services/scheduled_jobs.py does not compile. python3 -m py_compile backend/services/scheduled_jobs.py fails with SyntaxError: '(' was never closed at line 465 - the scheduler.add_job(...) call registering run_desk_email_intake (465 to 469) is missing its closing parenthesis and its replace_existing/coalesce/max_instances kwargs (every sibling job in the file carries all three); the very next job's scheduler.add_job(run_device_code_sweep, ...) call (472) is swallowed as a continuation of the same expression instead of starting a new statement. git blame shows the email-intake block landed cleanly on 2026-09-06 (ce8e328e) and was broken by a later change on 2026-09-12 (f719cca3, "W9 Labs: device codes...") that inserted the device-code job immediately after it without closing the previous call. Only this one file fails to compile: email_intake.py, inbox_config.py, workspace_router.py, ticket_service.py, and the models all compile cleanly (verified the same way, same commit). Because init_scheduler() is called from a broad except Exception in app_fastapi.py's startup (319 to 324), and SyntaxError is a subclass of Exception, the app does NOT crash on boot - it logs "Failed to start APScheduler: ..." and continues with every job in this file unregistered, not only desk_email_intake. A workspace can still connect and test a mailbox (the /imap routes live in a different, unaffected file) but no periodic poll currently runs to act on it. This is why maturity is broken rather than prod: the feature's own logic is merged, tested, and reachable by hand, but its scheduled trigger is not currently loadable.
  • 2026-09-11 (module docstring; DTH-0016): before this fix, a client's ordinary "thanks" reply to a resolved ticket opened a brand-new ticket every time, because threading relied only on In-Reply-To/References, and (a) Labs' own outbound replies were never recorded in desk_email_ingest, so their Message-ID was never something a reply could match, and (b) Outlook is observed to drop the References header on reply. The [PREFIX-N] subject-stamp fallback (218 to 245) is the fix; the second half of test_desk_email_threading.py exists to keep this from regressing.
  • 2026-09-12 (measured): no test in this repo exercises poll_workspace's actual IMAP session (connect/login/select/search/fetch/mark-Seen) or the four /imap config routes; see Tests. A rebuild should not assume this layer is covered just because the routing/parsing/storage layers it depends on are.

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/service_desk/email_intake.py (732 lines): module docstring (1 to 26); ParsedAttachment/ParsedEmail (61 to 77); attachment allowlist and extraction (_mime_and_ext 104 to 117, _extract_attachments 120 to 152); body extraction (_strip_html 155 to 168, _best_body 171 to 193); id helpers (strip_message_id 196 to 202, extract_reference_ids 205 to 215, extract_ticket_keys 218 to 245, synthesize_message_id 248 to 254); parse_email (257 to 279); dedup/threading DB helpers (_already_ingested 287 to 295, _thread_ticket_id 298 to 311, _subject_thread_ticket_id 314 to 331, _record_ingest 334 to 344); noise filter (_is_noise_sender 358 to 375); attachment persistence (_store_attachments 387 to 428, _store_and_scan 431 to 462); AI enrichment (_enrich_email_ticket 465 to 536); routing (_process_message 539 to 611); poll (poll_workspace 614 to 713, poll_all 716 to 732).
  • alchemy_labs/backend/services/service_desk/inbox_config.py (186 lines, whole file in scope): module docstring (1 to 15); get_imap_config (45 to 76); save_imap_config (79 to 122); send_via_mailbox (125 to 157, out of this recipe's scope, cited for context, the outbound sibling); delete_imap_config (160 to 169); enabled_workspace_ids (172 to 185).
  • alchemy_labs/backend/services/scheduled_jobs.py: run_desk_email_intake (576 to 590); the broken job registration (465 to 469, see Gotchas).
  • alchemy_labs/backend/api/routers/workspace_router.py: ImapConfigPut (771 to 781); get_workspace_imap/put_workspace_imap/delete_workspace_imap/test_workspace_imap (784 to 876).
  • alchemy_labs/backend/models/service_desk_model.py: DeskEmailIngest (419 to 448); DeskAttachment (306 to 336, cited for context); DeskTicket (129 onward, cited for context).
  • alchemy_labs/backend/migrations/versions/20260906_0002_desk_email_ingest.py (whole file, 51 lines).
  • Tests: alchemy_labs/backend/tests/test_desk_email_threading.py (100 lines, whole file); alchemy_labs/backend/tests/test_desk_email_attachments.py (160 lines, whole file); tests/desk_support.py for the shared fixtures both use.

Deviations of the reference from an ideal implementation, all dated 2026-09-12: the scheduler registration does not compile (Gotchas); the IMAP session itself and the /imap routes are untested; DELETE and POST .../test accept any workspace member while PUT requires admin, an asymmetry that is not documented as deliberate anywhere in the file; poll_all has no per-workspace try/except of its own, relying entirely on poll_workspace's documented guarantee never to raise.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc4 with the docker-compose.test.yml postgres-test service up (localhost:5434). Expected observations are exact, as measured 2026-09-12.

  1. .venv/bin/python -m pytest tests/test_desk_email_threading.py tests/test_desk_email_attachments.py -q --no-cov -o addopts="" -p no:cacheprovider prints 10 passed.
  2. grep -c "^def test_" tests/test_desk_email_threading.py tests/test_desk_email_attachments.py prints 5 and 5.
  3. python3 -m py_compile services/scheduled_jobs.py fails with SyntaxError: '(' was never closed, naming line 465; python3 -m py_compile services/service_desk/email_intake.py services/service_desk/inbox_config.py api/routers/workspace_router.py succeeds with no output - confirms the break is isolated to the scheduler-registration file, not to this recipe's own logic.
  4. grep -n "^def poll_workspace\|^def poll_all\|^def _process_message" services/service_desk/email_intake.py prints lines 614, 716, 539.
  5. sed -n '465,470p' services/scheduled_jobs.py shows the desk_email_intake add_job(...) call with no closing paren and no replace_existing/coalesce/max_instances, unlike every other job in the file.
  6. git blame -L 465,470 services/scheduled_jobs.py (from a clone of alchemy_labs) shows the break introduced by commit f719cca3 on 2026-09-12, on top of the working block committed as ce8e328e on 2026-09-06.
  7. grep -rl "poll_workspace\|poll_all\|imaplib" tests/ (run from backend/) prints no file, confirming the IMAP session itself is untested at this commit.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.