Email Bot Documentation
How the YieldExchange email bot ingests repo postings and offers from traders’ inboxes, validates them, and pushes them into the V2 negotiation API — covering architecture, every flow, configuration, and operational runbooks.
Audience: engineers maintaining the bot & admins operating the platform. Last updated when the running build was deployed.
Overview
The Repo Bot is an email-first interface to the YieldExchange platform. A trader sends a plain-English email like“Borrow 500M CAD at 4.85% from June 15 to June 22, Govt Bonds”and the bot interprets it, asks for anything missing, summarises the quote for confirmation, and on CONFIRM submits the RFQ to V2 — exactly as if the user had filled the web form themselves.
It exists because:
- Traders live in Outlook / Gmail. Forcing a web UI for fast-moving repo flow loses adoption.
- Email already carries enough structure for an LLM to extract every field.
- The bot reuses the V2 schema 1:1 — no parallel data model, no drift.
Two flows, one inbox
| Flow | Who initiates | What the bot does |
|---|---|---|
| Side A — Posting | Bank trader (initiator) | Collects fields, validates, posts an RFQ to V2 |
| Side B — Offer | Counterparty (responder) | Identifies which RFQ, collects offer terms, submits the offer |
| Browse | Either side | Lists currently active RFQs from V2 for the user to reply on |
Architecture
The bot is a Python (FastAPI) service that runs alongside the V2 stack in the same Docker Compose network. There are no extra databases for application data — V2 owns all RFQs, offers, orgs and users. The bot only stores conversational state.
┌──────────────┐ IMAP IDLE ┌──────────────────────────┐
│ Trader's │ ───────────────────────▶│ Repo Bot (FastAPI) │
│ inbox │ │ Python 3.12 │
│ (Gmail/IMAP)│ ◀───────────────────────│ │
└──────────────┘ SMTP reply │ • IMAP poller (IDLE) │
│ • PII tokenizer (5-layer│
│ • LLM extractor (OpenAI │
│ gpt-4o-mini, JSON) │
│ • State machine (FSM) │
│ • V2 client (httpx, JWT)│
│ • Bot admin dash (Next) │
│ • SQLite/MySQL (state) │
└──────────┬───────────────┘
│ HTTPS + JWT
│ (lookup, recipients,
│ RFQ create, offers)
▼
┌──────────────┐ Session JWT ┌────────────────────────────────┐
│ V2 dashboard │ ─────────────────────▶│ V2 API Gateway (Express) │
│ (Next.js) │ (Org admin uses ├────────────────────────────────┤
│ Org admin │ Integrations tab to │ • reference-data-service │
│ controls │ toggle botAccess + │ ↳ /organizations/lookup │
│ Integrations│ bot-members + │ ↳ /organizations/:id/bot-* │
│ tab here │ allowed-domains) │ ↳ permission cache (Redis) │
└──────────────┘ │ • negotiation-service │
│ • notification-service │
│ • audit-service │
└──────────────┬─────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌─────────┐
│ MySQL │ │ Redis │ │ audit │
│ (V2 DB)│ │ (sess + │ │ log │
│ │ │ notif.) │ │ (DB) │
└─────────┘ └──────────┘ └─────────┘Where each thing lives
Repos & container names
| Component | Repo path | Container | Port |
|---|---|---|---|
| Bot service | ye-repo-bot/bot/ | ye-repo-bot-bot-1 | 8001 |
| Admin dashboard | ye-repo-bot/dashboard/ | ye-repo-bot-dashboard-1 | 8000 |
| V2 API Gateway | V2-APP-DASHBOARD/services-v2/ | api-gateway | 4000 |
| Reference-data | services-v2/reference-data-service/ | reference-data-service | 4001 |
| Negotiation | services-v2/negotiation-service/ | negotiation-service | 4002 |
| Notification | services-v2/notification-service/ | notification-service | — |
| Bot DB | embedded (SQLite/MySQL) | ye-repo-bot-bot-1 | — |
| V2 DB | MySQL | mysql_v2app_bak | 3306 |
Quick start
Bring the whole stack up locally and send your first email to the bot.
1. Start everything
# V2 stack (api-gateway, services, MySQL, Redis)
cd V2-APP-DASHBOARD
docker compose up -d
# Bot + dashboard
cd ../ye-repo-bot
docker compose up -d
# Verify
docker ps --format 'table {{.Names}}\t{{.Status}}' \
| grep -E 'ye-repo-bot|api-gateway|reference-data|negotiation'2. Sign in to the admin dashboard
Open http://localhost:8000, sign in with the admin credentials from the bot’s .env (ADMIN_EMAIL / ADMIN_PASSWORD), then enter the OTP that arrives at ADMIN_OTP_EMAIL.
3. Send a test email
From a registered V2 user (e.g. [email protected]), email the bot inbox (defaults to [email protected]) with:
Subject: New repo posting Borrow 500M CAD at 4.85% from 2026-06-15 to 2026-06-22, Govt Bonds.
You should receive a confirmation summary within ~30 seconds. Reply CONFIRM and the posting goes live in V2 — counterparties on bot-enabled orgs receive a notification email.
Org onboarding
How a firm goes from “not on the email bot” to “trader sends an email and gets a confirmation in 30 seconds” — and exactly what V2 and the bot do under the hood.
Two-layer access control
Every inbound email must pass both gates before the bot will engage:
| Layer | Where it lives | Who controls it |
|---|---|---|
1 Org master switch (botAccess) | organizations.botAccess | Org Admin — opts the whole firm in or out |
2 Per-member opt-in (botEnabled) | user_organizations.botEnabled | Org Admin — opts each individual user in or out |
Why two layers?
The admin journey in V2 (three clicks)
- 1
Open Organization → Integrations
An ORG_ADMIN navigates toSettings → Organization → Integrations. Non-admins see the same page in read-only mode. - 2
Flip the master switch
Toggle Email Bot Integration on. This callsPATCH /api/organizations/:id/bot-access, requires permissionORG_SETTINGS_MANAGE, and writes an audit log entry (BOT_ACCESS_TOGGLE). - 3
Enable specific members
The Members table appears with a toggle next to every active member. Flip the ones who should be able to email the bot. Each toggle callsPATCH /api/organizations/:id/bot-members/:userOrgIdand writesBOT_MEMBER_TOGGLEto the audit log.Enable all/Disable allbuttons batch-update viaPOST /api/organizations/:id/bot-members/bulk. - 4
(Optional) Whitelist extra sender domains
If traders sometimes email from a sibling domain (e.g.td-securities.comwhen they're registered undertd.com), add it to the allowed-domains list. Saved viaPATCH /api/organizations/:id/allowed-domains. Up to 10 domains; format-validated server-side.
What gets persisted
-- Migration: 20260514000000-add-bot-per-user-toggle-and-domains ALTER TABLE user_organizations ADD COLUMN botEnabled BOOLEAN NOT NULL DEFAULT false; ALTER TABLE organizations ADD COLUMN allowedDomains JSON NULL DEFAULT NULL; -- shape: ["td.com", "td-securities.com"] (max 10 entries)
Defaults are deliberately opt-in: when orgbotAccess is flipped on, no member is automatically enabled. The admin must explicitly grant each one (or clickEnable all). New invitees default to disabled. This is the compliance-safe default — no surprise activations.
Permission model
All four mutation endpoints are guarded by V2’s shared requirePermission(Permissions.ORG_SETTINGS_MANAGE) middleware. The permission cache lives in Redis (ye:perms:{userOrgId}) and is populated at login. By default ORG_SETTINGS_MANAGE is granted toORG_ADMIN only — Senior Traders, Traders, and Read-Only users can see the page but can’t make changes.
// services-v2/reference-data-service/src/controllers/bot-config.controller.ts
botConfigRouter.patch(
'/:orgId/bot-members/:userOrgId',
requirePermission(Permissions.ORG_SETTINGS_MANAGE),
async (req, res) => { /* ... */ },
);Email → org → user, with the new gates
Every inbound email walks the same five-step chain. New steps in bold:
GET /api/organizations/[email protected] 1. users.email = '[email protected]' → 404 if not found 2. user_organizations: active membership → 404 if not found 3. organizations: row exists → 404 if not found 4. org.botAccess = true → 403 "org_not_enabled" 5. userOrganization.botEnabled = true ← NEW → 403 "user_not_enabled" 200 OK → bot engages
The 403 sub-code in the response body tells the bot which gate rejected the sender, so it can pick the appropriate reply template.
| Response code | Bot reply |
|---|---|
404 | “We couldn’t find a YieldExchange account for this address — please email from your registered address or ask your admin to set you up.” |
403 org_not_enabled | “Your firm hasn’t enabled the email bot yet. Ask an admin to flip it on under Org Settings → Integrations.” |
403 user_not_enabled | “Your firm has enabled the bot, but you specifically need admin approval. Ask them to enable you in the Members allowed to use the bot list.” |
200 | Bot proceeds with extraction and the rest of the flow. |
Allowed domains (what they do today, what they will do)
The allowed-domains list is saved per org but isn’t consulted by the bot lookup yet. The current behaviour is “exact email match against the users table.” The plumbing is in place so a future v1.1 can fall back to domain-matching for guest senders, with a per-org policy decision. We didn’t ship that in v1 because attributing emails to an org without a specific user opens audit/compliance questions worth a separate review.
Coming, not shipped
Audit trail
Every action emits an audit-service entry via the shared emitAuditLog helper. Compliance can reconstruct the full history of who enabled whom, when, from what IP.
| Action | entityType | details.subType |
|---|---|---|
Toggle org master | ORGANIZATION | BOT_ACCESS_TOGGLE |
Toggle one member | USER_ORGANIZATION | BOT_MEMBER_TOGGLE |
Enable / disable all | ORGANIZATION | BOT_MEMBER_BULK_TOGGLE |
Update allowed domains | ORGANIZATION | BOT_ALLOWED_DOMAINS_UPDATE |
V2 Integrations UI
Every control on the V2 dashboard’s Settings → Organization → Integrations tab — what it does, what backend call it makes, what gets persisted, and what audit entry it writes.
Where to find it
On the V2 dashboard, navigate to the user profile menu → Settings → Organization. The page has a tab strip; pick Integrations. Anyone in the org can open the page; only members with ORG_SETTINGS_MANAGE can change anything (typically ORG_ADMIN).
Anatomy of the page
┌──────────────────────────────────────────────────────────────────────┐ │ ✉ Email Bot Integration [ ⬤ ] │ ← (1) │ Master switch for your organisation. Members must also │ │ be individually enabled below. │ │ [Active — Members listed below who are enabled can email…] │ ├──────────────────────────────────────────────────────────────────────┤ │ Members allowed to use the bot [Enable all] [Disable all] │ ← (2) │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ NAME EMAIL ROLE BOT ACCESS │ │ │ │ Ravi Sumal [email protected] ORG_ADMIN On [⬤] │ │ │ │ Ernest Kiromo [email protected] TRADER Off [○] │ │ │ │ Sarah Chen [email protected] SENIOR_TRADER On [⬤] │ │ │ └────────────────────────────────────────────────────────────────┘ │ ├──────────────────────────────────────────────────────────────────────┤ │ Additional allowed sender domains │ ← (3) │ ⌗ td.com ⌗ td-securities.com ⌗ tdam.com │ │ [Pick from detected ▾] [td-asset-mgmt.com........ ] [+ Add] │ │ 3/10 domains [Cancel] [Save] │ └──────────────────────────────────────────────────────────────────────┘
(1) Master switch — Email Bot Integration
| Field | Value |
|---|---|
| What it does | Gates every inbound email from this organisation. When off, no one in the org can use the email bot — even members who are individually enabled. |
| When you flip it | UI calls PATCH /api/organizations/:orgId/bot-access with { enabled: true }. |
| What gets written | organizations.botAccess = the new value. No member rows change. |
| Audit log | entityType=ORGANIZATION, action=UPDATE, details.subType=BOT_ACCESS_TOGGLE, includes from + to and the actor’s userOrgId. |
| UI state | Switch is optimistic — flips instantly, rolls back with a toast if the API rejects. Disabled with reduced opacity for non-admins. |
| Side effects on the bot | Immediate. The next email the bot processes for any user in this org will see the new value (no cache; the bot calls V2 fresh each time). |
| Effect on existing threads | None — in-flight threads continue running. The next inbound message on those threads still hits the lookup gate, so disabling mid-flight will halt further turns. |
(2) Members table — per-member bot access
| Column | What it shows |
|---|---|
| Name | Display name from the user profile. |
| The exact address the bot uses for lookup. Senders emailing from a different address get a 404 reply (even with the right human behind it). | |
| Role | Badge with the user’s V2 role (e.g. ORG_ADMIN, TRADER). Shown for context — it does not affect bot access. |
| Bot access | The per-member toggle. Flipping it persists immediately. |
What each control on the Members table does
| Control | Backend call | DB write | Audit |
|---|---|---|---|
| Row toggle | PATCH /api/organizations/:orgId/bot-members/:userOrgId | user_organizations.botEnabled = enabled | USER_ORGANIZATION / BOT_MEMBER_TOGGLE |
| Enable all | POST /api/organizations/:orgId/bot-members/bulk body { enabled: true } | UPDATE all active memberships in this org → botEnabled=1 | ORGANIZATION / BOT_MEMBER_BULK_TOGGLE with affected count |
| Disable all | POST /api/organizations/:orgId/bot-members/bulk body { enabled: false } | UPDATE all active memberships in this org → botEnabled=0 | ORGANIZATION / BOT_MEMBER_BULK_TOGGLE |
Why the table is disabled when the master is off
botEnabled values are preserved, so flipping the master back on restores the previous per-member state without re-toggling everyone.(3) Additional allowed sender domains
| Field | Behaviour |
|---|---|
| Chip list | Each saved domain becomes a chip with the domain in monospace and a remove-X. Click X to mark for removal; the change isn't saved until you press Save changes. |
| Pick from detected ▾ | Surfaces domains the system inferred from member emails that the admin hasn’t whitelisted yet. Backend returns these in GET /bot-config as suggestedDomains. |
| Free-text input | Accepts any RFC-1035 domain (no protocol, no wildcards). Enter or comma commits. Format-validated with a regex; invalid input shows a toast. |
| Limits | Max 10 domains per org. Duplicates rejected with a toast. Empty list is allowed (cleared on save → DB stores NULL). |
| Save / Cancel buttons | Appear only when the list is dirty (differs from server state). Save fires PATCH /allowed-domains; Cancel resets the draft to the server value. |
| Backend | PATCH /api/organizations/:orgId/allowed-domains with { domains: string[] }. |
| Audit | ORGANIZATION / BOT_ALLOWED_DOMAINS_UPDATE with from and to arrays so a diff is reconstructable. |
| Current effect on the bot | None yet. The list is saved and ready, but the bot's lookup still does exact-email matching only. The v1.1 follow-up will consume this list (see the Allowed domains note above). |
Read-only mode (non-admins)
Members who lack ORG_SETTINGS_MANAGE still see the whole page — useful for traders sanity-checking whether their own row is enabled — but every control is disabled and an info banner explains why:
┌──────────────────────────────────────────────────────────────────────┐ │ ⓘ Read-only view │ │ You can see the current configuration but only an Org Admin │ │ can change it. │ └──────────────────────────────────────────────────────────────────────┘
- Master switch: visible, disabled, shows current state.
- Member rows: each toggle shows the current state but won’t respond to clicks.
- Domain chips: remove-X icons are hidden; the input + Add button are absent.
- If a non-admin still tries via the API directly, the server returns
403 Insufficient permissionsfromrequirePermission.
What happens behind a single click
Sequence diagram for “Org admin flips Ernest’s row from Off to On”:
Admin clicks ⬤ in the row for [email protected] │ ▼ [React] IntegrationsTab.handleMemberToggle(member) • setMemberState({ ...prev, [member.userOrgId]: true }) ← optimistic • updateBotMember.mutate({ orgId, userOrgId, enabled:true }) │ ▼ [RTK Query] PATCH /api/organizations/:orgId/bot-members/:userOrgId • Authorization: Bearer <jwt> • body: { enabled: true } │ ▼ [api-gateway → reference-data-service] • authMiddleware decodes JWT → req.user.userOrgId • requirePermission(ORG_SETTINGS_MANAGE) ├─ reads ye:perms:{userOrgId} from Redis ├─ if missing → 401 PERMISSION_CACHE_EXPIRED → client refreshes └─ if permission missing → 403 Insufficient permissions • controller: ├─ UserOrganization.findOne({ id: userOrgId, orgId }) → 404 if not found ├─ membership.update({ botEnabled: true }) └─ emitAuditLog({ entityType:'USER_ORGANIZATION', action:'UPDATE', details:{ subType:'BOT_MEMBER_TOGGLE', from:false, to:true, userId: membership.userId } }) • returns 200 { userOrgId, botEnabled:true, unchanged:false } │ ▼ [RTK Query] invalidatesTags: [{ type:'BotConfig', id:orgId }] • the GET /bot-config query is auto-refetched in the background • server returns the fresh members list — UI reconciles │ ▼ [Toast] "Enabled the email bot for Ernest Kiromo" │ ▼ Next email from [email protected] lookup → bot proceeds. On failure anywhere above, the optimistic local state is rolled back and a "Failed to update Ernest — please try again" toast appears.
User journeys
End-to-end stories — what an org admin / trader / auditor / compliance officer actually does, what they see in the UI, what the bot replies, and what gets recorded. Six representative scenarios covering setup, day-2 ops, and incident response.
1 · First-time firm onboarding
Persona: Ravi, Org Admin at TD. Goal: get TD’s desk on the email bot.
- 1
Sign in to V2
Ravi logs intoapp.yieldexchange.ca. The session-context cache (ye:perms:{userOrgId}) is populated in Redis on completion of the OTP step. - 2
Open Integrations tab
Settings → Organization → Integrations. Sees the master switch (off), an empty Members table (no rows enabled yet), and no allowed domains. - 3
Flip the master
Toggles Email Bot Integration on. Toast: “Email bot enabled for TD Bank”. Members table un-greys. - 4
Enable the desk
Decides which traders should be in the v1 pilot. Toggles three rows: himself, Ernest, Sarah. Each toggle fires a separate audit entry. - 5
(Optional) Add a sibling domain
Sarah’s registered email is[email protected]but she emails from[email protected]. Ravi addstd-securities.comto allowed domains (saved, not consumed by lookup until v1.1). - 6
Trader sends test email
Sarah forwards a quote to[email protected]. Within ~30s she gets a confirmation summary from the bot. After replying CONFIRM the RFQ is live on V2; counterparties on other bot-enabled orgs (with at least one opted-in member) receive notifications.
2 · A new trader joins TD
Persona: Maya, recently promoted to a junior repo trader. Pre-condition: TD already has the email bot enabled and Ravi as Org Admin.
- 1
Ravi invites Maya in Members tab
Standard V2 invite flow. Maya appears in the org with roleTRADERandbotEnabled=falseby default. - 2
Maya signs in for the first time
She completes OTP, sees the trading dashboard. Nothing in V2 hints at her bot-access state — that’s by design (the toggle is the admin’s call, not a self-service feature for v1). - 3
Maya emails the bot too early
She tries the bot from her work address. The bot callslookup; V2 returns 403 withcode: user_not_enabled. The bot replies with the Personal Bot Access Required template — points her at her Org Admin, names her firm, and explains the exact toggle her admin needs to flip. - 4
Maya pings Ravi
The reply email is the entire script — Ravi knows exactly what to do without a back-and-forth. - 5
Ravi enables her
Opens Integrations, flips Maya’s row to On. Audit entry recorded with subTypeBOT_MEMBER_TOGGLE, from=false, to=true. - 6
Maya retries
She resends the email. Lookup passes both gates this time → bot extracts fields → confirms → submits. Total elapsed: minutes, no support ticket.
3 · A trader leaves the firm
Persona: Ernest leaves TD on Friday. Goal: make sure no email he sends Monday morning can reach the V2 platform.
- 1
HR offboarding pings the platform admin
Ravi gets the same ticket he'd get for any V2 access removal. Two separate actions: revoke V2 access AND revoke email-bot access. - 2
Flip bot toggle off first
Integrations → Ernest’s row → toggle Off. This takes effect immediately for the next inbound email — even before Ernest’s V2 account is fully closed. - 3
Then close the V2 account (Members tab)
Standard V2 deactivation. Membership status flips toSUSPENDEDor the user is removed. - 4
Verify on Monday
If Ernest tries to email from his still-active corporate inbox, lookup returns 404 (user removed) and the bot replies “Account Not Found”. Audit log shows the exact off-toggle that preceded the deactivation.
4 · “The bot stopped replying!” — a trader debugs herself
Persona: Sarah, senior trader, used the bot yesterday and today it’s silent.
- 1
Sarah sends another posting
No silence — within seconds she gets an automated reply from the bot. - 2
Read the reply, not the headline
The bot’s reply is one of three diagnostic templates depending on the failure mode:- Account Not Found — her email isn’t registered (typo / wrong inbox).
- Email Bot Access Not Enabled — TD turned the master off (probably an admin emergency).
- Personal Bot Access Required — TD is still on but her specific toggle was flipped off (probably a leaver-script ran wrong).
- 3
Forward the reply to Ravi
The reply is self-contained — Ravi sees which gate failed without opening V2 or the audit log. - 4
Ravi fixes
Either flips her toggle back on (per-member case) or flips the master back on (org case). The fix is one click and there is an audit row for both the original off and the restore.
5 · Compliance kill switch
Persona: a Compliance Officer needs to disable inbound trading for the whole firm now (e.g. a regulatory incident, suspected account compromise).
- 1
Open Integrations as Org Admin
Compliance contacts the Org Admin (or has the role themselves). Settings → Organization → Integrations. - 2
Flip the master switch off
One toggle. Takes effect on the next inbound email — typically within seconds. No member rows change, so re-enabling later restores the exact same per-member configuration. - 3
Audit row is the legal record
entityType=ORGANIZATION,action=UPDATE,subType=BOT_ACCESS_TOGGLE, with timestamp, actoruserOrgId, IP, user-agent, and the before/after values. Joinable with the regulatory case ID via the audit-service. - 4
Bot replies during the freeze
Any email sent during the freeze gets the Email Bot Access Not Enabled reply — clear, non-alarming, pointing at the admin. No deals slip through. - 5
Restore
When cleared, the admin flips the master back on. All previously-enabled members are still individually enabled. Two audit entries total: off, on.
6 · Auditor reconstructs a year of access changes
Persona: a compliance auditor preparing for SOC 2 / regulatory review.
- 1
Query the audit-service
Filters onentityType IN (ORGANIZATION, USER_ORGANIZATION)ANDdetails.subType LIKE ‘BOT_%’. Returns every relevant change. - 2
Reconstruct master-switch history per org
FromBOT_ACCESS_TOGGLEentries: orgId, timestamp, actor, before, after. Builds a per-org timeline of when the firm had bot access on. - 3
Reconstruct per-member history
FromBOT_MEMBER_TOGGLEentries: which user was enabled when, by whom.BOT_MEMBER_BULK_TOGGLEentries give the count + acting principal but not per-row breakdown (intentional — bulk is an admin convenience, not a fine-grained event). - 4
Cross-check with email events
For any inbound email the bot rejected, the bot’s logs show the exact 403 sub-code (org_not_enabled/user_not_enabled) and the lookup timestamp — matchable to the audit timeline. - 5
Output report
Every access grant and revoke for the period, with actor + IP + before/after, exportable as CSV/JSON via the audit-service's standard endpoints.
What this design buys you
Side A — Posting flow
What happens when a trader emails a new repo posting.
- 1
Inbound email arrives
The IMAP poller (idling on the bot inbox) wakes up, fetches the message, deduplicates against theprocessed_messagestable. - 2
Sender is resolved
The bot callsGET /api/organizations/lookup?email=…on V2. The endpoint checks two gates: (1) orgbotAccess=trueand (2) userbotEnabled=true. If either fails, the bot sends the appropriate reply (org-level vs user-level) and closes the thread. See Org onboarding for the full chain. - 3
PII is tokenised
The body is run through the 5-layer tokeniser — names, emails, phones, and any unknown ORG are replaced with placeholders before the text reaches the LLM. See PII tokenisation. - 4
Intent + fields extracted
An OpenAI call (gpt-4o-mini, JSON schema response) classifies the intent (POSTING, OFFER, BROWSE, CONFIRM, CANCEL) and extracts every field present. - 5
Validate locally
The bot’s validator (bot/v2/validator.py) catches problems V2 would otherwise reject — bad currency, past start date, amount < $100k, missing collateral — and turns them into a clear reply. - 6
Collect anything missing
If any required field is absent the bot replies with an adaptive ask: full helper card on the first turn, concise “still need rate, dates” on follow-ups. The LLM message and the static template share a single opener (no duplicate greetings). - 7
Summarise & confirm
Once everything is present, the bot sends a styled summary email. The thread state isPENDING_CONFIRMATIONuntil the user repliesCONFIRMorCANCEL. - 8
Submit to V2
OnCONFIRM, the bot maps fields to the V2 shape (collateral normalised, direction translated, dates serialised), callsPOST /api/requests, and stores the returnedrequest_idon the thread. - 9
Notify counterparties
The bot callsGET /api/organizations/bot-recipients?excludeOrgId=…which returns active, individually-opted-in users in other orgs whosebotAccessis on. Each gets a counterparty-alert email. Users in bot-enabled orgs who haven’t themselves been toggled on are not notified. - 10
Thread closes
The thread transitions toCOMPLETE. Further replies on the same thread won’t accidentally resubmit.
Side B — Offer flow
When a counterparty wants to respond to an active RFQ, they reply to the bot’s counterparty-alert email (or start a fresh thread quoting the request ID).
- 1
Identify the RFQ
The bot resolves which RFQ the reply refers to from the email’sIn-Reply-Toheader, then fetches the latest RFQ details viaGET /api/requests/:id. - 2
Extract offer terms
The LLM extractor uses a different schema for offers —rate,max_amount,proposed_collateral,proposed_settlement_date,notes. - 3
Collect missing fields
If onlyrateis present, the bot asks for the optional fields with hints (max amount, settlement date, collateral, free-text notes). - 4
Summarise the quote
The offer-summary email shows both sides — the RFQ’s indicative rate and the counterparty’s quoted rate. - 5
Submit the offer
OnCONFIRM, the bot callsPOST /api/requests/:id/offers, stores theoffer_id, and replies with a submission confirmation including a dashboard link.
Bulk uploads (text + CSV)
A user can post several repos in one email — either by listing them in the body or by attaching a CSV. Both paths converge on the same internal pipeline.
Text bulk
Please post these 3 repos: 1) Borrow 500M CAD at 4.85% from 2026-06-15 to 2026-06-22, Govt Bonds 2) Lend 250M USD at 5.10% from 2026-06-15 to 2026-06-22, T-Bills 3) Borrow 100M EUR at 3.95% from 2026-06-15 to 2026-06-22, Corp Bonds
extract_bulk_postings() runs against the tokenised body (LLM with structured JSON schema), returning one object per posting. A regex backfill then runs over each row’s source slice to recover any collateral the LLM may have dropped — this is a safety net for known multi-row LLM flakiness.
CSV attachment
direction,amount,currency,rate,start_date,end_date,collateral_type BORROW,500000000,CAD,4.85,2026-07-01,2026-07-08,Govt Bonds LEND,250000000,USD,5.10,2026-07-01,2026-07-08,T-Bills BORROW,1B,EUR,3.95,2026-07-01,2026-07-08,Corporate Bonds
The CSV parser is forgiving: 1B / 500M expand, collateral synonyms normalise (Govt Bonds →Government Bonds), date formats accept YYYY-MM-DD, DD/MM/YYYY, etc. Up to one CSV attachment is processed per email; additional attachments are logged and ignored.
Validation feedback is actionable
row 3 collateral: Corp Bonds), full row resend, or attach a corrected CSV.Thread state machine
Every email thread has exactly one state. The state controls which inbound replies are valid and which template responds. States are stored on the email_threads table.
| State | Meaning | Valid inbound actions |
|---|---|---|
| AWAITING_REPLY | Bot sent something, waiting for the user | Any user reply |
| COLLECTING | Single posting — still missing fields | Fill missing fields / cancel |
| PENDING_CONFIRMATION | Single posting — all fields present | CONFIRM / CANCEL / edit-and-confirm |
| BULK_COLLECTING | Bulk — at least one row invalid | Per-row corrections / re-upload / cancel |
| BULK_PENDING_CONFIRMATION | Bulk — all rows valid | CONFIRM / CANCEL / row edits |
| BROWSING | Side B picking which RFQ to offer on | Number selection or RFQ reference |
| OFFER_COLLECTING | Offer — missing fields | Fill fields / cancel |
| OFFER_PENDING_CONFIRMATION | Offer — fields present | Confirm / cancel |
| SUBMITTED | Posting/offer accepted by V2 | (closed) |
| COMPLETE | Submitted + all notifications sent | (closed) |
| ERROR | User cancelled or V2 rejected hard | (closed) |
PII tokenisation
The bot is never allowed to send raw personal data to OpenAI. A deterministic 5-layer pipeline replaces every PII span with an opaque token before any LLM call, then re-hydrates the token in outbound text.
| Layer | What it does |
|---|---|
| 1 Known entities | Replaces the sender's name, email and org (from message headers + V2 lookup). Zero false positives. |
| 2 Regex PII | Email addresses, phone numbers, internal reference codes like YE-POST-20260514-6FD5. |
| 3 Known orgs dict | Hard-coded list of 25+ financial institutions on the platform (RBC, TD, JP Morgan, …). Matches case-insensitively. |
| 3.5 Domain protection new | A finance-vocabulary lexicon is masked with opaque placeholders BEFORE spaCy runs, so collateral/rate/tenor terms can't be mis-tagged as ORG. |
| 4 spaCy NER | Runs en_core_web_sm on the protected text. Catches any remaining PERSON / ORG entities (e.g. a colleague mentioned in passing). |
| 4b ORG-suffix detector | A regex catches all-caps / mixed-case names followed by an unmistakable suffix — LLC, Inc, Corp, GmbH, PLC, etc. |
Token format
[TOKEN:TYPE:NNN] TYPE NAME | EMAIL | PHONE | ORG | REF NNN 3-digit per-thread counter
The full real → token map is persisted as JSON in email_threads.pii_registry so the same real value always maps to the same token across messages in a thread, and survives process restarts.
Why Layer 3.5 exists
“Govt Bonds” is collateral — it tagged it as ORG. A reactive skiplist of every spelling variant was brittle. Pre-masking with a curated regex lexicon is deterministic and future-proof.Domain vocabulary
One source of truth — _FIN_VOCAB_PATTERNS in bot/core/pii_tokenizer.py — protects domain terms from tokenisation. The same terms are normalised to V2’s enum by _normalise_collateral() in bot/v2/v2_client.py.
| V2 enum class | Accepted spellings |
|---|---|
| Government Bonds | Govt Bonds, Government Bonds, Govt, Government, Treasuries, Treasury, Treasury Bonds, Sovereigns, Sovereign, Sovereign Bonds, Gilts, Gilt, Govies, UST, USTs, JGB, JGBs, US Treasuries |
| T-Bills | T-Bills, T-Bill, T Bills, Tbills, Tbill, Treasury Bills, Treasury Bill |
| Agency Bonds | Agency Bonds, Agency Bond, Agency MBS, Agency Debt, Agencies, Agency |
| Corporate Bonds | Corp Bonds, Corporate Bonds, Corp, Corporate, Corporates, IG Corp, IG Corporates, HY Corp, HY Bonds, Investment Grade, High Yield |
Other protected terms
| Category | Examples |
|---|---|
| Repo mechanics | Triparty, Bilateral, GC, Specials, Overnight, O/N, Open repo |
| Tenor codes | 1W, 3M, 6M, 1Y, 30Y |
| Currencies | CAD, USD, EUR, GBP, JPY, CHF, AUD, SEK, NOK, DKK, NZD, HKD, SGD, CNY, CNH, MXN, BRL |
| Rate benchmarks | SOFR, CORRA, €STR / ESTR, SONIA, TONAR, BBSW, EURIBOR, OIS, Fed Funds |
| Sides | BORROW, LEND, Cash Provider, Collateral Provider |
LLM extraction
All LLM calls go to OpenAI gpt-4o-mini with response_format={ type: "json_schema" } and a strict per-task schema. The model never returns free text we can’t parse.
| Function | When called | Schema |
|---|---|---|
detect_intent | First inbound on a new thread | {intent: enum} |
extract_posting_fields | Side A — single posting | PostingFields (7 fields, all nullable) |
extract_bulk_postings | Bulk text or CSV-converted-to-text | {postings: PostingFields[]} |
extract_fields | Side B — offer fields | OfferFields (5 fields, all nullable) |
detect_confirmation_intent | Ambiguous replies in PENDING_CONFIRMATION | {intent: CONFIRM|CANCEL|UNCLEAR} |
apply_bulk_correction | User replies to a bulk validation error | {postings: PostingFields[]} |
generate_bot_message | Every outbound bot reply | free text (≤35 words, mandatory random opener) |
Anti-hallucination guards
- Dates: if the user’s text contains no date-like cue (regex sweep), all date fields are forced to
nullregardless of LLM output. - Bulk collateral: after the LLM returns, each row’s source slice is regex-scanned for collateral keywords and used to backfill anything missed.
- Mandatory opener: the bot-message prompt assigns a random word from a 20-word pool to start the reply, so two consecutive emails never sound identical.
- Issues summary: when there are validation issues, the LLM is told explicitly — “NEVER claim work was done that wasn’t” — so it can’t say “correction applied” when no correction occurred.
V2 API integration
The bot acts as a special V2 user (a service account configured via V2_BOT_EMAIL / V2_BOT_PASSWORD). All calls flow through the _v2_request() helper which provides:
- Automatic JWT acquisition + caching with refresh on 401
- Exponential-backoff retry on 5xx (502/503/504) and transport errors
- Per-request timeout (default 15s, 30s for token fetch since V2’s bcrypt is slow)
X-Org-Idheader set so V2 attributes the action to the correct orgX-Act-As-User/X-Act-As-Orgheaders on create/offer calls so V2 attributes the row to the real user’s org, not the bot’s service-account org — see V2 bot impersonation
Endpoints used
The Caller column shows who initiates each request. Bot-callers go via the bot’s service-account JWT; admin-UI-callers go via the user’s session JWT and pass permission checks (requirePermission).
| Caller | Method | Path | Purpose |
|---|---|---|---|
| Bot | POST | /api/auth/login | Acquire JWT token (service-account) |
| Bot | GET | /api/organizations/lookup?email=… | Resolve sender → org; checks botAccess + botEnabled |
| Bot | GET | /api/organizations/bot-recipients?excludeOrgId=… | List opted-in counterparty users to notify |
| Admin UI | PATCH | /api/organizations/:id/bot-access | Toggle org master switch |
| Admin UI | GET | /api/organizations/:id/bot-config | Load Integrations tab payload |
| Admin UI | PATCH | /api/organizations/:id/bot-members/:userOrgId | Per-member toggle |
| Admin UI | POST | /api/organizations/:id/bot-members/bulk | Enable / disable many |
| Admin UI | PATCH | /api/organizations/:id/allowed-domains | Replace allowed-domains list |
| Bot | POST | /api/requests | Create an RFQ (single) |
| Bot | POST | /api/requests/batch | Create multiple RFQs (bulk) |
| Bot | GET | /api/requests/:id | Fetch RFQ details (for offer / browse) |
| Bot | GET | /api/requests?type=REPO&statuses=ACTIVE,OPEN,… | List active RFQs (browse) |
| Bot | POST | /api/requests/:id/offers | Submit an offer (Side B) |
Lookup response codes
The bot branches its reply on the code field that the lookup endpoint adds to non-200 responses. Templates are referenced by name; see bot/email/template.py.
| HTTP | Body code | When | Bot template |
|---|---|---|---|
| 200 | — | User exists, both gates pass | (no template — bot proceeds) |
| 404 | user_not_found | Sender email isn't in users | not_registered_email() |
| 404 | no_membership | User has no ACTIVE membership | not_registered_email() |
| 404 | org_not_found | Membership points at a missing org row | not_registered_email() |
| 403 | org_not_enabled | Org.botAccess = false | not_registered_email(access_denied=true) |
| 403 | user_not_enabled | Org.botAccess = true but membership.botEnabled = false | user_not_enabled_email() |
Field mapping (bot → V2)
{
"direction": "BORROW" → COLLATERAL_PROVIDER // borrowing cash means providing collateral
"LEND" → CASH_PROVIDER
"amount": 500000000 → amount: 500000000
"currency": "CAD" → currency: "CAD"
"rate": 4.85 → repoTerms.fixedRate: 4.85
"start_date": "2026-06-15" → startDate: "2026-06-15"
"end_date": "2026-06-22" → endDate: "2026-06-22"
termType: "TERM" (derived from end_date - start_date)
"collateral_type": "Corp Bonds" → repoTerms.preferredCollateralTypes: ["Corporate Bonds"]
}V2 bot impersonation
The bot logs into V2 as a single service account (configured via V2_BOT_EMAIL, e.g. [email protected]). Without help, V2’s authMiddleware reads orgId straight from that account’s JWT, so every RFQ the bot creates lands under the bot’s own org (in our dev data: AIMCo) — never under TD, RBC, or whoever the email actually came from. That breaks the entire counterparty model: an org can’t quote on its own RFQs, notifications fan out to the wrong list, dashboards show garbage attribution, etc.
The impersonation header lets the bot tell V2 “I’m calling on behalf of this user at this org for this single request”. The header is honoured only for JWTs whose email appears in V2’s BOT_SERVICE_ACCOUNTS allowlist — a regular user’s JWT can never impersonate anyone.
Why this matters in one sentence
[email protected] is recorded in V2 with initiatorOrgId = TD Bank. Without impersonation, the same RFQ would be recorded as initiatorOrgId = AIMCo (the bot’s default org), and TD’s own user could not quote on it.The two headers
| Header | Value | Required | What V2 does with it |
|---|---|---|---|
X-Act-As-User | Email of the real end-user | YES | Resolved against /internal/organizations/lookup. The looked-up user becomes req.user for this request. |
X-Act-As-Org | Org ID (e.g. demo_org_td_…) | optional | Cross-check. If sent, it MUST match the org returned by /lookup, otherwise V2 returns 403 bot_impersonation_org_mismatch. |
Request lifecycle (with impersonation)
- 1
1. authMiddleware
Decodes the bot’s JWT. Putsreq.user.email = [email protected]andreq.user.orgId = AIMCoon the request. - 2
2. botImpersonation
Sees thatreq.user.emailis inBOT_SERVICE_ACCOUNTS. ReadsX-Act-As-User=[email protected]. Calls/internal/organizations/[email protected]over HMAC. - 3
3. Lookup validates
Returns{ userId, orgId, orgName }ONLY if: user is ACTIVE, has an ACTIVE membership,botEnabled=trueon that membership, and the org hasbotAccess=true. Any failure is forwarded as 403. - 4
4. Rewrite
The middleware rewritesreq.user.id,req.user.email,req.user.orgId,req.orgIdto the resolved values. The original bot identity is preserved onreq.botActorfor downstream audit/logging. - 5
5. Audit
Emits a fire-and-forgetBOT_IMPERSONATIONentry to audit-service with both the bot identity (performedBy) and the impersonated identity (entityId), plus path and method. - 6
6. Controller runs
The downstream controller (POST /api/requests,POST /api/requests/:id/offers, …) sees the rewritten identity and writes the row attributed to the real user’s org.
Security gates
Every one of these must pass — otherwise the headers are silently dropped (no-op) or the request is rejected (403). The middleware itself lives in services-v2/platform/shared/middleware/bot-impersonation.ts.
| # | Gate | Failure mode |
|---|---|---|
| 0 | Allowlist non-empty: BOT_SERVICE_ACCOUNTS env var has at least one entry | Empty → middleware is a complete no-op (safe rollback) |
| 1 | Requester is authenticated (req.user populated by authMiddleware) | No user → no-op |
| 2 | Requester’s email is in the allowlist | Mismatch → headers silently ignored (regular users can’t impersonate) |
| 3 | The X-Act-As-User email resolves through /lookup with all the bot-access flags green | 404 / 403 → reflected to the bot as 403 with the lookup’s code |
| 4 | If X-Act-As-Org is sent, it equals the org returned by /lookup | Mismatch → 403 bot_impersonation_org_mismatch |
What the bot sends
Bot-side wiring lives in bot/v2/v2_client.py. The two headers are threaded through _headers() → _v2_request() → create_request(), create_request_batch(), and submit_offer(). The poller (bot/email/poller.py) fills them with the values resolved from the inbound email:
# Side A — posting (bot/email/poller.py::_submit_posting)
await v2_client.create_request(
org_id, # from thread.org_id
fields,
act_as_email=sender_email, # the email author
act_as_org=org_id, # their resolved V2 org
)
# Side B — offer (bot/email/poller.py::_submit_offer)
# sender_org is cached by _detect_offer_block before this call.
await v2_client.submit_offer(
request_id,
payload,
act_as_email=sender_email,
act_as_org=sender_org or thread.org_id,
)
# Service calls (lookup, recipients) DO NOT impersonate — they run as
# the bot's own identity so the impersonation middleware stays out of
# the way of the very endpoint it depends on.
await v2_client.lookup_org_by_email(sender_email) # no headersConfiguration
| Side | Variable | Where | Example |
|---|---|---|---|
| V2 | BOT_SERVICE_ACCOUNTS | V2-APP-DASHBOARD/.env | [email protected] |
| V2 | BOT_SERVICE_ACCOUNTS | (multiple bots) | [email protected],[email protected] |
| Bot | V2_BOT_EMAIL | ye-repo-bot/.env | [email protected] |
| Bot | V2_BOT_PASSWORD | ye-repo-bot/.env | (service-account password) |
The two values must match
V2_BOT_EMAIL. For impersonation to work, that same email must appear in V2’s BOT_SERVICE_ACCOUNTS list. If you rotate the bot to a different service account, update both files and restart V2 (the env var is read at process start).How to roll it out / roll it back
| Step | What | How |
|---|---|---|
| 1 | Disabled (safe state) | Leave BOT_SERVICE_ACCOUNTS unset or empty. Bot still sends X-Act-As-*; V2 ignores them. Identical to pre-rollout behaviour. |
| 2 | Enable for one bot | Set [email protected] in V2-APP-DASHBOARD/.env, then docker compose up -d --no-deps negotiation-service reference-data-service. |
| 3 | Verify with the smoke script | Run docker exec ye-repo-bot-bot-1 bash -c "cd /app && PYTHONPATH=/app python scripts/smoke_impersonation.py". Should print “IMPERSONATION SUCCEEDED” and show two different org IDs. |
| 4 | Add more bots later | Append comma-separated emails to the same env var, restart V2. |
| 5 | Roll back | Clear the env var, restart V2. Zero schema changes — the impersonation columns / models don’t exist; only request-time middleware behaviour changes. |
Auditability
Every successful impersonation writes a BOT_IMPERSONATION entry via emitAuditLog:
{
"entityType": "AUTH",
"action": "BOT_IMPERSONATION",
"performedBy": "<bot user id>", // who really made the call
"orgId": "<impersonated org id>",
"entityId": "<impersonated user id>",
"ipAddress": "<caller ip>",
"userAgent": "<caller ua>",
"details": {
"botEmail": "[email protected]",
"botOrgId": "demo_org_aimco_000008",
"actingAsEmail": "[email protected]",
"actingAsUserId": "usr_ernest_kiroma_01",
"actingAsOrgId": "demo_org_td_000000002",
"actingAsOrgName": "TD Bank",
"path": "/requests",
"method": "POST"
}
}Both the bot identity and the impersonated identity are in the same row, so compliance can answer “who really did this” and “who was it attributed to” without joining tables.
Known good / known bad responses
| Scenario | HTTP | Body code | Outcome |
|---|---|---|---|
| Bot posts as Ernest@TD (valid impersonation) | 200/201 | — | RFQ attributed to TD |
| Bot posts as a user without botEnabled | 403 | user_not_enabled | request rejected |
Bot posts as a user at an org with botAccess=false | 403 | org_not_enabled | request rejected |
Bot sends X-Act-As-Org that doesn’t match the user’s org | 403 | bot_impersonation_org_mismatch | forgery blocked |
Regular user JWT sends X-Act-As-User | — | — | headers silently ignored |
BOT_SERVICE_ACCOUNTS unset (rollback) | — | — | middleware is a no-op |
Files that implement this
| File | Role |
|---|---|
services-v2/platform/shared/middleware/bot-impersonation.ts | The V2 middleware itself. |
services-v2/negotiation-service/app.ts | Registers the middleware after authMiddleware. |
services-v2/reference-data-service/src/router.ts | Registers the middleware after authMiddleware. |
services-v2/platform/shared/middleware/__tests__/bot-impersonation.test.ts | Unit tests (8 cases — gates, happy path, mismatches, rollback). |
bot/v2/v2_client.py | Bot-side header plumbing on _headers / _v2_request / create_request / submit_offer. |
bot/email/poller.py | Where act_as_email / act_as_org are passed (Side A + Side B). |
scripts/smoke_impersonation.py | Live two-RFQ smoke test (control vs impersonation). |
scripts/e2e_qa.py | Scenario E17 — 18 asserts on the bot-side plumbing. |
Admin dashboard
The same Next.js app you’re reading right now. Reachable at http://localhost:8000. Routes:
| Route | Purpose |
|---|---|
| /conversations | List all threads with state badges + search + tab between Offers / Postings. |
| /conversations/[id] | Single-thread view: user-view, bot-view (token-decorated), full email timeline, attachments, audit trail, V2 payload. |
| /docs | This page. |
/signin | Admin login (password + OTP). |
Login flow
- 1
Step 1 — Password
Sign in withADMIN_EMAIL/ADMIN_PASSWORD(orADMIN_PASSWORD_HASHbcrypt for production). Rate-limited to 10 attempts / minute / IP. - 2
Step 2 — OTP
A 6-digit one-time code is generated, persisted inadmin_otps(so it survives restarts), and sent toADMIN_OTP_EMAIL. Valid for 10 minutes. Rate-limited to 8 verifications / minute / IP. - 3
Step 3 — JWT
On success, a signed JWT is returned and stored inlocalStorageasbot_token. The token expires in 24h and is sent on every API request.
Configuration
All settings come from environment variables (see ye-repo-bot/.env):
Bot identity
| Variable | Default | Purpose |
|---|---|---|
IMAP_HOST | imap.gmail.com | Inbound mail server |
IMAP_PORT | 993 | Inbound port (TLS) |
SMTP_HOST | smtp.gmail.com | Outbound mail server |
SMTP_PORT | 587 | Outbound port (STARTTLS) |
SMTP_USERNAME | — | Bot inbox address |
SMTP_PASSWORD | — | App password (Gmail) or SMTP password |
EMAIL_ENABLED | true | Toggle the poller off in test envs |
V2 integration
| Variable | Default | Purpose |
|---|---|---|
V2_API_BASE_URL | http://api-gateway:4000 | V2 gateway URL |
V2_BOT_EMAIL | [email protected] | Service-account email. Must also be in V2’s BOT_SERVICE_ACCOUNTS for impersonation — see V2 bot impersonation. |
V2_BOT_PASSWORD | — | Service-account password |
V2_BOT_ORG_ID | (auto-resolved) | Org the bot acts as (optional) |
V2 stack (set in V2-APP-DASHBOARD/.env)
| Variable | Default / example | Purpose |
|---|---|---|
BOT_SERVICE_ACCOUNTS | [email protected] | Comma-separated allowlist of bot service-account emails. JWTs from any other identity cannot use the X-Act-As-User / X-Act-As-Org impersonation headers. Leave empty to disable the feature entirely (safe rollback). See V2 bot impersonation. |
Admin / LLM / other
| Variable | Purpose |
|---|---|
OPENAI_API_KEY | OpenAI key (required for LLM extraction) |
ADMIN_EMAIL | Admin sign-in identifier |
ADMIN_PASSWORD | Plain-text admin password (dev only) |
ADMIN_PASSWORD_HASH | bcrypt hash (production; takes precedence over plain password) |
ADMIN_OTP_EMAIL | Where to send admin sign-in OTPs |
JWT_SECRET | Used to sign dashboard JWTs (rotate in production) |
DASHBOARD_URL | Inserted into “View in dashboard” CTAs on outbound emails |
DATABASE_URL | Bot DB (defaults to local SQLite; MySQL supported) |
Operations & health
Health checks
# Bot service
curl -s http://localhost:8001/health
# {"status":"ok","imap":"connected","db":"ok","version":"1.0.0"}
# Admin dashboard
curl -s http://localhost:8000/api/bot/health
# V2 stack
docker ps --format 'table {{.Names}}\t{{.Status}}' \
| grep -E 'api-gateway|reference-data|negotiation'Useful commands
# View latest threads
docker exec ye-repo-bot-bot-1 python -c "
from bot.db import SessionLocal
from bot.db.models import EmailThread
with SessionLocal() as db:
for t in db.query(EmailThread).order_by(EmailThread.updated_at.desc()).limit(5):
print(f'{t.state:24s} {t.request_id} {t.updated_at}')
"
# Tail bot logs
docker logs -f ye-repo-bot-bot-1
# Run the e2e regression
docker exec ye-repo-bot-bot-1 python /app/scripts/e2e_qa.py
# Verify the V2 stack has the bot's recent RFQ
docker exec mysql_v2app_bak mysql -uroot -ppassword ye_negotiation -e "
SELECT id, direction, amount, currency, status, createdAt
FROM requests ORDER BY createdAt DESC LIMIT 5;"Logs & their meaning
| Log line | Meaning |
|---|---|
IMAP connected — IDLE mode, since=… | Poller is online and listening for new mail |
IMAP search found N email(s)… | N new messages picked up on this poll cycle |
Reply sent to … subject='…' | Bot replied successfully (or sent an OTP) |
Backfilled collateral_type for row N… | Regex backfill recovered something the LLM dropped |
V2 request retry attempt N (status=…) | V2 returned 5xx; auto-retrying with backoff |
Unknown collateral type from LLM: 'X' | Add X to _COLLATERAL_NORM and _FIN_VOCAB_PATTERNS |
Security
| Control | Implementation |
|---|---|
| No PII to LLM | 5-layer tokenizer replaces every PII span before any OpenAI call. Tokens are reversed only inside the bot, never sent outbound. |
| Two-layer gate | Both org botAccess=true AND user botEnabled=true are required. Senders failing either gate get distinct, actionable replies pointing them at their Org Admin. |
| Per-toggle audit trail | Every access change writes an audit-service entry (BOT_ACCESS_TOGGLE, BOT_MEMBER_TOGGLE, BOT_MEMBER_BULK_TOGGLE, BOT_ALLOWED_DOMAINS_UPDATE) with actor, IP, user-agent, and before/after values. Reconstructable for compliance review. |
| RBAC on every mutation | The V2 endpoints powering the Integrations UI are gated by requirePermission(ORG_SETTINGS_MANAGE). Non-admin attempts return 403 server-side regardless of UI state. |
| Compliance kill-switch | An Org Admin can disable a whole firm in one click. Effect is immediate — the bot has no read-through cache for botAccess; every inbound email re-checks V2. |
| Admin auth | Bcrypt password (when ADMIN_PASSWORD_HASH is set) + persistent OTP. Rate-limited per IP. |
| JWT TTL | 24h. Refresh requires re-sign-in (password + OTP). |
| V2 token cache | JWT kept in memory only, refreshed automatically on 401. |
| Idempotency | Inbound messages deduplicated by Message-ID in processed_messages (bounded FIFO cache of 5000). |
| No reflection of secrets | Bot replies never include tokens, request IDs from other orgs, or anything from the PII registry. |
Troubleshooting
Bot isn’t replying to emails
- Check the bot is online:
docker logs ye-repo-bot-bot-1 --tail 20. Look forIMAP connected. - Confirm both the sender’s org has
botAccess=trueAND the sender personally hasbotEnabled=truein V2 (Org Settings → Integrations → toggle the member row). - Check
EMAIL_ENABLED=truein.env. - Look for
Reply sent to …in logs after the email lands — if absent, the bot didn’t process the message (likely deduped or filtered).
Sender received a “not registered” or “not enabled” reply
The reply itself is your diagnostic — three distinct templates, each pointing at a specific fix:
| Reply received | What happened | Fix |
|---|---|---|
| “Account Not Found” | The sender’s email isn’t in V2’s users table at all (or has no active membership). | Have an Org Admin invite the user in Members tab, OR have the user email from their already-registered address. |
| “Email Bot Access Not Enabled” | The org is on V2 but botAccess is false at the org level. | Org Admin flips Integrations → Email Bot Integration on. |
| “Personal Bot Access Required” | Org is enabled but this specific user’s botEnabled is false. | Org Admin opens Integrations → finds the user’s row in Members allowed to use the bot → flips the toggle on. |
To trace it server-side, fetch the lookup response the bot saw:
# Inside the bot container, replay the lookup the bot did:
docker exec ye-repo-bot-bot-1 python -c "
import asyncio, json
from bot.v2 import v2_client
async def main():
r = await v2_client._v2_request('GET', '/api/organizations/lookup',
params={'email': '[email protected]'})
print(r.status_code, json.dumps(r.json(), indent=2))
asyncio.run(main())
"“Collateral type is required” on a row that mentions collateral
- Open the thread in the dashboard, switch to Bot view to see the tokenised text. If a financial term shows up as
[TOKEN:ORG:001], the term needs to be added to_FIN_VOCAB_PATTERNS. - Otherwise the LLM dropped the field — the regex backfill should recover it; check logs for
Backfilled collateral_type for row ….
V2 returned 401 / 502
- 401: token expired or service account password rotated — bot auto-refreshes once; if still 401, check
V2_BOT_EMAIL/V2_BOT_PASSWORD. - 502/503/504: V2 service is down or hot-reloading — the retry layer should handle this; if it persists check the corresponding container.
OTP email never arrives
- Look for
OTP sent to …in bot logs — if absent, SMTP isn’t configured. CheckSMTP_*env vars. - Check the spam folder of
ADMIN_OTP_EMAIL. - OTPs are valid for 10 minutes. After that, request a new one.
FAQ
Does the bot store the original (raw) email body?
Yes — in thread_messages.body_raw, accessible only to admins via this dashboard. The PII registry is per-thread so the dashboard can rehydrate tokens for display, but tokens are what get sent to the LLM.
What happens if the bot is offline when a user sends an email?
The IMAP poller picks up missed messages on reconnect — the lookback window is max(today − 7 days, last_processed − 1 day). Idempotency keys (Message-ID) prevent double-processing.
How do I add a new collateral type?
Add one line each to (1) _COLLATERAL_NORM in bot/v2/v2_client.py mapping the new spelling to the V2 enum value, (2) _FIN_VOCAB_PATTERNS in bot/core/pii_tokenizer.py so it survives tokenisation, and (3) _COLLATERAL_KEYWORDS in bot/llm/field_extractor.py for the regex backfill. The e2e suite will catch any drift.
Can the bot speak languages other than English?
Not yet — the LLM prompts and all email templates are English-only. Adding multilingual support would require localised templates plus a per-thread locale field.
How do I enable the bot for a new trader?
Sign in as an Org Admin → Settings → Organization → Integrations → find their row in Members allowed to use the bot → toggle Bot access on. Takes effect on the next email they send. The action is audited; no V2 redeploy or admin script required.
How do I urgently disable the bot for the whole firm?
Same screen — flip the top-level Email Bot Integration switch off. All inbound emails from your firm start being rejected immediately with a polite “Email Bot Access Not Enabled” reply. Per-member enabled-states are preserved, so flipping the master back on restores the prior configuration exactly.
Why are new invitees not automatically enabled?
Compliance-safe defaults. botEnabled defaults to false for every new membership row, including newly invited users. The Org Admin must explicitly opt each user in (or click Enable all). This guarantees that adding a user to V2 is never accidentally an authorisation to trade by email.
Can a trader see whether their own bot access is on?
Yes — the Integrations tab is readable by any member, not just admins. Non-admins see the same page in read-only mode (toggles disabled, an info banner explains why). The trader can confirm their own row and ask their admin to flip it if it’s off.
What does adding a domain to the allowed-domains list do today?
Today: it’s saved per org and surfaced in the UI, but the bot’s lookup is still strict-email-match. A future v1.1 will let mail from those domains be attributed to the org even when the specific address isn’t a registered user (after a separate compliance review on guest-sender attribution). Curate the list now; the lookup will start consuming it without a UI change.
Where do I file bugs / feature requests?
File an issue against the ye-repo-bot repo and tag it appropriately. For data corrections on existing threads, contact the on-call engineer with the thread ID — never edit the DB by hand without an audit trail.