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

snippets/extract-the-latest-reply-from-an-email-thread/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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

snippets/extract-the-latest-reply-from-an-email-thread/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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

interactive

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

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

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

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

  1. RecommendedN0The marker the mail client wrote, French ones includedFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. 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

snippets/extract-the-latest-reply-from-an-email-thread/n3.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
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

snippets/extract-the-latest-reply-from-an-email-thread/n3.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * 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

Where the figures come from

Metadata