feat(quotes): resolve customer from person_hint when rep forwards email

Gary (and any future rep) forwards inbound RFQs to a shared mailbox.
Today the customer linked to the resulting quote is the rep (or the
Unknown Sender sentinel), because the only resolution path is exact
email match — the from_email resolves to Gary, not the actual customer.

Add person_hint to EmailIntakePayload. The AI extraction layer reads
the subject / body / signature for the customer being quoted FOR and
fills person_hint. findOrCreateCustomer now walks three resolution
passes in order:

  1. Exact email match (existing behaviour — direct RFQs unchanged)
  2. person_hint resolution: case-insensitive substring on
     customer.customer, with a companyHint tie-break when multiple
     candidates exist
  3. Existing sender-based create-new path (Gary wins, quoter
     reassigns in the WFDASH UI)

MCP schema for quotes.create_from_email surfaces the new field with
doc explaining the rep-forwarded case. /api/v1/quotes/from-email
doc updated. AI Agent system prompt in the n8n workflow doc now
extracts person_hint with worked examples.

Also extends the mock filter parser to accept PB's bare ~ operator
(production code uses ~; the mock previously only accepted =~).

5 new unit tests + 2 integration tests for the new path.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-27 10:45:51 +02:00
parent 4d8382c87f
commit dc84ce0f30
5 changed files with 279 additions and 9 deletions

View file

@ -220,14 +220,22 @@ Calls `attachments.upload_for_quote` via the WFDASH MCP. Reads the binary from `
> Workflow:
> 1. Read the email body (in your user message). For each attachment, also look at the extracted_text_preview field — that is the first 2KB of the PDF text. If the email body is short or empty, the line items are almost certainly in the PDF.
> 2. Extract the buyer/end-customer company name (company_hint). Empty string if unclear.
> 3. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.
> 4. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase.
> 5. Capture the quote_id from the tool result.
> 6. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.
> 7. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=...
> 3. Extract the customer being quoted FOR (person_hint). **This is NOT the email sender** — when a rep (e.g. Gary, the South Africa field rep) forwards an inbound RFQ or sends a quote request for a customer, the sender is Gary and the actual customer is whoever Gary named in the subject line, the email body, or a signature. Examples:
> - Subject `Quote for John Smith at ABC Mining` → person_hint=`John Smith`, company_hint=`ABC Mining`
> - Subject `RE: Bearings enquiry — Peter Jones (Acme)` → person_hint=`Peter Jones`, company_hint=`Acme`
> - Subject `RFQ from John Smith` (Gary forwarding) → person_hint=`John Smith`
> - Subject `Quote request` with body `Please quote for Sarah at TechCo` → person_hint=`Sarah`, company_hint=`TechCo`
>
> If no specific customer is named and the email IS from the customer themselves (the from_email address is their personal / corporate domain, not a wfsales.co.za rep), set person_hint to empty string — the from_email will resolve correctly. Only set person_hint when the sender is clearly NOT the customer.
> 4. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.
> 5. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase. Include `person_hint` and `company_hint` in the call.
> 6. Capture the quote_id from the tool result.
> 7. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.
> 8. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=... person_hint=...
>
> Important:
> - Empty body / no items is OK — create the quote anyway with an empty extracted_items array.
> - Empty person_hint is OK when from_email IS the customer (typical direct RFQ).
> - attachments metadata only (no base64) — the binary lives in the workflow's binary store; only upload_attachment_tool knows how to read it.
---

View file

@ -1084,7 +1084,8 @@ const tools = [
{
name: "quotes.create_from_email",
description:
"Create a new quote (with customer/company/items) from a parsed email payload. Idempotent on message_id. `from_email` is optional — if missing (e.g. an email with no From: header), the quote is linked to a sentinel 'Unknown Sender' customer (`unknown-sender@no-from.wfdash.local`) that the quoter reassigns in the existing quote-editing form. `message_id` is also optional in the same spirit — a stable `synth-…@wfdash.local` id is derived from the email identity when missing. The legacy `attachments` field is accepted for back-compat but no longer uploaded from this call — use `attachments.upload_for_quote` once per file after this call returns the new quote id.",
"Create a new quote (with customer/company/items) from a parsed email payload. Idempotent on message_id. `from_email` is optional — if missing (e.g. an email with no From: header), the quote is linked to a sentinel 'Unknown Sender' customer (`unknown-sender@no-from.wfdash.local`) that the quoter reassigns in the existing quote-editing form. `message_id` is also optional in the same spirit — a stable `synth-…@wfdash.local` id is derived from the email identity when missing. The legacy `attachments` field is accepted for back-compat but no longer uploaded from this call — use `attachments.upload_for_quote` once per file after this call returns the new quote id.\n\n" +
"**Rep-forwarded emails:** when the inbound email is forwarded by a rep (e.g. Gary) who named the actual customer in the subject line, pass `person_hint` with the named person's name. The service layer resolves it against existing customers (case-insensitive substring on the customer name, with a company-hint tie-break when multiple candidates exist) BEFORE the `from_email` sender. If it resolves, the quote is linked to the named person, not the rep. If it doesn't resolve, the sender (rep) wins so the quote still lands attributable. The quoter can reassign in the WFDASH UI.",
inputSchema: {
type: "object",
required: ["subject"],
@ -1098,6 +1099,11 @@ const tools = [
in_reply_to: { type: "string" },
received_at: { type: "string" },
company_hint: { type: "string" },
person_hint: {
type: "string",
description:
"The customer being quoted FOR. Used when the email is forwarded by a rep who named the actual customer in the subject line. Resolved BEFORE from_email. Pass empty / omit when from_email IS the customer.",
},
extracted_items: {
type: "array",
items: {
@ -4232,6 +4238,7 @@ export async function POST(req: NextRequest) {
in_reply_to: args.in_reply_to,
received_at: args.received_at,
company_hint: args.company_hint,
person_hint: args.person_hint,
extracted_items: args.extracted_items,
attachments: args.attachments,
};

View file

@ -26,6 +26,14 @@ import {
* in_reply_to string optional used by reply matching
* received_at string optional ISO timestamp
* company_hint string optional preferred company name
* person_hint string optional the customer being quoted FOR. Used
* when a rep (e.g. Gary) forwards an
* inbound email and names the actual
* customer in the subject. Resolved
* BEFORE the sender's email so the
* quote lands on the right customer,
* not on Gary. Falls through to the
* sender if no match.
* attachments array optional ACCEPTED but NOT acted on — caller
* must follow up with
* attachments.upload_for_quote per file

View file

@ -95,16 +95,23 @@ const { pbMock, clearStore } = vi.hoisted(() => {
}
}
}
// Leaf — a comparison. `!=`, `=~`, `!~`, `>=`, or `=`.
// Leaf — a comparison. `!=`, `=~`, `~`, `!~`, `>=`, or `=`.
// Value can be empty (e.g. `field != ""`) so we use `*?` not `+?`.
const m = s.match(/^(\w+)\s*(=~|!~|>=|<=|=|!=)\s*"?([^"]*?)"?$/);
// Note: the regex matches `!~` BEFORE `~` so the leading `!`
// doesn't get stripped on its own.
const m = s.match(/^(\w+)\s*(=~|!~|>=|<=|=|!=|~)\s*"?([^"]*?)"?$/);
if (!m) {
throw new Error(`Mock filter parser: cannot parse "${s}"`);
}
const [, field, op, value] = m;
let [, field, op, value] = m;
// PB accepts both `~` and `=~` for the contains operator; normalise
// them so service code can use whichever form PB's docs recommend
// for the version it targets.
if (op === "~") op = "=~";
return { kind: "cmp", field, op: op as "=" | "!=" | "=~" | "!~" | ">=" | "<=", value };
};
const evalFilter = (item: Record<string, unknown>, ast: FilterAST): boolean => {
switch (ast.kind) {
case "and": return evalFilter(item, ast.left) && evalFilter(item, ast.right);
@ -396,6 +403,109 @@ describe("findOrCreateCustomer", () => {
it("throws when email is missing", async () => {
await expect(findOrCreateCustomer({ email: "" })).rejects.toThrow(/email is required/);
});
it("resolves an existing customer by person_hint when the sender has no customer row", async () => {
// Gary is a rep — his email has no customer row. He forwards a
// customer's RFQ; the from_email is Gary (no match in the customer
// table) and the actual customer is John Smith. person_hint should
// resolve to John's existing row, NOT create a new customer tied to
// Gary's email.
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@personal.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "gary@wfsales.co.za",
name: "Gary",
personHint: "John Smith",
});
expect(created).toBe(false);
expect(customer.id).toBe(john.id);
expect(customer.customer).toBe("John Smith");
});
it("falls back to sender (Gary) when person_hint has no match", async () => {
// person_hint doesn't resolve to any existing customer. The function
// must NOT silently invent a customer — it falls through to the
// sender-based create-new path. This means a brand-new customer row
// is created tied to Gary's email, which the quoter reassigns in
// the WFDASH UI.
const { customer, created } = await findOrCreateCustomer({
email: "gary-new@wfsales.co.za",
name: "Gary New",
personHint: "Nobody By This Name",
});
expect(created).toBe(true);
expect(customer.email).toBe("gary-new@wfsales.co.za");
expect(customer.customer).toBe("Gary New");
});
it("falls back to existing exact-email match when person_hint is empty", async () => {
// Empty / missing person_hint must not change behaviour — exact email
// match wins, otherwise we create a new customer.
const existing = await pbMock.collection("customers").create({
customer: "Jane Buyer",
email: "jane@buyer.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "jane@buyer.co.za",
personHint: "",
});
expect(created).toBe(false);
expect(customer.id).toBe(existing.id);
});
it("falls back to existing exact-email match when person_hint matches a different customer but sender is already the right one", async () => {
// If the from_email already resolves to the right customer (rare
// but possible — Gary forwards his own email to himself), we
// shouldn't churn. Exact-email still wins because it's the first
// resolution pass.
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@abcmining.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "john@abcmining.co.za",
personHint: "John Smith",
});
expect(created).toBe(false);
expect(customer.id).toBe(john.id);
});
it("tie-breaks person_hint candidates by companyHint", async () => {
// Two customers named "John Smith" exist. companyHint should pick
// the one whose company matches.
const acmeCo = await pbMock.collection("companies").create({
company: "Acme Mining",
address: "",
vendor: "",
});
await pbMock.collection("companies").create({
company: "Other Co",
address: "",
vendor: "",
});
const johnAcme = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@acme-personal.co.za",
company: acmeCo.id,
});
await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@other.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "gary@wfsales.co.za",
personHint: "John Smith",
companyHint: "Acme Mining",
});
expect(created).toBe(false);
expect(customer.id).toBe(johnAcme.id);
});
});
describe("findOrCreateCompany", () => {
@ -651,6 +761,45 @@ describe("createQuoteFromEmail", () => {
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
});
it("links the quote to the named customer when a rep forwards with person_hint", async () => {
// Gary (the rep) forwards an inbound RFQ. The customer is John Smith
// at ABC Mining — both already exist. person_hint + company_hint make
// the quote land on the right customer, not on Gary.
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@personal.co.za",
company: "",
});
const result = await createQuoteFromEmail({
...basePayload,
from_email: "gary@wfsales.co.za",
from_name: "Gary",
subject: "Quote for John Smith at ABC Mining",
person_hint: "John Smith",
company_hint: "ABC Mining",
});
expect(result.customer.id).toBe(john.id);
expect(result.customer.customer).toBe("John Smith");
// Gary's email is still recorded on the quote (audit), but he is NOT
// the linked customer.
expect(result.quote.email_sender).toBe("gary@wfsales.co.za");
});
it("falls back to the sender (rep) when person_hint does not match any customer", async () => {
// The rep names someone we don't have in the database. Don't invent —
// land on the rep so the quote stays attributable; the quoter
// reassigns in the UI.
const result = await createQuoteFromEmail({
...basePayload,
from_email: "gary@wfsales.co.za",
from_name: "Gary",
subject: "Quote for Someone New",
person_hint: "Someone New",
});
expect(result.customer.email).toBe("gary@wfsales.co.za");
expect(result.customer.customer).toBe("Gary");
});
it("two emails with no from_email both link to the same sentinel customer", async () => {
// Different subjects bypass the message_id dedup, so two distinct
// quotes are created — but both should reuse the same sentinel.

View file

@ -76,6 +76,18 @@ export interface EmailIntakePayload {
attachments?: EmailIntakeAttachment[];
/** Optional override for company matching — used by the AI extraction layer. */
company_hint?: string;
/**
* Optional override for customer matching — used by the AI extraction
* layer when the inbound email is forwarded by a rep (e.g. Gary) who
* named the actual customer in the subject line. When set, this name
* is resolved against existing customers (case-insensitive exact, then
* a normalised-token pass) BEFORE the `from_email` sender is used as
* the customer. If it resolves, the linked customer is the named
* person, not the rep. If it doesn't resolve, the resolution falls
* back to the sender (Gary) so the quote still lands somewhere
* attributable rather than on the Unknown Sender sentinel.
*/
person_hint?: string;
}
export interface EmailReplyPayload {
@ -989,6 +1001,18 @@ export async function findOrCreateCustomer(input: {
email: string;
name?: string;
companyHint?: string;
/**
* Optional name to resolve BEFORE the email. Used when the inbound
* email is forwarded by a rep (e.g. Gary) who named the actual
* customer in the subject line — the from_email then matches the rep,
* not the customer, so `person_hint` lets us link the quote to the
* right person. Resolution is case-insensitive exact match on the
* `customer.customer` field; on a tie (multiple matches) we prefer
* the one whose company matches `companyHint`. If no match, falls
* through to the existing sender-based create-new path — Gary's
* existing customer row wins, so the quote still lands attributable.
*/
personHint?: string;
}): Promise<{ customer: Customer; created: boolean }> {
const pb = await getAdminPocketBase();
const email = (input.email || "").trim().toLowerCase();
@ -1001,6 +1025,19 @@ export async function findOrCreateCustomer(input: {
return { customer: exact, created: false };
}
// Rep-forwarded case: the sender (e.g. Gary) is not the customer
// being quoted for. Try to resolve the named person against the
// customer table before falling back to creating a new customer row
// tied to the sender's email — otherwise we'd auto-create a junk
// "Gary" customer or duplicate him across every forwarded quote.
const personHint = (input.personHint || "").trim();
if (personHint) {
const personMatch = await findCustomerByNameHint(pb, personHint, input.companyHint);
if (personMatch) {
return { customer: personMatch, created: false };
}
}
const senderDomain = email.split("@")[1] || "";
const fallbackName = input.name?.trim() || senderDomain || email;
// Resolve the company hint to a relation id, not a free-text name —
@ -1100,6 +1137,66 @@ async function findCustomerByExactEmail(
return null;
}
/**
* Resolve a free-text person name (e.g. "John Smith" extracted from the
* email subject) to an existing customer row. Two-step match:
*
* 1. Case-insensitive substring match on `customer.customer` via PB's
* `~` operator. Returns up to N candidates — usually one.
* 2. If multiple candidates come back (two "John Smith"s in the
* database), prefer the one whose company relation matches
* `companyHint` (case-insensitive exact on `company.company`).
* Falls back to the first candidate if no company hint was given
* or no company match was found.
*
* Returns `null` when no candidate matches. The caller falls through
* to the sender-based create-new path in that case — the rep (Gary)
* becomes the customer, which is at least attributable and the
* quoter can reassign in the WFDASH UI.
*/
async function findCustomerByNameHint(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
personHint: string,
companyHint?: string
): Promise<Customer | null> {
const trimmed = personHint.trim();
if (!trimmed) return null;
const esc = trimmed.replace(/"/g, '\\"');
// PB `~` is case-insensitive substring. We grab a small page; in
// practice this returns 1-3 rows for any non-trivial name.
let candidates: Customer[] = [];
try {
candidates = (
await pb.collection(CUSTOMERS_COLLECTION).getList<Customer>(1, 5, {
filter: `customer ~ "${esc}"`,
fields: "id,customer,company",
expand: "company",
})
).items;
} catch {
return null;
}
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
// Tie-break by company hint when available. The AI usually passes
// both, so the hint effectively narrows the candidate set.
const hint = (companyHint || "").trim();
if (hint) {
const hintEsc = hint.replace(/"/g, '\\"');
for (const c of candidates) {
const companyName = (c.expand?.company?.company || "") as string;
if (companyName && companyName.toLowerCase() === hintEsc.toLowerCase()) {
return c;
}
}
}
// No company hint or no company match — return the first candidate
// rather than creating a duplicate. Quoter can disambiguate.
return candidates[0];
}
// ──────────────────────────────────────────────────────────────────────────────
// Company matching
// ──────────────────────────────────────────────────────────────────────────────
@ -1256,6 +1353,7 @@ export async function createQuoteFromEmail(
email: fromEmail,
name: payload.from_name,
companyHint: payload.company_hint,
personHint: payload.person_hint,
})
: await findOrCreateUnknownSenderCustomer(pb);