Chapters
On this page
DOC-04 / Technical reference · Chapter 07
Inbox hub and Atlas Inbox: two distinct IMAP pipelines
This chapter describes the two IMAP collection pipelines of Synedre, one feeding the personal mailbox into `ps_ac_inbox_emails` via heuristic classification, the other processing the CodeMyShop agentic mailbox to trigger actions via LLM classification.
Overview — two distinct messaging pipelines
Synedre's messaging system relies on two entirely separate pipelines, with no join between them. Each has its own inbox, its own storage table, and its own processing logic. Confusing the two is the leading source of operational errors.
| Pipeline "Inbox hub" | Pipeline "Agentic inbox" | |
|---|---|---|
| IMAP mailbox | Personal contact mailbox (shared hosting) | Dedicated agentic mailbox (same host, separate folder) |
| Target table | Main inbox table (raw + classification) | Raw messages table and agentic workflow table |
| Active collector | Python collector scheduled every minute | Python collector scheduled every minute (unread folder) |
| Disabled implementation | Legacy server facade (disabled, see §2.1) | None — the Python collector has always been direct |
| Classification | Static heuristic (sender domain) | Language model — 6 possible intents |
| Purpose | Triage and display in the hub (bugs, MRR, priority) | Triggering actions: run, project, negotiation, advisory… |
| User interface | Hub inbox dial, skill /inbox |
Runs, negotiations, etc. dials |
Contact mailbox (IMAP, personal folder)
│
│ cron every minute — inbox hub collector
│ (read-only, searches last 7 days)
▼
Main inbox table
(heuristic classification: client / priority / MRR / spam)
│
▼ skill /inbox, hub dial
[DISABLED] Legacy server facade (POST sync)
disabled on 2026-05-23 — was saturating the event loop
Agentic mailbox (IMAP, dedicated folder)
│ cron every minute — agentic collector
│ (searches unread messages)
▼
Raw messages table ──1:1──► Agentic workflow table (status='received')
│
├─ attachments → antivirus scanner + heuristics
│ └─ result: safe / unsafe
▼
LLM classifier → detected intent
│
┌──────────┬────┴─────┬───────────────┬──────────┐
run question project negotiation advisory / noise
Pipeline "Inbox hub" — contact mailbox to main inbox table
A single active collector since May 2026
Since 23 May 2026, a single Python collector feeds the main inbox table. The legacy server facade has been permanently disabled.
-
Active Python collector — queries the contact mailbox in read-only mode via the internal IMAP connection facade. It searches messages from the last 7 days, processes them from most recent to oldest, and inserts each message with an
ON CONFLICT (imap_id) DO UPDATEclause: in the event of a duplicate, only empty fields are filled in (COALESCE); existing classification is never overwritten. The collector is triggered by the system scheduler every minute. - Legacy server facade — disabled — the former implementation, which exposed a synchronisation endpoint triggered by a scheduled shell script, was shut down on 23 May 2026. It caused IMAP timeouts and saturated the application server's event loop, making the hub unavailable. The corresponding line in the system scheduler is commented out and accompanied by an explicit warning.
There are therefore no two co-active paths: only one path is live (the Python collector), the other is dead. The read-only fallback skill (emergency IMAP fallback) never touches the main table in order to prevent any double insertion.
True insertion detection: the RETURNING clause distinguishes a genuine insertion from a backfill on a duplicate, making it possible to count new messages and ignored duplicates separately.
Heuristic classification — no language model here
Classification relies on a static heuristic applied to the sender address only (the subject line is not used):
- The address is compared against an internal table of known client domains (exact match on the address or on the domain); if it matches, the client name, priority, and MRR fields are populated.
- Otherwise, the address is tested against a list of typical spam markers (
newsletter,noreply,no-reply,mailer-daemon,postmaster,notifications@,updates@,marketing@…); if a marker matches, the message is classified as spam.
The initial status set is either spam or new.
The message body — in text/plain and text/html formats — is extracted in multipart fashion, explicitly ignoring parts flagged as attachments. Both bodies are persisted in full (without truncation).
Attachments not captured by the active flow. The active Python collector does not persist any attachments. This feature belonged to the now-disabled legacy facade; as things stand, the inbox hub attachment storage table is no longer populated.
IMAP connection credential management
The active collector does not read IMAP environment variables directly: it delegates this resolution to the internal connection facade, which reads credentials from the host machine's environment file. This file is the single source of truth and is never versioned in the repository.
Note: the various system components (outbound send facade, utility scripts, active collector) use slightly divergent variable names to refer to the IMAP password. When in doubt, the host machine's environment file takes precedence. The canonical name documented in the central configuration reference is distinct from the aliases used in certain legacy scripts.
Main inbox table structure
| Column | Type | Role |
|---|---|---|
| Internal identifier | integer PK | Auto-incremented primary key |
| IMAP identifier | varchar UNIQUE | Deduplication key — target of the ON CONFLICT |
| Sender (address, name), subject | varchar | Message metadata |
| Received date | timestamptz | |
| Client name, priority, MRR | varchar / int | Populated by heuristic classification |
| Bug flag, bug keyword, AI severity, AI summary | smallint / varchar / text | Bug triage — flag is set to 0 on initial insertion |
| Archive path | varchar | Not written by the active flow |
| Status | varchar | new, spam, etc. |
| Processing (agent, date, notes) | various | Manual or agentic processing annotations |
| Project link | integer FK | Association with an existing project |
| Plain-text body, HTML body | text | Full body, untruncated |
| Created / updated timestamps | timestamptz | Updated on each insertion or update |
The hub inbox dial exposes endpoints for listing messages, viewing an individual message, updating its status, viewing its attachments, linking it to a project, or triggering a manual synchronisation.
Pipeline "Atlas Inbox" — Smart Inbox
This pipeline automatically processes incoming emails from the platform's central mailbox in three steps chained within a single polling cycle: IMAP polling → attachment analysis → AI classification.
3.1 IMAP Polling
A polling automation runs every minute via the system scheduler. An exclusive lock mechanism ensures that only one instance runs at a time; a secondary watchdog, triggered every fifteen minutes, cleans up orphaned locks left by previous crashes.
The polling cycle follows these steps:
- Acquisition of a non-blocking lock — if the lock is already held, the cycle is silently skipped.
- Connection to the inbox via the mandatory email facade (all direct IMAP access is prohibited — see §3.5). The connection opens in read-write mode to allow the
\Seenflag to be set after processing. - Search for unread messages (
UNSEEN), capped at 50 messages per cycle (configurable). - Retrieval of each message body using
BODY.PEEK[]—PEEKmode avoids automatically marking the message as read; the\Seenflag is only set after a confirmed database write, guaranteeing idempotency: "unread = not yet processed", even in the event of a post-retrieval crash. - Database insertion into the email messages table, with duplicate resolution via
ON CONFLICT DO NOTHING. The body is truncated (plain text: 60,000 characters; HTML: 120,000 characters) to avoid overflow during database exchanges. - Insertion of a tracking row into the Atlas workflow table, with the initial status
received, also idempotent. - Automatic chaining (level 1): if the message contains attachments, antivirus analysis is triggered first (scan-first doctrine), then AI classification is called in all cases.
- Writing of an append-only audit event — with no personal data in plaintext: only a short hash (8 characters) of the sender address is recorded.
Internal anti-loop: emails sent from the mailbox itself, or whose subject reveals an internal pipeline origin, are marked as read and ignored without insertion, to prevent self-spawn loops. Legitimate re-forwards from the founder (client emails forwarded manually) are not affected by this rule — this flow is intentional.
Large payloads (email bodies) are passed through a temporary SQL file rather than injected directly into the command, to work around shell argument size limits.
Command-line interface:
python3 -m <inbox-polling> # full cycle
python3 -m <inbox-polling> --dry-run # polling without insertion
python3 -m <inbox-polling> --max 10 # limit to 10 messages
3.2 Attachment Analysis
When a message contains attachments, the analyser extracts them and submits them to an antivirus scan before any classification. The process follows these steps:
- Retrieval of message metadata (RFC822 identifier, presence of attachments) via a join between the workflow table and the messages table.
- Re-retrieval of the raw message via IMAP in read-only mode, by its
Message-IDidentifier. - Extraction of attached parts to an isolated temporary directory, with filename sanitisation to prevent path traversal attacks.
- Pre-scan rejection: files whose extension is considered executable or dangerous (
.exe,.scr,.bat,.cmd,.com,.vbs,.js,.jar,.msi,.dmg,.deb,.rpm,.ps1) or whose size exceeds 25 MB are immediately rejected with the verdictblocked. - Antivirus scan of each file via the security agent facade (see §5).
- The global verdict
attachments_safe = 1is granted only if at least one file has been scanned and none is suspicious, infected, or rejected. - In the event of failure,
attachments_safe = 0, the status moves tofailed, and files are moved to quarantine in an isolated directory. - The scan verdict (JSONB) and an audit event are persisted to the database.
3.3 AI Classification
Classification analyses the email content and assigns an intent that determines its downstream processing.
Scan-first guardrail (blocking)
If an email contains attachments whose scan verdict is present and indicates that the attachments are not safe, classification is refused and a classify_blocked_unsafe_attachments audit event is emitted. Tolerance: if the verdict is absent (attachments not yet scanned, or message with no attachment), classification may proceed.
Prompt injection protection
The email body is enclosed by explicit delimiter markers in the prompt submitted to the language model, accompanied by a warning stating that the content is email data and not an instruction. The body is truncated to 8,000 characters. The model output is constrained by a list of valid intents — even if the model hallucinates an out-of-list value, validation rejects it. Scope and recipient are never derived from the email body.
Pre-LLM short-circuit — internal re-forward
If the founder forwards to the Atlas inbox a summary whose origin is itself Atlas (detected by the sender address and subject prefix), the intent is forced to noise with a confidence of 1.0, before any call to the language model. This short-circuit avoids the costly triggering of a spawn on an email auto-generated by the pipeline itself.
Local OCR — prompt enrichment
After validation of the antivirus verdict (safe attachments), if image files are present, a local OCR engine (Tesseract) is triggered. The extracted text is added to the prompt context between dedicated markers. Two doctrine rules apply: (1) OCR text is never persisted to the database (potentially personal data — in-memory use only); (2) OCR is triggered after the antivirus gate, so that the content of an unverified attachment is never read.
Language model call
The AI routing configuration determines the provider and model to use (default: a lightweight model from a third-party AI provider). The call is made with a 30-second timeout and a structured JSON response. The AI provider API key is injected from the secure environment configuration — it never appears in plaintext in the code.
Output validation
The model output is validated: the intent must belong to the list of allowed values, and the confidence score must be between 0 and 1. If confidence is below 0.7, a classification_uncertain audit event is emitted. An automatic floor forces the intent to noise if confidence is below 0.5, independently of the audit threshold.
Post-LLM guardrails — two override rules
- False noise from founder with attachments: if the model returns
noisebut the email is a re-forward from the founder containing attachments, the intent is requalified toquestion— actionable content likely resides in an image that the lightweight model cannot analyse directly (local OCR partially addresses this). Audit event:noise_override_founder_attachment. - Existing client rerouted: if the intent is
negociationorconseilbut the sender address matches an active tenant in the database (matched against client email domains), the intent is requalified torunscoped to that tenant. Prospects not yet clients retain theirnegociationintent. Audit event:existing_client_guard.
Persistence
The Atlas workflow row is updated with the classified intent, confidence score, model name, textual reasoning, and the status moves to classified.
Intents and downstream materialisation
Six intents are recognised: run, chantier, question, noise, negociation, conseil. Each intent materialises at most one row in a dedicated table, idempotently:
| Intent | Action | Target table | Deduplication | Traced link |
|---|---|---|---|---|
run |
UPSERT | Runs table (source='atlas-inbox', trigger='email', optional scope if rerouted by the client guardrail) |
Unique constraint on the (reference type, reference id) pair filtered by source | Column id_run_linked |
question |
UPSERT | Questions table (status='pending') |
Idem | Column id_question_linked |
negociation |
INSERT | Negotiations table (status='nouveau') |
Guard on non-null link column | Column id_negociation_linked |
conseil |
INSERT | Advisory table (status='received') |
Guard on non-null link column | Column id_conseil_linked |
chantier |
Draft via CLI --link-chantier |
Projects table (status='draft') |
— | Column id_chantier_linked |
noise |
Total no-op | — | — | — |
For negociation and conseil intents, the company is derived from the sender address domain, except for generic domains (consumer webmail services such as Gmail, Outlook, Laposte, etc.) for which no company is inferred.
Intent chantier — manual materialisation: unlike other intents, project creation is not automatic at the end of classification. The status remains classified with intent='chantier'. Draft project creation is triggered manually via the CLI, after human review. This is a deliberate design choice (seven-step procedure doctrine).
Classifier command-line interface:
python3 -m <atlas-classifier> --id-atlas-email 42
python3 -m <atlas-classifier> --all-pending
python3 -m <atlas-classifier> --reclassify <id>
python3 -m <atlas-classifier> --link-chantier <id>
python3 -m <atlas-classifier> --report-uncertain
3.5 Email facade guardrail
This guardrail is a technical mechanism — not a mere convention — that structurally blocks all email access outside the authorised facade. It is a hook attached to the development agent, intercepting every shell command before execution.
Operation:
- Only commands invoking the authorised facade scripts (email client, IMAP synchronisation, direct IMAP access, SASU mailbox) pass through without being blocked.
- Any command containing direct calls to low-level email libraries (
smtplib,imaplib,SMTP_SSL,IMAP4_SSL,MIMEText,MIMEMultipart, etc.) or to banned parallel facades (send_immediate,send_email_quick) is blocked with an explicit error message returned to the agent.
This guardrail covers, in particular, attempts at inline Python sending or via a custom script outside the whitelist. It was put in place following a production incident: a direct send function bypassed IMAP, making sent emails invisible in the Sent folder and corrupting the encoding of special characters when passed through the shell. The hook structurally prevents this scenario from recurring.
3.4 Atlas workflow schema
The Atlas workflow table centralises the processing state of each email. The key columns are:
- Identifiers: primary key of the Atlas row, foreign key to the email messages table.
- Classification: classified intent, confidence score (numeric), model name, textual reasoning, overall status.
- Attachments: scan verdict (JSONB), boolean safety indicator.
- Downstream links: link columns to the project, run, question, negotiation, and advisory tables.
- Ship-via-email: authorisation token, issuance and consumption timestamps, consumer identifier, reference message identifier (
In-Reply-To). - Provenance:
source_typeandsource_metadatacolumns (JSONB) tracing the row's origin — automatic IMAP poll or manual re-forward — enabling the anti-loop short-circuit to distinguish a legitimate email from an auto-generated summary that has been re-forwarded.
Forensic audit: an append-only audit table records each significant event (action type, actor, JSON details, timestamp). JSON details never contain personal data in plaintext — only hashes, internal identifiers, error codes, and metrics.
Spawn and ship are two distinct automations:
- The spawn automation (cron every 5 minutes, with a 2-minute offset) triggers a headless agent for
chantierandquestionintents only — never fornoiseorrun.runintents are materialised in the dedicated table but do not trigger a spawn via this cron (downstream processing is out of scope).- The ship automation manages the email-based authorisation pipeline: it generates a single-use token, sends the authorisation email via the facade, then applies a triple anti-spoofing check (sender whitelist + DKIM +
In-Reply-To+ atomic token consumption) before executing the./shipcommand.Both automations are out of scope for this section.
Manual Attachment Extraction
A command-line tool allows attachments to be extracted from an email by its message identifier (not by position in the mailbox):
node <attachment-extraction-tool> <message-id> [output-folder]
The tool loads its configuration from environment files (IMAP host, login credentials, IMAP password). The default output folder is a temporary directory named after the message prefix. The MIME parser is multipart-aware and performs binary writes to preserve byte integrity.
Doctrine: extracted files must not be opened or analysed before the antivirus scan has returned a
cleanverdict (see next section). The inbox search skill lists attachments without extracting them, in order to enforce this scan-first principle.
Outbound Email Façade
A single façade centralises all professional email sending. Direct use of the standard SMTP sending library is prohibited outside this façade — all outbound communications must transit through it.
Draft → validation → send cycle
The façade implements a three-stage workflow:
- Draft creation (
--draft): the draft is written to disk in JSON format with statusdraft. A validation copy is automatically sent to the operator (never to the final recipient). - Review: the operator lists pending drafts (
--list), previews a full draft (--preview --draft-id <ref>), and may request a new validation copy (--validate --draft-id <ref>). - Actual send (
--send --draft-id <ref>): the email is delivered to the recipient and a copy is archived in the IMAP Sent folder. The draft reference (not a positional number) must be used to target a draft — a positional number changes as soon as an earlier draft is sent.
Main arguments
| Flag | Effect |
|---|---|
--to / --cc (repeatable or CSV) |
Recipients |
--subject |
Email subject |
--body |
Inline body |
--body-file |
Body from a UTF-8 file (recommended, avoids inline) |
--markdown |
Markdown body — automatically converted to HTML before sending |
--html |
Body already in HTML |
--attachment (repeatable) |
Attachments — immediate failure if a file is missing |
--draft-id <ref> |
Targets an existing draft for sending, preview, or re-validation |
The --draft command requires --to, --subject, and at least one body (--body or --body-file).
Enforced format: the --plain / --no-markdown option is rejected. The façade systematically converts the body to HTML before sending, which guarantees that the canonical HTML signature (carrying the logo) is always appended.
Automatic validation copy (show-before-send)
On each draft creation, the façade automatically sends a copy to the operator with the subject prefixed [TO VALIDATE → <recipient>]. The client email is not sent at this stage — a console message states this explicitly. An anti-loop safeguard prevents a validation copy from being sent if the recipient is already the validation mailbox.
This is the implementation of the show-before-send doctrine: the draft and send steps are never chained automatically; the operator validates the received copy before any --send.
Before displaying the draft, the façade queries the tone engine in order to show a contextual reminder about the register to adopt for this recipient. This call is best-effort and never blocks the workflow.
Actual send and archiving
The actual send is performed via SMTP, after which the façade copies the message to the IMAP Sent folder. The list of candidate folder names covers the common variants used by different hosting providers (OVH, Gmail, etc.). The copy is declared successful only after an explicit OK status from the IMAP server; if all folders fail, a non-blocking warning is raised — the SMTP email has been sent successfully. The canonical HTML signature is automatically appended to every send.
SMTP/IMAP configuration
All configuration (SMTP host, port, username, SMTP password, sender address, IMAP host, IMAP username and password) is read from environment variables at startup. A launch-time check lists any missing variables. Port 465 triggers SMTP over SSL mode.
Mandatory Antivirus Scan Before Opening (P0)
The antivirus analysis engine is operated by the Mitnick agent. No extracted attachment is opened or analysed before the verdict is clean. Cloud-based analysis (e.g. VirusTotal) is excluded for client data confidentiality reasons. Any bypass is prohibited.
Analysis layers
- Hard rejection of executables: any attachment whose extension matches a known executable type (
.exe,.dll,.so,.bat,.cmd,.ps1,.sh,.vbs,.js,.jar,.msi) is immediately classifiedinfected. - ClamAV analysis: the ClamAV binary is invoked in non-interactive mode. An exit code of 1 (signature found) triggers an
infectedverdict and immediately halts the chain. If ClamAV is not installed, the engine returns a descriptive error. - PDF heuristics: a regular-expression scan over the first 4 megabytes of the file detects high-risk PDF directives (embedded JavaScript, automatic actions, submission forms, rich media…) and produces a
suspiciousverdict. - Office macros: Office formats that allow macros are checked via the
olevbatool (best-effort) for automatic triggers and system calls — verdictsuspiciousif detected. - Archives: compressed archives are not recursively traversed and are classified
suspiciousby default, requiring manual review.
Clean result marker
When the final verdict is clean, the engine writes an atomic JSON marker file (containing the SHA-256 hash and timestamp) to a dedicated subdirectory alongside the attachment. Downstream processes operate in fail-close mode on the absence of this marker: no marker = not yet scanned = block. The write is atomic; if it fails, the verdict of the call remains correct — marker persistence is optional only.
Verdicts
| Verdict | Meaning | CLI exit code |
|---|---|---|
clean |
No signal — opening permitted | 0 |
suspicious |
Static signals — sandbox processing required | 1 |
infected |
ClamAV match or executable — DO NOT OPEN | 2 |
error |
Scan impossible (ClamAV unavailable, file missing…) | 3 |
The CLI exit code reflects the worst verdict across all analysed files. The scan engine can be invoked from the command line with a --json option for structured output, or against an entire directory.
In the inbound email processing pipeline, the scan engine sets a security flag on each attachment. The classifier refuses to run if this flag indicates unsafe attachments.
Doctrine — Zero Direct Client Communication (P0)
The system never communicates directly with clients. All external communication transits through the human operator.
Concrete safeguards:
- The sending façade never sends to the client when creating a draft. It writes the draft and sends only a validation copy to the operator. Only a
--send --draft-id <ref>triggered after explicit validation delivers the message to the final recipient. - A guard hook blocks any attempt at direct SMTP or IMAP sending from any script, outside the whitelist of authorised components. This technical safeguard makes the façade non-bypassable.
- The inbox classifier never derives the recipient or scope from an email body (protection against prompt injection). Draft replies remain in
pendingstatus for human validation. - Formal address forms are used systematically for all external contacts. Appointment scheduling is done via the Calendly link, never with a hard-coded time slot in an email.
Active Scheduled Tasks
| Frequency | Role | Pipeline |
|---|---|---|
| Every minute | Inbox synchronisation | IMAP collector → inbound email storage table (sole active collector) |
| Every minute | Atlas processing pipeline polling | IMAP read → Atlas processing table |
| Every 15 minutes | Orphaned Atlas lock monitoring | Lock watchdog |
| Every 5 minutes (with startup delay) | AI agent spawn in autonomous mode | Processing of chantier and question intents only — fixed whitelist |
Disabled: the legacy scheduled task that triggered synchronisation via the Nuxt façade web API has been commented out since 23 May 2026. It was saturating the application server event loop during IMAP/database timeouts. It is replaced by the external Python collector mentioned above. Do not re-enable it without having resolved the event loop saturation.
Known Pitfalls
- Two distinct storage spaces: the IMAP collection table (key: IMAP identifier) and the Atlas pipeline tables (key: account + message identifier) are two separate worlds. No join links them.
\Seenflag set after insertion: a crash between fetch and insertion leaves the email unread, and it will therefore be reprocessed on the next cycle. Idempotence is enforced by anON CONFLICTdatabase constraint.PEEKmandatory during Atlas retrieval, to avoid prematurely marking the message as read.--markdownrecommended for all client emails. Always use--body-filerather than inline--body.- Scan-first non-negotiable: opening an attachment before the
cleanverdict constitutes a P0-level architectural debt. - Docstring / reality divergence: the Atlas collector comment mentions "5 minutes" but the scheduled task runs every minute.
- Email anti-loop: the founder's address intentionally forwards messages to the Atlas mailbox. Only the Atlas mailbox is filtered as a self-sender. Do not add the founder's address to the self-sender list.
chantierintent: the classifier does not automatically create a chantier. This is an explicit CLI action (--link-chantier). This prevents orphaned chantiers from being created without a mission letter.