---
title: Enquiry reading
description: How the worker reads an enquiry for its categories, asks, due date and contact, what each model is sent, and when a client email is a copy that is not read at all.
sidebar:
  label: Enquiry reading
  order: 7
---

The worker reads each enquiry for what the next layer needs: which catalogue categories it asks for, what the client asks of the offer and by when, who the client is, and who inside Flowtech passed it on. Before any of that, it records who each email really came from, so a client email that arrives again and adds nothing is linked to the first and not read at all. The path around this, from a mailbox sync to a finished run, is on [Sync to labels](/engineering/sync-to-labels), and the layers after reading are on [Processing layers](/engineering/processing-layers). The code is in `apps/worker/src/origin/`, `apps/worker/src/persist/record-origin.ts` and `apps/worker/src/read-enquiry/`.

## Where reading sits

```mermaid
flowchart TD
  A["Stored email arrives on the queue"] --> B["PREPARE: extract the email"]
  B --> C{"Record origin: a copy of an email already recorded?"}
  C -->|yes| X["Link SAME_ORIGIN CONFIRMED, run DONE. Nothing else runs"]
  C -->|no| D["Convert documents"]
  D -->|model calls off| Z["Stop. The run stays RUNNING"]
  D -->|model calls on| E["CLASSIFY: Jev names the kind of email"]
  E --> F["LABEL: rules only, no model"]
  F -->|not an enquiry| Y["Run DONE"]
  F -->|enquiry| G["READ: build the enquiry view"]
  G --> H["Categories: vocabulary and Jev yes or no"]
  G --> I["Asks and due date: rules and Jev choices"]
  G --> J["Contact: rules and the contact model"]
  H & I & J --> K["Save ENQUIRY_READING, run DONE"]
```

- The origin is recorded straight after extraction, before documents are converted, and also with model calls off. A copy ends there: its documents are not converted and no model is called.
- Labelling calls no model. It splits the email and its documents into lines with ids such as `subj:l0` or `m1:l5`, and tags signatures, disclaimers and firm references by rule. Everything the reading finds cites these ids.
- Only an email classified as an enquiry is read. Its three parts are read at once, and saving the reading as an `ENQUIRY_READING` artifact on the `READ` step finishes the run.

One enquiry job, and the service each step talks to:

```mermaid
sequenceDiagram
  participant Q as Queue
  participant W as Worker
  participant DB as Postgres
  participant T as TypeSafe Jev
  participant C as Contact model
  Q->>W: email id
  W->>DB: record origin, locked per client
  alt a copy of an email already recorded
    W->>DB: link CONFIRMED, run DONE
  else not a copy
    W->>T: classify
    W->>DB: save the classification and labels
    opt an enquiry
      par categories
        W->>T: yes or no per category
      and asks
        W->>T: a choice per candidate line
      and contact
        W->>C: the contact block
      end
      W->>DB: save the reading, run DONE
    end
  end
  Note over W,C: A 429 throws and the queue waits a minute. Another failed reading call leaves its part partial.
```

## Origin and copies

### Origin

`findOrigin` finds the client behind an email. Ours means the intake mailbox's domain, plus any of Flowtech's other addresses listed in full in `MAIL_OWN_ADDRESSES`, such as a shared Gmail inbox staff write from. A colleague writing from any other address is taken for the client.

- **Direct mail.** A sender who is not ours is the client, and the email's new text is the client's body.
- **A forward.** From a sender of ours, it reads the forward headers newest first and skips colleague layers: a header of ours addressed only to our addresses, or to a name alone under a subject that is not a reply. The first `From` that is not ours is the client, and the client's body is the text under that header, without their quoted history and sign-off.
- **No client.** A header of ours addressed to someone outside, such as our reply to the client, ends the search with no client, and so does a client header that names no address. A colleague forwarding our quotation is never taken for a copy of the RFQ quoted below it.
- **Reading looks further.** The reading asks `findOrigin` for its `reading` purpose. That also searches quoted history, where a colleague's reply puts the client's email when its subject is not a forward, and signatures, since the sign-off split off a forward can take in the client's email below our reply when theirs has no closing line of its own. Only a header block names a client: the attribution line a reply is quoted under, such as "On Mon, 1 Sep 2026, … wrote:", as Gmail quotes, names none, so such a reply with no header block below it is read whole. It skips every header of ours, not only colleague layers, so a forward of our reply to the client is read with the client below it. It still stops at a header that names no address. The first outside sender it meets is taken for the client, whoever they are. The copy check keeps the stricter search, so such an email is read in full and never taken for a copy.

`recordOrigin` writes the client's address and sent time onto the email, with three fingerprints: the subject with RE and FW prefixes, case and extra spaces removed; a hash of the client's body, letters and digits only, kept only when 40 or more remain; and a hash of the attachment set, images left out, in any order. Two more facts are worked out for the copy check and not stored: whether the client's body is wordless, empty or only greetings and filler such as "Dear Sir, PFA, kindly quote", and whether any forwarding colleague's note adds words.

### The copy rule

An email is a copy of one already recorded when all of these hold. The business statement is R2a on [Business rules](/business/business-rules).

- **Same client:** the same client address.
- **Within 30 days:** the two were received at most 30 days apart, either way round (`COPY_WINDOW_DAYS`).
- **One office:** every mailbox counts as its office, or as itself when it has none. An email reached the offices of its intake mailbox and of every mailbox it was delivered to, and two emails share an office when those overlap. Mail stores a message once per `internetMessageId`, so one email addressed to two offices is one row, recorded and read once. The same enquiry the client sent separately to each office is two emails with no office in common, and each is read. A mailbox with no office matches only itself.
- **Adds nothing:** the same non-empty attachment set, with the same body hash or a wordless body; or, with no attachments on either side, the same body hash. New words, however short ("revised qty 10"), new files, or files on only one side are read in full.
- **No words from a colleague:** a forward in which any colleague's note adds words is never a copy.

A forward has one colleague note per colleague who passed it on (`colleagueNoteAddsWords`): the new text above its first header, and, for every colleague layer `findOrigin` skips, that layer's text between its header and the next, up to a forward marker or quoted history. Each note is checked on its own, without the colleague's signature. A leading greeting and the one word after it on the same line are dropped, so "Hi Rohan," and "Dear Anita" add nothing. When no signature was split from a note and its first line is a sign-off, such as "Regards," or "Sent from my iPhone" but not a thanks, the whole note is taken for a signature. What is left adds words unless it is only greetings and filler. The filler list includes routing words, and it is the same list that decides whether the client's own body is wordless, so a client's "Please check and revert" over the same files is a copy too. "FYI", "Please check and revert", "Kindly do the needful" and "pls handle" add nothing. "Client now wants 20", "urgent, lead time 2 weeks" and "Revised qty below" add words, so the forward is read, whichever colleague wrote them. A signature with no closing line above it, or a phrase outside the list such as "please see below", counts as words.

```mermaid
flowchart TD
  A["Email with a client"] --> N{"A forward whose colleague note adds words?"}
  N -->|yes| S{"Same client and subject within 7 days?"}
  N -->|no| B{"Any email already recorded from the same client, not itself a copy, with no REJECTED link from this one to it?"}
  B -->|none| S
  B -->|yes, earliest received first| W{"Received within 30 days of this one, either way?"}
  W -->|yes| O{"Do the two share an office?"}
  O -->|yes| C{"Same non-empty attachment set?"}
  C -->|yes| D{"Same body, or only greetings and filler?"}
  C -->|no| E{"No files on either side, and the same body of 40 or more letters and digits?"}
  D -->|yes| CP["Copy: link SAME_ORIGIN CONFIRMED to it, run DONE"]
  E -->|yes| CP
  W -->|no| NX{"Another such email?"}
  O -->|no| NX
  D -->|no, it adds words| NX
  E -->|no| NX
  NX -->|yes, the next earliest| W
  NX -->|none left| S
  S -->|yes| SU["Link SUSPECTED, at most 5, and process normally"]
  S -->|no| P["Process normally"]
```

- **The first email.** A copy links to the earliest-received email that qualifies among those already recorded, never to a copy, so a chain always points at the original. The first sync walks a mailbox newest first, so the email recorded first, and read, can be the later one.
- **The lock.** The check runs under a Postgres advisory lock on the client's address, so two emails from one client recorded at once cannot both become the first.
- **A copy** is linked `SAME_ORIGIN`, `CONFIRMED`, with evidence naming what matched, the attachment set or the body. Its run ends `DONE`, even after a failed stage, and the job returns `duplicate`.
- **Recording again.** An email recorded again, on a redelivery or in `pnpm flow`, is checked afresh. A `CONFIRMED` link from its earlier recording that no longer holds, to an email no longer the first or from one no longer a copy, is removed.
- **SUSPECTED.** An email that is not a copy, including one the window, the office or a colleague's words keep from being one, is linked `SUSPECTED` to at most five emails from the same client under the same subject within seven days, and processed normally.
- **REJECTED.** A link a person marked `REJECTED` is not made again from the same email to the same target. Nothing in the app sets it yet.
- **Resets.** A mailbox reset that purges a first email reopens its surviving copies, as [Sync to labels](/engineering/sync-to-labels) describes.
- **Older mail.** Mail processed before origins were recorded has none, so a copy of it is not found.

## The enquiry view

Every reader works from one view of the email, built from its labelled pack and its origin (`buildEnquiryView`). The view sorts the lines by whose words they are, and each line keeps its pack id.

With no client found, such as a colleague restating a request with the client only in Cc, every line of the new, forwarded and quoted text is read as the client's words. Header lines, separators, noise, lines holding our addresses and any signature split off the new or forwarded text are left out. There is then no contact block and no routing.

- **Client body:** the client's own words, without noise lines, their sign-off, or any line holding one of our addresses.
- **Contact block:** the client's header, forwarded or quoted, without lines naming us, and up to 15 lines of their sign-off.
- **Colleague note:** the new text of the colleague who forwarded it to us, without their signature. It is routing, not the client's request. An earlier colleague's note, inside the forward, is used only by the copy check.
- **Subject and attachment names.**
- **Document lines:** converted documents' lines and rows, matched only inside the worker and never sent to a model.

```mermaid
flowchart LR
  E["Enquiry email: labelled pack and origin"] --> SB["Subject and attachment names"]
  E --> CB["Client body: the client's own words, no noise, no lines naming us"]
  E --> CK["Contact block: client header and up to 15 sign-off lines"]
  E --> CN["Colleague note"]
  E --> DL["Document lines"]
  SB --> CAT["Categories, via Jev"]
  CB --> CAT
  CB --> ASK["Asks and due date, via Jev"]
  SB --> TRM["Terms files, no model"]
  CK --> CON["Contact, via the contact model"]
  CN --> ROU["Routing, no model"]
  DL --> HNT["Category hints, no model, never sent"]
```

**Routing** records a forward: the colleague who sent it, the mailbox it reached, when, and the colleague's note. Mail the client sent directly has none.

## Categories

One RFQ often asks for several item-group master categories ("LEVEL SWITCHES, ROTAMETERS"), and sometimes names them only in an attached datasheet.

1. **Vocabulary.** The worker keeps phrases for each item-group prefix, each pointing at its category; a prefix with no phrases is matched by its own name. Bare process words, such as "differential pressure", "turbine" or "ultrasonic", name nothing.
2. **Matching**, over the subject, attachment names and client body: whole phrases, in any case, with an optional plural, the longest phrase first, after dropping a "with …" accessory clause. "Glass tube rotameter (with high-low flow switches)" names Rotameters only, and "glass tube flow meter" names Rotameters, not Flow Meters.
3. **One Jev call**, with a yes-or-no question per category and one for something Flowtech does not catalogue. Jev is sent the subject, the attachment names and the client body, cut at 20,000 characters, and nothing else.
4. **Combining**, per category:

| Jev's probability | With a vocabulary hit | Without one |
| ----------------- | --------------------- | ----------- |
| 0.5 or more       | present               | present     |
| 0.2 to under 0.5  | present               | unsure      |
| under 0.2         | unsure                | absent      |
| no answer         | unsure                | absent      |

5. **Resolution.** Any present or unsure category makes the reading resolved; unsure is recorded for the next layer, when it is built, to check like present, so a borderline category is not dropped. With every category absent the reading is unresolved, for the next layer to check every category. Matches in document lines are kept as hints only.

The "something uncatalogued" answer is flagged at 0.5 or more.

```mermaid
flowchart TD
  V["Match the vocabulary over subject, file names and client body"] --> Q["One Jev call: yes or no per category, plus something uncatalogued"]
  Q --> C{"Per category: Jev probability p and vocabulary hit h"}
  C -->|p at least 0.5, or p at least 0.2 with h| PR["present"]
  C -->|p 0.2 to 0.5 without h, or p under 0.2 with h| UN["unsure"]
  C -->|anything else| AB["absent"]
  PR --> R["resolved: present and unsure kept for the next layer"]
  UN --> R
  AB --> U{"Every category absent?"}
  U -->|yes| UR["unresolved: every category left for the next layer"]
  U -->|no| R
  Q -.->|call fails| F["partial: a vocabulary hit counts as unsure"]
  F --> C
```

## Asks and the due date

An RFQ carries the client's terms besides its products: delivery, freight basis, payment, taxes, validity and warranty, documents to send back, inspection, approved makes, and a due date. They sit in loose lines, in bullets under "along with the following", or in an attached terms file.

- **Candidates** come from the client body only, never from a table row or the contact block:
  - a line with a keyword for an ask type: due date, delivery, price basis, payment, taxes, validity or warranty, documents, inspection or testing, make or model;
  - up to 12 list lines after an introducer such as "along with the following" or "as follows", keyword or not.
- **One Jev call**, with a multiple-choice question per candidate: which ask it is, another instruction about the offer, or not an ask. Each line is sent with the line above it, so a list item is read beside its introducer, and Jev's choice overrides the keyword's type. At most 40 candidates are asked; the rest are kept as unjudged.
- **Urgency and terms.** "Urgent", "ASAP", "at the earliest", "on priority" or "immediately" in the client body marks the enquiry urgent. Attachments named GTC, T&C, terms, general conditions, or conditions of contract, purchase or supply are listed as the client's terms, and are not read.

**The due date** is parsed by rule from the lines Jev files under due date. Jev never writes a date; it only decides whether a line sets one.

- Reading starts at "due", "before", "latest", "last date", "deadline", "closing" or "submission", otherwise at "by", so "RFQ date 23.06.2026, due date 30.06.2026" reads 30 June.
- The first date after that point counts, written as 14.07.2026, 14/07/26, 2026-07-14, "14th July", "30-Jun-2026" or "July 3, 2026". An impossible date, such as 31.02.2026, gives none.
- A date with no year takes the year nearest the sent date, so "by 5th January" sent on 28 December is next year's.
- "Today", "EOD", "tomorrow" and "within N days" count calendar days from the sent date, and the date is marked relative. Working days are counted as calendar days.
- Across several lines, the earliest date wins.
- **The clock.** A forward's client header time is taken as written. A direct email's sent time is shifted to India time, UTC+05:30, and a forward whose client header time cannot be read is dated from when it was forwarded, in India time.

```mermaid
flowchart TD
  L["Client body lines, never table rows or the contact block"] --> K{"A keyword for an ask type, or a line under an introducer?"}
  K -->|no| X["Not a candidate"]
  K -->|yes| C["Candidate, up to 40 asked"]
  C --> J["One Jev call: which kind of ask, or not an ask?"]
  J -->|not an ask| Y["Dropped"]
  J -->|an ask type| A["Ask, citing its line"]
  J -->|due date| S["Start reading at due, before, latest, last date, deadline, closing or submission. Otherwise at by"]
  J -.->|call fails| KW["Keyword type kept, unconfirmed"]
  KW -.->|a due-date keyword| S
  S --> W{"Is a date written after that point?"}
  W -->|yes, a real date| D["That date. No year means the year nearest the sent date"]
  W -->|yes, impossible| N["No date"]
  W -->|no| R{"Within N days, tomorrow, today or EOD?"}
  R -->|yes| RD["Sent date plus N days, marked relative"]
  R -->|no| N
  D --> E["The earliest date across the lines wins"]
  RD --> E
```

## Contact

An enquiry has to say who the client is and how to reach them, and that sits in signatures whose layout varies from one email to the next. Rules read the formats that can be checked; one model call splits what rules cannot.

- **Rules** (`read-contact-rules.ts`):
  - the client's address and display name, from the origin, for direct mail too;
  - the domain, and whether it is free mail, such as gmail or rediffmail;
  - Indian phone numbers, leaving out any the forwarding colleague wrote in their note;
  - a GSTIN, only when its check digit is right;
  - the PIN, the first six-digit number in the contact block that is outside the client's header lines and not part of a phone number, and its state from a prefix table. A six-digit number in the list of numbers after a phone label, as in "Tel: 0712-2000111 / 400001", is never a PIN. A PIN's state is known only where its prefix belongs to one state; a prefix two states share gives none, and so does a prefix a state shares with a union territory.
- **One model call** on the contact block alone splits it into name, designation, company and address. The model is `LLM_MODEL` at `LLM_BASE_URL`, an OpenAI-compatible API, DeepInfra today. It runs at temperature 0, with thinking off for Qwen3 and at most 512 output tokens, and is sent the answer's JSON schema. An answer that does not fit the schema is asked again, up to three times in all.
- **Grounding.** A value the model returns that the block does not say, allowing for case and spacing, is dropped, and so is one found only inside a longer word.
- **Merge.** Rule fields always win. The state comes from the printed PIN when its prefix names one state; otherwise the model's state is kept, unless it has digits in it. When the model's address names another PIN, the printed one stands and `pinDisagrees` is set.

```mermaid
flowchart TD
  O["Origin"] --> R1["Rules: address, display name, domain, free mail"]
  B["Contact block"] --> R2["Rules: phones, GSTIN with its check digit, PIN and its state"]
  B --> M["Contact model: name, designation, company, address"]
  M --> G["Grounding: drop any value the block does not say"]
  M -.->|no key, or the call fails| P["partial: rule fields and the display name"]
  R1 --> MG["Merge: rule fields win, the PIN's state wins, a PIN mismatch is flagged"]
  R2 --> MG
  G --> MG
  P --> MG
```

## What goes to which model

| Call                                                         | Model             | Sent                                                                                          | Never sent                                                                          |
| ------------------------------------------------------------ | ----------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Classify                                                     | Jev, TypeSafe     | Subject, new text, forwarded text, attachment names                                           | Documents                                                                           |
| Categories                                                   | Jev, TypeSafe     | Subject, attachment names, the client body, at most 20,000 characters                         | With a client: colleague note, quoted history other than the client's email, signatures; always: documents |
| Asks                                                         | Jev, TypeSafe     | Candidate lines, each with the line above it                                                  | Everything else                                                                     |
| Contact                                                      | The contact model | The contact block: up to 15 sign-off lines and the client's header lines, forwarded or quoted | Body, the rest of the quoted history, colleague note, documents                     |
| Copies, labelling, routing, the due date, phones, GSTIN, PIN | None              | Nothing                                                                                       |                                                                                     |

The client body can be the client's email quoted below a colleague's reply. With no client found, it is the email's own new, forwarded and quoted lines, the colleague's words and any sign-off inside quoted text included, and there is no contact block; a signature split off the new or forwarded text is left out. A forward's client header lines can include `To` and `Cc` lines naming the client's colleagues. Text sent to either model is kept under the provider's default terms; there is no zero-retention agreement yet. In `pnpm flow`, each model call asks consent first and says where the text goes.

## When a model fails

|            | Another failure                                | Rate limit, listener | Rate limit, `pnpm flow` | No key                                   |
| ---------- | ---------------------------------------------- | -------------------- | ----------------------- | ---------------------------------------- |
| Classify   | The job fails and the queue retries it          | The queue waits      | Fails open              | Off; the listener will not start with model calls on |
| Categories | Asked once more, then the vocabulary alone      | The queue waits      | The vocabulary alone    | The vocabulary alone, in `pnpm flow`     |
| Asks       | Asked once more, then keyword asks, unconfirmed | The queue waits      | Keyword asks            | Keyword asks, in `pnpm flow`             |
| Contact    | Asked once more, then the rule fields           | The queue waits      | The rule fields         | The rule fields                          |

A part that falls back is saved `partial`, with its error when a call failed. A partial reading still finishes the run, and is marked partial in the job's log and on the mail debug page. One part failing never loses the other two.

## Run state

- **A copy** finishes at origin, and nothing else runs.
- **Anything that is not an enquiry** finishes when its labels are saved.
- **An enquiry** finishes when its reading is saved.
- **A new document, classification or pack** reopens the run until the stage that finishes it runs again.

```mermaid
stateDiagram-v2
  [*] --> RUNNING: job starts
  RUNNING --> DONE: copy recorded
  RUNNING --> DONE: labels saved, not an enquiry
  RUNNING --> DONE: reading saved, enquiry
  DONE --> RUNNING: new document, classification or pack
  RUNNING --> RUNNING: a stage fails and is retried
```

How the run's state shows on the email, and what the sweeper does with a run that stalls, are on [Sync to labels](/engineering/sync-to-labels).
