Isolate the newest reply in an email thread
Separate the message that just arrived from the history copied under it.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the boundary you are looking for is not to be inferred: it is a string the mail client wrote before sending. The two libraries in the field are email_reply_parser in Python and email-reply-parser in JavaScript, and this entry names them without calling them, for a reason it measures. On the same French thread, the first leaves “Le 10 octobre 2026 à 13:55, Marie Martin a écrit :” inside the reply, for want of knowing the French header; the second cuts it, but also removes the signature, because “Bien à vous,” is in its list of sign-offs where “Best,” is not. Two extracts that do not answer the same thing about the same message are of no use on a page that shows both: so the markers are written here, French ones included, and the signature is left alone — a name on a line of its own is sometimes the whole message, and removing it is another entry’s subject. Move up to N3 for the threads, and only those, where the report says the reply was written inside the quote.
The marker the mail client wrote, French ones included
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Keep the last reply of an email thread, and drop the thread under it.
Rung N0. An email client does not send the reply alone: it sends the reply,
then the whole conversation under it, marked by something the client wrote
itself — a « > » in front of each line, a line of underscores, a header block,
or the sentence « Le 10 octobre 2026 à 13:55, Marie Martin a écrit : ». Those
markers are what this rung looks for, and the reply is what sits above the
first of them.
The libraries that do this are `email-reply-parser` in JavaScript and
`email_reply_parser` in Python. They were written for English threads, and it
shows: on the French thread of this entry's test, the Python one leaves
« Le 10 octobre 2026 à 13:55, Marie Martin <marie@exemple.fr> a écrit : » in
the reply, and the JavaScript one removes the signature it keeps on the English
thread. Two extracts that answer differently on the same message are of no use
to a page that shows both, so the markers are written here, French ones
included.
One thing is deliberately not done: removing the signature. « Bien à vous, Jean
Dupont » is part of what Jean wrote, and a line holding nothing but a name is
sometimes the whole message. The cut happens at the quote, and stops there.
"""
from __future__ import annotations
import re
# What a mail client writes when it quotes, and what is under each marker.
# « quoted » means the old message is prefixed line by line, so what is not
# prefixed under it was written by the sender; « block » means it is copied
# as is, and nothing tells it apart from a reply.
MARKERS = [
# A quoted line, the oldest convention of all.
("quoted", re.compile(r"^\s*>")),
# Outlook's separator, and the header block that follows it.
("block", re.compile(r"^\s*_{5,}\s*$")),
("block", re.compile(r"^\s*(De|From|Expéditeur)\s*:\s+\S")),
# « -----Message d'origine----- » and its translations.
("block", re.compile(r"^\s*-{2,}\s*(Message d'origine|Message transféré"
r"|Original Message|Forwarded message)\s*-{2,}\s*$",
re.IGNORECASE)),
]
# « Le 10 octobre 2026 à 13:55, Marie Martin a écrit : », which Gmail wraps
# over two lines. Opening and closing are therefore looked for separately,
# and the closing is tried on one line, then two, then three.
ATTRIBUTION_OPENS = re.compile(r"^\s*(Le|On)\s+\S")
ATTRIBUTION_CLOSES = re.compile(r"(a écrit|wrote)\s*:\s*$")
ATTRIBUTION_LINES = 3
# Every mail client writes a date or an address in that line. Requiring one
# keeps « Le client a écrit : », which is a sentence and not a marker, from
# cutting the message at its first line.
ATTRIBUTION_DATES = re.compile(r"\d|@")
LINE_END = re.compile(r"\r\n|\r|\n")
# Characters the two languages do not class alike: a byte-order mark is a space
# for JavaScript and not for Python, and the C1 separators are the reverse.
# None of them belongs in a reply, and leaving them in would make the two
# extracts of this entry cut at different places.
ODD_SPACES = re.compile(r"[\ufeff\x1c-\x1f\x85]")
def extract_reply(message) -> dict:
"""
The text above the first quote marker, and what was found under it.
`reason` is set when the reply looks like it was written under the quote or
inside it: the cut then returns almost nothing, and « almost nothing » must
not be handed back as « the reply was empty ». What the attribution line
itself holds is not counted as text written under the quote — it is the
dressing of the quote, and it is sixty-nine characters long, which is
longer than most business replies.
`quoted_from_line` counts lines in the message split on CR, LF and CRLF,
and on those only — not the way `str.splitlines()` or `String.split` would
do it. A caller that uses the number splits the same way, with the regular
expression named below.
"""
if not isinstance(message, str):
return _report("", None, f"expected text, not {type(message).__name__}")
# Split on the three line endings a mail carries, and on those only:
# str.splitlines() also cuts on a form feed and on U+2028, which the
# JavaScript side does not, and the two would stop answering alike.
lines = LINE_END.split(ODD_SPACES.sub("", message))
cut, kind, dressing = _first_marker(lines)
if cut is None:
return _report(message.strip(), None, None)
reply = "\n".join(lines[:cut]).strip()
if kind == "quoted" and _unquoted_under(lines[cut + dressing:]) > len(reply):
return _report(reply, cut, "more text was written under the quote than above it")
return _report(reply, cut, None)
def _first_marker(lines):
"""
Where the quoted thread begins, how the old message is marked, and how many
lines the marker itself takes.
The third number is what keeps the attribution out of the count of what was
written under the quote: a `>` prefix is already dropped by the filter, an
attribution is not.
"""
for index, line in enumerate(lines):
for kind, marker in MARKERS:
if marker.search(line):
return index, kind, 0
if ATTRIBUTION_OPENS.match(line):
for length in range(1, ATTRIBUTION_LINES + 1):
window = " ".join(lines[index:index + length]).rstrip()
if ATTRIBUTION_CLOSES.search(window) and ATTRIBUTION_DATES.search(window):
return index, "quoted", length
return None, None, 0
def _unquoted_under(lines) -> int:
"""How much text under the cut carries no quote prefix."""
loose = [line for line in lines if line.strip() and not line.lstrip().startswith(">")]
return len("\n".join(loose).strip())
def _report(reply: str, cut, reason: str | None) -> dict:
return {"reply": reply, "quoted_from_line": cut, "reason": reason}JavaScript
/**
* Keep the last reply of an email thread, and drop the thread under it.
*
* Rung N0. An email client does not send the reply alone: it sends the reply,
* then the whole conversation under it, marked by something the client wrote
* itself — a « > » in front of each line, a line of underscores, a header
* block, or the sentence « Le 10 octobre 2026 à 13:55, Marie Martin a écrit : ».
* Those markers are what this rung looks for, and the reply is what sits above
* the first of them.
*
* The libraries that do this are `email-reply-parser` in JavaScript and
* `email_reply_parser` in Python. They were written for English threads, and
* it shows: on the French thread of this entry's test, the Python one leaves
* « Le 10 octobre 2026 à 13:55, Marie Martin <marie@exemple.fr> a écrit : » in
* the reply, and the JavaScript one removes the signature it keeps on the
* English thread. Two extracts that answer differently on the same message are
* of no use to a page that shows both, so the markers are written here, French
* ones included.
*
* One thing is deliberately not done: removing the signature. « Bien à vous,
* Jean Dupont » is part of what Jean wrote, and a line holding nothing but a
* name is sometimes the whole message. The cut happens at the quote, and stops
* there.
*/
// What a mail client writes when it quotes, and what is under each marker.
// « quoted » means the old message is prefixed line by line, so what is not
// prefixed under it was written by the sender; « block » means it is copied
// as is, and nothing tells it apart from a reply.
export const MARKERS = [
// A quoted line, the oldest convention of all.
['quoted', /^\s*>/],
// Outlook's separator, and the header block that follows it.
['block', /^\s*_{5,}\s*$/],
['block', /^\s*(De|From|Expéditeur)\s*:\s+\S/],
// « -----Message d'origine----- » and its translations.
['block', /^\s*-{2,}\s*(Message d'origine|Message transféré|Original Message|Forwarded message)\s*-{2,}\s*$/i],
];
// « Le 10 octobre 2026 à 13:55, Marie Martin a écrit : », which Gmail wraps
// over two lines. Opening and closing are therefore looked for separately,
// and the closing is tried on one line, then two, then three.
const ATTRIBUTION_OPENS = /^\s*(Le|On)\s+\S/;
const ATTRIBUTION_CLOSES = /(a écrit|wrote)\s*:\s*$/;
const ATTRIBUTION_LINES = 3;
// Every mail client writes a date or an address in that line. Requiring one
// keeps « Le client a écrit : », which is a sentence and not a marker, from
// cutting the message at its first line.
const ATTRIBUTION_DATES = /\d|@/;
const LINE_END = /\r\n|\r|\n/;
// Characters the two languages do not class alike: a byte-order mark is a
// space for JavaScript and not for Python, and the C1 separators are the
// reverse. None of them belongs in a reply, and leaving them in would make the
// two extracts of this entry cut at different places.
const ODD_SPACES = /[\ufeff\x1c-\x1f\x85]/g;
/**
* The text above the first quote marker, and what was found under it.
*
* `reason` is set when the reply looks like it was written under the quote or
* inside it: the cut then returns almost nothing, and « almost nothing » must
* not be handed back as « the reply was empty ». What the attribution line
* itself holds is not counted as text written under the quote — it is the
* dressing of the quote, and it is sixty-nine characters long, which is
* longer than most business replies.
*
* `quoted_from_line` counts lines in the message split on CR, LF and CRLF, and
* on those only — not the way `String.prototype.split(/\s/)` or Python's
* `str.splitlines()` would do it. A caller that uses the number splits the
* same way, with the regular expression named below.
*/
export function extractReply(message) {
if (typeof message !== 'string') {
return report('', null, `expected text, not ${typeof message}`);
}
const lines = message.replace(ODD_SPACES, '').split(LINE_END);
const [cut, kind, dressing] = firstMarker(lines);
if (cut === null) return report(message.trim(), null, null);
const reply = lines.slice(0, cut).join('\n').trim();
if (kind === 'quoted' && unquotedUnder(lines.slice(cut + dressing)) > reply.length) {
return report(reply, cut, 'more text was written under the quote than above it');
}
return report(reply, cut, null);
}
/**
* Where the quoted thread begins, how the old message is marked, and how many
* lines the marker itself takes.
*
* The third number is what keeps the attribution out of the count of what was
* written under the quote: a `>` prefix is already dropped by the filter, an
* attribution is not.
*/
function firstMarker(lines) {
for (let index = 0; index < lines.length; index += 1) {
for (const [kind, marker] of MARKERS) {
if (marker.test(lines[index])) return [index, kind, 0];
}
if (ATTRIBUTION_OPENS.test(lines[index])) {
for (let length = 1; length <= ATTRIBUTION_LINES; length += 1) {
const window = lines.slice(index, index + length).join(' ').replace(/\s+$/, '');
if (ATTRIBUTION_CLOSES.test(window) && ATTRIBUTION_DATES.test(window)) {
return [index, 'quoted', length];
}
}
}
}
return [null, null, 0];
}
/** How much text under the cut carries no quote prefix. */
function unquotedUnder(lines) {
const loose = lines.filter((line) => line.trim() && !line.trimStart().startsWith('>'));
return loose.join('\n').trim().length;
}
function report(reply, quotedFromLine, reason) {
return { reply, quoted_from_line: quotedFromLine, reason };
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
Nothing goes out on the network: the cut happens in your browser, on the text you paste.
Or start from an example
Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.
1 match
What the code picked up
Hi Marie, The quote is signed, you can start production. Best, Jean On Oct 10, 2026, at 1:55 PM, Marie Martin <marie@exemple.fr> wrote: > Hi Jean, > > Could you confirm quote DV-2026-118 before Friday? > > Regards, > Marie
What it returns
Hi Marie, The quote is signed, you can start production. Best, Jean
What it concludes
Reply kept, quote recognised at line 8
Here it breaksSee the level above
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the message is cut inside your own process and is sent to nobody
- An email thread is correspondence, and carries the personal data of people who asked you nothing: this is the level that keeps it at home
Breaking point
A reply written inside the quote is not above the first marker: on a thread where “Yes, it was signed this morning.” answers under the quoted question, the cut returns nothing, and the report says why. Witness in the same test: the ordinary thread, answered on top, comes back whole, signature included.
When to move up a level
The report says there is more text under the quote than above it, that is, that the reply was written inside it. For those threads, and those only, level N3 reads what no marker delimits.
The four options
- RecommendedN0The marker the mail client wrote, French ones includedFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3The thread’s line numbers, asked of the model and checkedmodéré · About a second
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
A classic model would learn to recognise what a program wrote in plain sight. The one tool in the field that genuinely learns is talon, whose signature module is a classifier trained on annotated messages; it is at version 1.4.4, published on 24 August 2017, and it pins the scikit-learn versions of the day. And it would carry the very bias this entry measures: a classifier trained on English corpora cuts the English thread and not the French one.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same argument, with budget on top. This runs once per message over a mailbox that receives thousands a day; this entry measures it at a few microseconds per message. A resident model, however small, costs orders of magnitude more per message, to recover a boundary the mail client wrote in plain text.
N3The thread’s line numbers, asked of the model and checkedmodéré · About a second
The thread’s line numbers, asked of the model and checked
General-purpose LLM APIN3modéréAbout a second
Python
"""
Ask a model which lines of a thread the last sender wrote.
Rung N3. It exists for the one case rung N0 cannot settle: a reply that is not
above the quote but inside it, line by line, under the sentences it answers.
No marker separates those lines from the ones around them — the client wrote
none — and a model reads them the way a human does.
The guard is what makes this rung defensible, and it is unusually strong here
because the answer is not text but line numbers. The model is given the thread
numbered, and returns the numbers of the lines it believes the last sender
wrote; anything that is not a line of the message that was sent is dropped, and
`dropped` says which. So the reply returned is made of lines of the thread, in
their order, and never of a sentence the model found likelier.
Two operating conditions. The thread is cut to a budget from the end, because
the reply being looked for is normally near the top; a thread longer than the
budget whose reply is at the very bottom is cut away before it is read. And the
cost is a call per message: this rung is for the threads rung N0 has flagged,
not for a mailbox.
"""
from __future__ import annotations
import json
import re
# The budget, in characters. A thread that does not fit is cut, not refused.
MAX_CHARACTERS = 6000
# A single code fence around the whole answer is a common shape, and refusing
# it would buy another call for nothing.
FENCE = re.compile(r"\A\s*```(?:json)?\s*(.*?)\s*```\s*\Z", re.DOTALL)
LINE_END = re.compile(r"\r\n|\r|\n")
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
PROMPT = (
"Here is an email thread, one numbered line per line.\n"
"Return the numbers of the lines written by the person who sent this "
"message, and not the lines of the messages quoted under it.\n"
'Answer with JSON only: {{"lines": [numbers]}}.\n\n'
"Thread:\n{thread}"
)
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
)
return response.choices[0].message.content
class ReadingUnavailable(Exception):
"""The provider could not be reached, or answered something unusable."""
def read_reply(message: str, client=None, *, attempts: int = 3) -> dict:
"""
The lines of the thread a model attributes to the last sender.
`client` is injected so this function can be tested without a network call.
In production it defaults to a real provider client.
"""
client = client or ProviderClient()
thread = message[:MAX_CHARACTERS]
lines = LINE_END.split(thread)
answer = _ask(client, lines, attempts)
kept, dropped = [], []
for number in answer.get("lines", []) if isinstance(answer, dict) else []:
if isinstance(number, int) and 1 <= number <= len(lines) \
and not lines[number - 1].lstrip().startswith(">"):
kept.append(number)
else:
# Not a line of what was sent, or a line of the quoted thread.
dropped.append(number)
kept = sorted(set(kept))
reply = "\n".join(lines[number - 1] for number in kept).strip()
return {"source": "model", "reply": reply, "lines": kept, "dropped": dropped,
"characters_sent": len(thread)}
def _ask(client, lines, attempts: int) -> dict:
numbered = "\n".join(f"{number}: {line}" for number, line in enumerate(lines, 1))
prompt = PROMPT.format(thread=numbered)
last_error: Exception | None = None
for _ in range(attempts):
try:
answer = client.complete(prompt=prompt, temperature=0)
if not isinstance(answer, str): # a refusal comes back as no content
last_error = ValueError("the model answered no text")
continue
fenced = FENCE.match(answer)
return json.loads(fenced.group(1) if fenced else answer)
except Exception as error: # noqa: BLE001 - any provider failure is retried
last_error = error
raise ReadingUnavailable(str(last_error))JavaScript
/**
* Ask a model which lines of a thread the last sender wrote.
*
* Rung N3. It exists for the one case rung N0 cannot settle: a reply that is
* not above the quote but inside it, line by line, under the sentences it
* answers. No marker separates those lines from the ones around them — the
* client wrote none — and a model reads them the way a human does.
*
* The guard is what makes this rung defensible, and it is unusually strong
* here because the answer is not text but line numbers. The model is given the
* thread numbered, and returns the numbers of the lines it believes the last
* sender wrote; anything that is not a line of the message that was sent is
* dropped, and `dropped` says which. So the reply returned is made of lines of
* the thread, in their order, and never of a sentence the model found likelier.
*
* Two operating conditions. The thread is cut to a budget from the end,
* because the reply being looked for is normally near the top; a thread longer
* than the budget whose reply is at the very bottom is cut away before it is
* read. And the cost is a call per message: this rung is for the threads rung
* N0 has flagged, not for a mailbox.
*/
// The budget, in characters. A thread that does not fit is cut, not refused.
export const MAX_CHARACTERS = 6000;
// A single code fence around the whole answer is a common shape, and refusing
// it would buy another call for nothing.
const FENCE = /^\s*```(?:json)?\s*([\s\S]*?)\s*```\s*$/;
const LINE_END = /\r\n|\r|\n/;
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
const PROMPT = [
'Here is an email thread, one numbered line per line.',
'Return the numbers of the lines written by the person who sent this message,'
+ ' and not the lines of the messages quoted under it.',
'Answer with JSON only: {"lines": [numbers]}.',
'',
'Thread:',
].join('\n');
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, temperature }) {
const response = await sdk.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
temperature,
});
return response.choices[0].message.content;
},
};
}
export class ReadingUnavailable extends Error {}
/**
* The lines of the thread a model attributes to the last sender.
*
* @param {string} message
* @param {{client?: {complete: Function}, attempts?: number}} [options]
*/
export async function readReply(message, { client, attempts = 3 } = {}) {
client ??= await providerClient();
const thread = message.slice(0, MAX_CHARACTERS);
const lines = thread.split(LINE_END);
const answer = await ask(client, lines, attempts);
const kept = [];
const dropped = [];
const asked = answer && typeof answer === 'object' ? answer.lines ?? [] : [];
for (const number of Array.isArray(asked) ? asked : []) {
if (Number.isInteger(number) && number >= 1 && number <= lines.length
&& !lines[number - 1].trimStart().startsWith('>')) {
kept.push(number);
} else {
// Not a line of what was sent, or a line of the quoted thread.
dropped.push(number);
}
}
const numbers = [...new Set(kept)].sort((a, b) => a - b);
const reply = numbers.map((number) => lines[number - 1]).join('\n').trim();
return { source: 'model', reply, lines: numbers, dropped, characters_sent: thread.length };
}
async function ask(client, lines, attempts) {
const numbered = lines.map((line, index) => `${index + 1}: ${line}`).join('\n');
const prompt = `${PROMPT}\n${numbered}`;
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
const answer = await client.complete({ prompt, temperature: 0 });
if (typeof answer !== 'string') {
lastError = new Error('the model answered no text'); // a refusal has no content
continue;
}
const fenced = FENCE.exec(answer);
return JSON.parse(fenced ? fenced[1] : answer);
} catch (error) {
lastError = error;
}
}
throw new ReadingUnavailable(String(lastError));
}What this means for you
- Your data goes to a third party
- The result varies from one call to the next
- Hard to test
- Depends on an outside provider
- Moderate footprint
What this adds to your regulatory scope
- Transfer of correspondence to a processor, with the contractual arrangements that implies, and for people who did not choose it
- Where the processing happens is to be checked with the provider
Breaking point
The guard refuses lines prefixed with “>”, not the lines of a message copied without a prefix: on an Outlook thread, the lines of the old message pass the guard and enter the reply. The witness is in the same test: on a thread quoted line by line, the quoted line is refused and returned in `dropped`.
When to move up a level
There is no level above this one.
Further reading
- email-reply-parser — la bibliothèque JavaScript, et ses expressions par langue
- email_reply_parser — la bibliothèque Python, portée de celle de GitHub
- talon — l’outil de Mailgun, et son classifieur de signatures
- RFC 3676 — la citation par « > », telle qu’elle est spécifiée
Where the figures come from
- email-reply-parser sur npm (consulté) : version 2.3.9, publiée le 16 juillet 2026, licence MIT, aucune dépendance d’exécution — `re2` est une dépendance de pair facultative, employée pour rendre ses expressions régulières sûres
- email-reply-parser, son fichier d’expressions (consulté) : la liste des formules de politesse françaises contient « Bien . vous,?!? », d’où la signature retirée en français et gardée en anglais
- email_reply_parser sur PyPI (consulté) : version 0.5.12, publiée le 7 octobre 2020, licence MIT ; ses marqueurs de citation sont ceux de la bibliothèque anglaise d’origine
- talon sur PyPI (consulté) : version 1.4.4, publiée le 24 août 2017, licence Apache 2.0 ; c’est le seul outil du domaine dont une partie soit un modèle appris
- RFC 3676, section 4.5 (consultée) : la profondeur de citation est le nombre de « > » en tête de ligne, ce qui fait du préfixe une convention spécifiée et pas un usage
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.