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 |
|
| 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):
| Route | Auth | Tenancy | Codes | Notes |
|---|---|---|---|---|
GET /{workspace_id}/imap (784 to 796) | get_current_user | IsolationService.verify_workspace_access - any member | 200 {"configured": false} when unset, else the config minus the password; 403 not a member | Never returns password_encrypted or the decrypted password (45 to 49) |
PUT /{workspace_id}/imap (799 to 815) | get_current_user | verify_workspace_access(..., required_role="admin") | 200 saved config (minus password); 403 non-admin | Body ImapConfigPut (771 to 781); a re-save without password keeps the stored one (103) |
DELETE /{workspace_id}/imap (818 to 829) | get_current_user | verify_workspace_access - any member, not admin | 200 {"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_user | verify_workspace_access - any member | 200 {"ok": true, "folder", "message_count"} or {"ok": false, "error"}, always 200 | Live 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:
| Function | Commits its own work | Never raises | Returns |
|---|---|---|---|
poll_workspace(db, workspace_id) (614 to 713) | yes, per message | yes (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_workspace | yes, 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 itself | one of "created"|"threaded"|"dupe"|"noise"|"off_domain" |
Application logic
poll_workspace, in commit order (614 to 713):
- Resolve
workspace_idto a UUID; a bad id is logged and returns empty stats, nothing else runs (624 to 629). - 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).
- 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_id352 to 356). imaplib.IMAP4_SSL(host, port), login, select the configured folder,UID SEARCH ALL(643 to 649).- 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\Seenso it is not re-fetched; on"dupe"leave it alone (672 to 686). - Stop after
MAX_PER_RUN=200created-or-threaded messages in one run; the remainder waits for the next tick, safe because of the dedup guarantee (652 to 658,MAX_PER_RUN46 to 49). - Any per-message exception rolls the session back and moves to the next UID rather than aborting the run (687 to 697).
- 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):
- Parse the raw bytes, pure, no IO, into from/subject/body/message_id/reference_ids/attachments (546,
parse_email257 to 279). - Dedup:
message_idalready recorded for this workspace →"dupe", nothing else runs (549 to 550,_already_ingested287 to 295). - 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_idNULL, commit,"noise"(358 to 375, 554 to 557). - 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). - Thread by header: any
In-Reply-To/Referencesid that is itself adesk_email_ingestrow with a non-nullticket_idwins (570,_thread_ticket_id298 to 311). - 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_id314 to 331,extract_ticket_keys218 to 245) - the DTH-0016 fix (module docstring 20 to 26;test_desk_email_threading.py1 to 7). - If either threading step found a ticket: append the mail as an inbound message, release
waiting_on_clientback toin_progressif 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_email528 to 562). - 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_email429 to 525). - 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=Trueand the sender's domain was accepted - email any Nachfragen back and park the ticket onwaiting_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_ingestedis 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_idNULL, 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
Referencessurvived 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 partsget_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.pydoes not compile.python3 -m py_compile backend/services/scheduled_jobs.pyfails withSyntaxError: '(' was never closedat line 465 - thescheduler.add_job(...)call registeringrun_desk_email_intake(465 to 469) is missing its closing parenthesis and itsreplace_existing/coalesce/max_instanceskwargs (every sibling job in the file carries all three); the very next job'sscheduler.add_job(run_device_code_sweep, ...)call (472) is swallowed as a continuation of the same expression instead of starting a new statement.git blameshows 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). Becauseinit_scheduler()is called from a broadexcept Exceptioninapp_fastapi.py's startup (319 to 324), andSyntaxErroris a subclass ofException, the app does NOT crash on boot - it logs "Failed to start APScheduler: ..." and continues with every job in this file unregistered, not onlydesk_email_intake. A workspace can still connect and test a mailbox (the/imaproutes live in a different, unaffected file) but no periodic poll currently runs to act on it. This is whymaturityisbrokenrather thanprod: 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 indesk_email_ingest, so their Message-ID was never something a reply could match, and (b) Outlook is observed to drop theReferencesheader on reply. The[PREFIX-N]subject-stamp fallback (218 to 245) is the fix; the second half oftest_desk_email_threading.pyexists 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/imapconfig 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_ext104 to 117,_extract_attachments120 to 152); body extraction (_strip_html155 to 168,_best_body171 to 193); id helpers (strip_message_id196 to 202,extract_reference_ids205 to 215,extract_ticket_keys218 to 245,synthesize_message_id248 to 254);parse_email(257 to 279); dedup/threading DB helpers (_already_ingested287 to 295,_thread_ticket_id298 to 311,_subject_thread_ticket_id314 to 331,_record_ingest334 to 344); noise filter (_is_noise_sender358 to 375); attachment persistence (_store_attachments387 to 428,_store_and_scan431 to 462); AI enrichment (_enrich_email_ticket465 to 536); routing (_process_message539 to 611); poll (poll_workspace614 to 713,poll_all716 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.pyfor 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.
.venv/bin/python -m pytest tests/test_desk_email_threading.py tests/test_desk_email_attachments.py -q --no-cov -o addopts="" -p no:cacheproviderprints10 passed.grep -c "^def test_" tests/test_desk_email_threading.py tests/test_desk_email_attachments.pyprints5and5.python3 -m py_compile services/scheduled_jobs.pyfails withSyntaxError: '(' 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.pysucceeds with no output - confirms the break is isolated to the scheduler-registration file, not to this recipe's own logic.grep -n "^def poll_workspace\|^def poll_all\|^def _process_message" services/service_desk/email_intake.pyprints lines614,716,539.sed -n '465,470p' services/scheduled_jobs.pyshows thedesk_email_intakeadd_job(...)call with no closing paren and noreplace_existing/coalesce/max_instances, unlike every other job in the file.git blame -L 465,470 services/scheduled_jobs.py(from a clone ofalchemy_labs) shows the break introduced by commitf719cca3on 2026-09-12, on top of the working block committed asce8e328eon 2026-09-06.grep -rl "poll_workspace\|poll_all\|imaplib" tests/(run frombackend/) 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.