AI Summaries of CRM Emails with n8n and Mistral: The Prompt, the Parser and the Comma
Part 5 of 6 in the series The Follow-Up Machine
- Overview, Automated CRM Follow-Up That Tells You When It Breaks
- Architecture, An n8n Workflow Example Built to Survive Retries: The Follow-Up Machine Architecture
- Register, EspoCRM Webhooks in n8n: The Event Name, the Signature and the Stage That Did Not Exist
- Evaluate, An Hourly n8n Workflow That Does Not Overlap Itself: Locks, Merge Barriers and Quiet Runs
- Enrich, AI Summaries of CRM Emails with n8n and Mistral: The Prompt, the Parser and the Comma
- Alerts, n8n Error Workflows That Actually Alert: Deduplication, Stale Locks and the Friday Email
All 6 parts
- Overview, Automated CRM Follow-Up That Tells You When It Breaks
- Architecture, An n8n Workflow Example Built to Survive Retries: The Follow-Up Machine Architecture
- Register, EspoCRM Webhooks in n8n: The Event Name, the Signature and the Stage That Did Not Exist
- Evaluate, An Hourly n8n Workflow That Does Not Overlap Itself: Locks, Merge Barriers and Quiet Runs
- Enrich, AI Summaries of CRM Emails with n8n and Mistral: The Prompt, the Parser and the Comma
- Alerts, n8n Error Workflows That Actually Alert: Deduplication, Stale Locks and the Friday Email
This is part 5 of the follow-up machine series. Part 4 covered how the machine decides that a follow-up is due. This part covers “Followup 4: Enrich”, which answers the next question: what should the follow-up be about?
It reads the last three emails on each deal and turns them into two lines: where the deal stands, and an angle for the next follow-up. Those lines appear in the ClickUp task and in the morning list.
It was not in the original plan. It was added on the second day, and most of its bugs appeared only once real email text went through it.
What it does and does not do
- It summarises. It does not write the follow-up email and it sends nothing.
- It reads emails only, not calls or meetings.
- It runs at half past every hour, 07:30 to 19:30 on weekdays. Evaluate runs on the hour, so fresh context is in the database before the next task is created.
- It is not gated by dry-run. The model call only reads from the CRM and sends nothing outward. It still goes to Mistral’s API and still costs money there, and the summary is written to Postgres either way (see part 2).
Only summarise what changed
Calling a model every hour for every open deal would be slow, cost money and change nothing. So each watch stores a high-water mark, enriched_activity_at: the timestamp of the newest activity that has already been summarised.
For each open watch, the workflow fetches only the newest activity:
GET {ESPO_BASE_URL}/api/v1/Activities/Opportunity/{id}/history?maxSize=1&orderBy=dateStart&order=desc
and an IF node decides whether there is anything new:
{{ !!$json.latest_activity_at
&& (!$json.enriched_activity_at
|| Date.parse($json.latest_activity_at) > Date.parse($json.enriched_activity_at)) }}
The first version compared the two timestamps as strings. Postgres serialises timestamptz with milliseconds and its own offset format, so the string comparison gave the wrong answer. Parse both, compare numbers.
A Set node that crashed on real data
Between the fetch and that IF node sits a Set node, “Map head”, that shapes the result. It shipped with two fields that had no explicit type. Set node version 3.4 calls .toLowerCase() on the type, so a missing type throws:
Cannot read properties of undefined (reading 'toLowerCase')
It only threw when the field had a real value. So it survived every test run where the deal had no activity, and failed on 5 August, the first run where a held call existed. Every assignment in a Set node now gets an explicit type.
The same node also uses an empty string, not null, for “no value”. '' is falsy, so the IF node skips correctly. A null that gets turned into the text 'null' somewhere along the way is truthy, and would have blocked every first summary without an error.
Which emails count
GET {ESPO_BASE_URL}/api/v1/Email
?where[0][type]=equals&where[0][attribute]=parentId&where[0][value]={id}
&where[1][type]=equals&where[1][attribute]=parentType&where[1][value]=Opportunity
&orderBy=dateSent&order=desc&maxSize=3
&select=bodyPlain,dateSent,from,name
(plus a filter on status, see below)
Two things had to change after the first real run:
- Drafts are not correspondence. EspoCRM leaves
dateSentempty on drafts, and the prompt builder sorted by it, so any draft attached to a deal crashed the node. The query now takes only emails with the status Sent, Archived or Received, and the sort handles an empty date anyway. It is the same reasoning as a planned call not counting as contact. - Email permissions are per participant. EspoCRM restricts emails to their participants. The API user needs Email: Read = All in its role, or it cannot read correspondence it was not part of.
The prompt
The prompt is built in TypeScript and tested outside n8n:
export const MAX_BODY_CHARS = 3000;
export const MAX_LINE_CHARS = 160;
export function buildEnrichmentPrompt(deal: EnrichDeal, emails: EnrichEmail[]): string {
const ordered = [...emails].sort((a, b) => (b.dateSent ?? '').localeCompare(a.dateSent ?? ''));
const blocks = ordered.map((e) => {
const body = e.bodyPlain.length > MAX_BODY_CHARS ? `${e.bodyPlain.slice(0, MAX_BODY_CHARS)}…` : e.bodyPlain;
return [`Date: ${e.dateSent ?? 'unknown'}`, `From: ${e.from ?? 'unknown'}`, `Subject: ${e.subject ?? '(none)'}`, body].join('\n');
});
return [
`You are helping a solo consultant follow up on the sales deal "${deal.name}"${deal.stage ? ` (stage: ${deal.stage})` : ''}.`,
'Below is the most recent email correspondence, newest first.',
'',
blocks.join('\n\n---\n\n'),
'',
'Respond with ONLY a JSON object, no prose, no code fences:',
`{"context": "<one line, max ${MAX_LINE_CHARS} chars: where the deal stands right now>",` +
` "suggested_next": "<one line, max ${MAX_LINE_CHARS} chars: concrete angle for the next follow-up>"}`,
'Write both lines in the same language as the correspondence.',
].join('\n');
}
Three choices in it:
- Each email body is cut at 3,000 characters. Quoted reply chains make email bodies long, and the newest part is at the top.
- The output format is JSON with two fixed keys, not free text. What it checks is the expected structure and fields, not whether the summary is right.
- “The same language as the correspondence.” Deals can run in German or English. A German summary of an English thread is one more thing to translate while reading.
A parser can check JSON. It cannot check prose.
The model node
In n8n this is a Basic LLM Chain node with a Mistral Cloud Chat Model attached, model mistral-small-latest.
One trap: n8n also has a node called Mistral AI in its base nodes. That one is for OCR, not chat, and it is easy to pick by mistake.
Mistral Small is enough. The task is to compress three emails into two lines, not to reason about them.
The parser trusts nothing
function oneLine(value: unknown): string | null {
if (typeof value !== 'string') return null;
const collapsed = value.replace(/\s*\n\s*/g, ' ').trim();
if (collapsed.length === 0) return null;
return collapsed.length > MAX_LINE_CHARS ? collapsed.slice(0, MAX_LINE_CHARS) : collapsed;
}
export function parseEnrichmentResponse(raw: string): Enrichment {
const stripped = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
let parsed: unknown;
try {
parsed = JSON.parse(stripped);
} catch {
throw new Error(`enrichment parse failed: not JSON: ${stripped.slice(0, 120)}`);
}
const obj = parsed as Record<string, unknown>;
const context = oneLine(obj['context']);
const suggestedNext = oneLine(obj['suggested_next']);
if (!context || !suggestedNext) {
throw new Error('enrichment parse failed: context/suggested_next missing or empty');
}
return { context, suggestedNext };
}
The prompt says “no code fences”. Models add them anyway sometimes, so the parser strips them. Line breaks inside a value are collapsed, and each line is cut at 160 characters, because a task description is not the place for a paragraph.
If the response still is not usable, the parser throws. The Code node routes that to its error output, the deal is skipped, and the run is marked partial. A response that does not parse, or that does not carry the expected fields, is never stored. The high-water mark is not moved either, so the deal is tried again next hour.
The comma
The summary goes to Postgres. And the first real summary broke the write.
Part 3 described how the n8n Postgres node takes its query parameters as one comma-separated string of expressions. What part 3 did not cover: the node splits that string after resolving the expressions. A comma inside a value splits the value.
The first real Mistral output was a German sentence with two commas. It became three parameters. Every parameter after it shifted, and watch_id::bigint received half a German sentence.
Commas inside balanced braces survive the split. So the fix passes one JSON object as the only parameter and unpacks it in SQL:
update watches
set context = $1::jsonb->>'context',
suggested_next = $1::jsonb->>'suggested_next',
enriched_activity_at = ($1::jsonb->>'enriched_activity_at')::timestamptz,
updated_at = now()
where id = ($1::jsonb->>'watch_id')::bigint
returning id;
The same fix then went into two more places that had never been hit: the alert workflow, which stores error messages, and deal registration, which stores deal names. A deal called “Müller, GmbH” would have corrupted its own registration.
The test data had no commas in it. That was the only reason neither had failed.
Free text from a model or a user never goes into that parameter string as a separate value.
Privacy, briefly
This workflow sends the plain text of up to three emails per deal to Mistral’s API. Those emails contain what the other side of the conversation wrote, and they did not choose the model provider. If you build this, for yourself or for a client, check what the provider does with API input and what your processing agreement says, before the first real run.
What to take from this part
- Store a high-water mark, and only call the model for new activity.
- Compare timestamps as instants, not strings.
- Give every Set node assignment an explicit type.
- Ask for JSON, parse strictly against the expected structure, and never store what fails that check.
- Never pass free text as a separate Postgres node parameter. Pass one JSON object.
- Test with data that looks like real data: commas, drafts, empty dates.
Next: Part 6, Alerts and reports.