Our support tickets notify by email and set a reply address, which means a user can answer from their inbox and never open the app again. That is the behaviour you want. It also means the thing arriving at our webhook is not a message, it is a message with a complete copy of our own email stapled underneath it, formatted by whichever client the user happens to run.
If you append that whole body to the ticket, the thread becomes unreadable after two exchanges. So somewhere there has to be a function whose entire job is to find the line where the user's new text stops, and it is pure string handling with no good universal answer.
Four shapes of quote, in one ordered scan
There is no standard for this. There is a convention per client, and the scan takes the first line that matches any of them:
function quoteStart(lines: string[]): number {
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('>')) return i;
if (SEPARATOR.some((re) => re.test(line))) return i;
if (OUTLOOK_FROM.test(line) && OUTLOOK_SENT.test(lines[i + 1]?.trim() ?? '')) return i;
if (/[::]$/.test(line)) {
const start = attributionStart(lines, i);
if (start !== -1) return start;
}
}
return -1;
}
-
>prefixes, which is the easy case and the one every example in a blog post uses. - A separator line:
--- Original Message ---, or a run of ten or more underscores, which is what some Outlook clients draw above the quoted message. - An Outlook header block, detected as a pair rather than a single line: a
From:line is only a quote boundary when the line after it isSent:orDate:. On its own, "From: my manager" is something a user writes. - An attribution line, the "On Tuesday, X wrote:" that precedes the quote.
The colon check in front of the attribution test is not decoration. Testing every line against the attribution patterns is the expensive path, and the one shape every attribution shares is that it ends in a colon, so the cheap character test gates the regexes.
The attribution is where this gets interesting
For English clients the pattern is roughly what you would guess, and it has to contain a digit, because every client puts a date in it:
/^On\s.*\d.*\swrote:$/i
Then there is everybody else. "wrote" is English, and our users are not. A German Gmail writes:
Am Di., 13. Okt. 2026 um 10:36 Uhr schrieb Support <support@cogniprep.app>:
You cannot enumerate the verb in every locale, and you should not try. What every client does when quoting us, in every language, is print our own address in angle brackets. That is the anchor:
/^(?=.*\d)\S.*<\s*[^<>\s]+@cogniprep\.app>.*[::]$/i
A digit somewhere via a lookahead, our domain inside angle brackets, a colon or a fullwidth colon at the end. The fallback identifies the quote by recognising ourselves in it, which is a nice property: it works for a client we have never seen, as long as that client is quoting an email we sent.
And it will not fire on a user's own prose, which matters more than it sounds for a support desk attached to a practice-test product. People really do write:
On my results page I see this:
Score 0
That line starts with "On" and ends with a colon. It has no digit and no address, so neither pattern matches, and a test pins exactly that string.
Gmail wraps the attribution, so one line is not enough
In the plain-text part of a Gmail reply, a long attribution is hard wrapped, and the wrap lands wherever it lands:
On Tue, Oct 13, 2026 at 10:36 AM Support <
support@cogniprep.app> wrote:
So the matcher joins up to three lines ending at the candidate and tries shortest first. That immediately risks eating the user's last line of text, which is why a multi-line attribution must sit in its own paragraph:
if (n > 1 && start > 0 && lines[start - 1].trim() !== '') continue;
One line can follow text directly. Two or three cannot, because a client that wraps an attribution also leaves a blank line above it. A test covers the case that rule exists for: two lines of reply text sitting immediately on top of a single-line attribution, both kept.
Empty string means "do not try"
The function returns everything above the cut, trimmed, and when nothing is above the cut it returns an empty string rather than a best effort. That is the bottom-posted reply, where the user has typed under the quote, or inline next to it, and no amount of cleverness recovers their words from our words.
The caller treats that as "I could not read this", and falls back to forwarding the whole email to the support inbox, where a human sorts it out. The same happens when what survives is shorter than two characters. Threading is an optimisation on top of forwarding; forwarding always works.
The threading check itself is deliberately boring: the reply has to carry the reference from the subject and come from the address on that ticket. A subject line is user-supplied text, so it can select a ticket but it cannot authorise one.
A detail I liked: when a reply does thread onto a ticket, its attachments are re-sent to the admin notification without their content IDs. Our notification HTML references none of them, and an inline image whose content ID matches nothing in the HTML is an image that silently does not appear. Dropping the IDs turns it into a plain attachment instead.
See it
The whole path starts at a page with no sign-in:
- cogniprep.app/help is the support form. It asks how urgent the issue is before it asks anything else, which is the only field on it that exists for us rather than for the user.
- Send one, then reply to the email it sends you from your own mail client, with your reply above the quote and your signature attached. What lands on the ticket is the text above the quote, your signature included: "Sent from my iPhone" is above the cut, and the test for the iOS Mail case asserts it survives.
- If you reply underneath the quote instead, nothing threads and the whole email is forwarded to a human. That is the designed outcome, not a failure.
For the product the tickets are about, the practice tests are at cogniprep.app/tests.












