Skip to content
FlowOS Guide
Esc
↑↓navigate↵open⌘Jpreview
On this page

Enquiry reading

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.

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, and the layers after reading are on 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

  • 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:

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.

  • 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.

  • 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 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.

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
  1. 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.

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, and a forward whose client header time cannot be read is dated from when it was forwarded, in India time.

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.

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.

How the run’s state shows on the email, and what the sweeper does with a run that stalls, are on Sync to labels.

Was this page helpful?