Never let the LLM write the subject line | ddz.dev LLCSkip to content<br>6th August, 2026·<br>Posted in Email, Llm, Aws<br>By Dušan Dželebdžić
Photo by engin akyurt on Unsplash<br>The Fix (TL;DR)<br>1.Gmail threads on References and a matching subject. You need both.<br>2.Derive the reply subject in code: Re: , never from the model.<br>3.Carry the inbound Message-ID all the way to the send and set In-Reply-To / References.<br>✓The LLM writes the body. The plumbing writes the envelope.<br>I'm building biro.works , a website studio where AI agents handle the first line of everything: a lead emails us, an agent drafts the reply, a human approves it, the reply goes out. The pipeline worked. Leads got answers within minutes, the tone was right, the pricing came straight from the catalog.<br>Then I looked at a test conversation from the client's side, in Gmail. The client had sent "Need a website for my bakery." Our reply arrived as "Your new bakery website with biro.works". A brand new conversation, sitting in the inbox with no connection to the message it answered. Reply to that, get another new conversation. Three emails in, the thread was confetti.<br>Nothing was broken in any log. The emails sent fine, delivered fine, read fine. They just refused to be a conversation.<br>Two bugs wearing one symptom<br>The stack: SES receives mail for our reply domain, dumps the raw message to S3, a Lambda parses it and POSTs a normalized payload to the ingestion service, which kicks off a durable workflow that drafts and (after approval) sends the reply. The inbound payload had everything: sender, subject, body, and the RFC822 Message-ID.<br>Bug one: the workflow never received the subject or the Message-ID. The webhook dutifully stored both in the database, then started the workflow with just name, email, and body. So the reply went out with no In-Reply-To and no References header at all. Gmail had literally nothing to attach it to.<br>Bug two is the one worth a blog post. The reply draft comes from Claude as a JSON object, and the schema I gave it looked like this:<br>{ "replySubject": string, "replyBody": string, ... }See the problem? I asked the model for a subject line. The model, being a model, wrote one. A good one, even. Friendly, specific, on-brand. And since the prompt only contained the thread bodies, it had never seen the original subject it was supposed to preserve. It wasn't hallucinating. It was answering the exact question I put in the schema.<br>Gmail wants both<br>Here's the part that makes this worth writing down. I fixed the headers first and assumed I was done, because everything you skim about email threading talks about References and In-Reply-To. That's the RFC 5322 story, and clients like Thunderbird or Mutt live by it.<br>Gmail doesn't. Gmail threads on the reference headers and the subject line. Same References, different subject: new conversation. Google says so themselves, in the fine print of their threading documentation: correct References and In-Reply-To headers, and then, as its own separate requirement, "The Subject headers must match."<br>So a reply with perfect headers and a creative subject still splits the thread for every Gmail and Google Workspace user, which for a small-business audience is roughly everyone. The model wasn't allowed to be creative here. Nobody had told it that, because nobody had told me that.<br>The fix<br>The subject of a reply is not content. It's protocol. So it moved out of the model's hands and into a ten-line function:<br>/** Strip any stack of reply/forward prefixes ("Re:", "RE:", "Fwd:", ...). */<br>function stripReplyPrefixes(subject: string): string {<br>return subject.replace(/^(\s*(re|fwd?|aw|sv)\s*(\[\d+\])?\s*:\s*)+/i, "").trim();
export function threadReplySubject(original: string | null | undefined, fallback: string): string {<br>const stripped = original ? stripReplyPrefixes(original) : "";<br>return stripped ? `Re: ${stripped}` : fallback;<br>}The prefix stripping matters more than it looks. Clients stack prefixes (Re: Re: Re:), localize them (AW: from German Outlooks, SV: from Scandinavian ones), or number them (Re[2]:). Normalize to a single Re: and Gmail is happy.<br>The model still returns replySubject, but it's demoted to a fallback: it's only used when a lead arrives through the web form, where there's no email thread to preserve and someone has to write a subject for the first message. For anything that arrived as an email, the subject is derived, end of discussion.<br>And the Message-ID now rides through the whole pipeline into the send. With SES v2 that's the Headers field on the simple content type (supported since well before my @aws-sdk/client-sesv2 3.1057):<br>Content: {<br>Simple: {<br>Subject: { Data: subject },<br>Body: { Text: { Data: text }, Html: { Data: html } },<br>Headers: [<br>{ Name: "In-Reply-To", Value: inboundMessageId },<br>{ Name: "References", Value: inboundMessageId },<br>],<br>},<br>},One more detail that would have bitten later: the approval gate. A human reviews every outgoing reply before it sends, and the review screen was...