Documentation Index Fetch the complete documentation index at: https://docs.lettr.com/llms.txt
Use this file to discover all available pages before exploring further.
Reply tracking allows you to associate incoming replies with the original emails you sent, enabling threaded conversations, support ticket systems, and automated workflows.
How Reply Tracking Works
Send with Reply-To
Send an email with a unique reply-to address that encodes the conversation ID.
Recipient Replies
When the recipient hits reply, their email goes to your inbound domain.
Match Reply
Extract the conversation ID from the recipient address and match to the original email.
Thread Together
Add the reply to the conversation thread.
Variable Reply-To Addresses
The most reliable way to track replies is using variable (plus) addressing in the reply-to field:
// Send an email with a trackable reply-to address
await lettr . emails . send ({
from: 'support@example.com' ,
to: [ 'customer@example.com' ],
subject: 'Re: Your support request' ,
html: '<p>Thanks for contacting us...</p>' ,
reply_to: `reply+ticket_ ${ ticketId } @mail.example.com` ,
reply_to_name: 'Support Team'
});
When the customer replies, you receive a relay.relay_delivery webhook event:
[
{
"msys" : {
"relay_message" : {
"rcpt_to" : "reply+ticket_TKT-1234@mail.example.com" ,
"msg_from" : "customer@example.com" ,
"friendly_from" : "Customer Name <customer@example.com>" ,
"content" : {
"subject" : "Re: Your support request" ,
"text" : "Thanks, that fixed my issue!" ,
"headers" : [
{ "In-Reply-To" : "<original-message-id@example.com>" },
{ "References" : "<original-message-id@example.com>" }
]
}
}
}
}
]
Processing Replies
Extract the conversation ID and match to the original:
// Helper to find a header value from the headers array
function getHeader ( headers , name ) {
const header = headers . find ( h => h [ name ] !== undefined );
return header ? header [ name ] : null ;
}
app . post ( '/webhooks/inbound' , express . json (), async ( req , res ) => {
for ( const event of req . body ) {
const relay = event . msys ?. relay_message ;
if ( ! relay ) continue ;
const recipient = relay . rcpt_to ;
const { msg_from , content } = relay ;
// Extract ticket ID from reply address
const match = recipient . match ( / ^ reply \+ ticket_ ( [ ^ @ ] + ) @/ );
if ( match ) {
const ticketId = match [ 1 ];
// Add reply to the ticket
await addReplyToTicket ( ticketId , {
from: msg_from ,
subject: content . subject ,
body: content . text || stripHtml ( content . html ),
messageId: getHeader ( content . headers , 'Message-ID' ),
inReplyTo: getHeader ( content . headers , 'In-Reply-To' ),
receivedAt: new Date ()
});
// Optionally notify the support agent
await notifyAgent ( ticketId , { from: msg_from , subject: content . subject });
}
}
res . sendStatus ( 200 );
});
Standard email headers help track conversation threads:
Header Purpose Message-IDUnique identifier for each email In-Reply-ToMessage-ID of the email being replied to ReferencesChain of Message-IDs in the thread
app . post ( '/webhooks/inbound' , express . json (), async ( req , res ) => {
for ( const event of req . body ) {
const relay = event . msys ?. relay_message ;
if ( ! relay ) continue ;
const { msg_from , content } = relay ;
// Try to find the original message using headers
const inReplyTo = getHeader ( content . headers , 'In-Reply-To' );
const references = getHeader ( content . headers , 'References' );
let thread = null ;
if ( inReplyTo ) {
// Find by direct reply
thread = await findThreadByMessageId ( inReplyTo );
}
if ( ! thread && references ) {
// Find by any message in the reference chain
const refIds = references . split ( / \s + / );
for ( const refId of refIds ) {
thread = await findThreadByMessageId ( refId );
if ( thread ) break ;
}
}
if ( thread ) {
await addMessageToThread ( thread . id , { from: msg_from , subject: content . subject , body: content . text });
} else {
// Start a new thread
await createNewThread ({ from: msg_from , subject: content . subject , body: content . text });
}
}
res . sendStatus ( 200 );
});
Building a Conversation Thread
Store and display threaded conversations:
// Data model for conversation threads
class ConversationThread {
constructor ( id ) {
this . id = id ;
this . messages = [];
this . participants = new Set ();
this . subject = '' ;
this . createdAt = null ;
this . updatedAt = null ;
}
addMessage ( message ) {
this . messages . push ({
id: message . messageId || generateId (),
from: message . from ,
body: message . body ,
direction: message . direction , // 'inbound' or 'outbound'
timestamp: message . timestamp || new Date ()
});
this . participants . add ( message . from );
this . updatedAt = new Date ();
if ( ! this . createdAt ) {
this . createdAt = this . updatedAt ;
}
}
getChronological () {
return [ ... this . messages ]. sort (( a , b ) =>
new Date ( a . timestamp ) - new Date ( b . timestamp )
);
}
}
// Usage
const thread = await getOrCreateThread ( ticketId );
// When receiving a reply
thread . addMessage ({
from: relay . msg_from ,
body: relay . content . text ,
direction: 'inbound' ,
messageId: getHeader ( relay . content . headers , 'Message-ID' )
});
// When sending a response
thread . addMessage ({
from: 'support@example.com' ,
body: responseText ,
direction: 'outbound' ,
messageId: sentEmail . messageId
});
await saveThread ( thread );
Auto-Reply Detection
Detect and handle auto-replies (out-of-office, delivery receipts):
function isAutoReply ( relay ) {
const { content } = relay ;
const subject = ( content . subject || '' ). toLowerCase ();
// Check standard auto-reply headers
const autoSubmitted = getHeader ( content . headers , 'Auto-Submitted' );
if ( autoSubmitted && autoSubmitted !== 'no' ) {
return true ;
}
if ( getHeader ( content . headers , 'X-Auto-Response-Suppress' )) {
return true ;
}
if ( getHeader ( content . headers , 'Precedence' ) === 'auto_reply' ) {
return true ;
}
// Check common auto-reply subject patterns
const autoReplyPatterns = [
/ ^ out of office/ i ,
/ ^ automatic reply/ i ,
/ ^ auto:/ i ,
/ ^ autoreply/ i ,
/ ^ vacation/ i ,
/ ^ away from/ i ,
/delivery ( status ) ? notification/ i ,
/ ^ undeliverable:/ i ,
/ ^ returned mail/ i
];
for ( const pattern of autoReplyPatterns ) {
if ( pattern . test ( subject )) {
return true ;
}
}
return false ;
}
app . post ( '/webhooks/inbound' , express . json (), async ( req , res ) => {
for ( const event of req . body ) {
const relay = event . msys ?. relay_message ;
if ( ! relay ) continue ;
if ( isAutoReply ( relay )) {
// Log but don't create a ticket or notify
await logAutoReply ( relay );
continue ;
}
// Process as a normal reply
await processReply ( relay );
}
res . sendStatus ( 200 );
});
Reply Matching Strategies
Strategy 1: Variable Address (Recommended)
// Encode data in the reply-to address
const replyTo = `reply+ ${ encodeData ( conversationId ) } @mail.example.com` ;
function encodeData ( id ) {
return Buffer . from ( id ). toString ( 'base64url' );
}
function decodeData ( encoded ) {
return Buffer . from ( encoded , 'base64url' ). toString ();
}
// Add custom headers to outgoing emails
await lettr . emails . send ({
from: 'support@example.com' ,
to: [ 'customer@example.com' ],
subject: 'Your request' ,
html: content ,
headers: {
'X-Conversation-ID' : conversationId ,
'X-Ticket-ID' : ticketId
}
});
// Note: Custom headers may not always be preserved in replies
Strategy 3: Subject Line Tokens
// Add a token to the subject
const subject = `[Ticket # ${ ticketId } ] Your support request` ;
// Parse the token from replies
function extractTicketFromSubject ( subject ) {
const match = subject . match ( / \[ Ticket # ( [ ^ \] ] + ) \] / );
return match ? match [ 1 ] : null ;
}
Strategy 4: Message-ID Tracking
// Store the Message-ID when sending
const result = await lettr . emails . send ( emailData );
await storeMessageId ( conversationId , result . messageId );
// Match replies using In-Reply-To header
const originalConversation = await findByMessageId (
getHeader ( relay . content . headers , 'In-Reply-To' )
);
Handling Reply Chains
When multiple people are involved in a thread:
app . post ( '/webhooks/inbound' , express . json (), async ( req , res ) => {
for ( const event of req . body ) {
const relay = event . msys ?. relay_message ;
if ( ! relay ) continue ;
const { rcpt_to , msg_from , content } = relay ;
// Find the conversation
const conversationId = extractConversationId ( rcpt_to );
const conversation = await getConversation ( conversationId );
if ( ! conversation ) continue ;
// Add the new message
await addMessage ( conversationId , {
from: msg_from ,
body: content . text ,
inReplyTo: getHeader ( content . headers , 'In-Reply-To' )
});
// Track all participants from To and CC headers
const toAddresses = content . to || [];
const ccHeader = getHeader ( content . headers , 'CC' );
const allRecipients = [ ... toAddresses , ... ( ccHeader ? []. concat ( ccHeader ) : [])];
for ( const recipient of allRecipients ) {
if ( ! recipient . includes ( '@mail.example.com' )) {
await addParticipant ( conversationId , recipient );
}
}
// Notify internal team members
await notifyTeam ( conversation , { from: msg_from , subject: content . subject , body: content . text });
}
res . sendStatus ( 200 );
});
Complete Support Ticket Example
import express from 'express' ;
import { v4 as uuid } from 'uuid' ;
const app = express ();
// Send initial support response
async function sendTicketResponse ( ticket , responseText ) {
const result = await lettr . emails . send ({
from: 'support@example.com' ,
from_name: 'Support Team' ,
to: [ ticket . customerEmail ],
subject: `Re: ${ ticket . subject } ` ,
html: `
<p> ${ responseText } </p>
<hr>
<p style="color: #666; font-size: 12px;">
Ticket # ${ ticket . id } - Please reply above this line
</p>
` ,
reply_to: `reply+ ${ ticket . id } @mail.example.com` ,
metadata: {
ticketId: ticket . id ,
type: 'support_response'
}
});
// Store the message
await addTicketMessage ( ticket . id , {
from: 'support@example.com' ,
body: responseText ,
direction: 'outbound' ,
messageId: result . messageId
});
return result ;
}
// Handle incoming replies
app . post ( '/webhooks/inbound' , express . json (), async ( req , res ) => {
for ( const event of req . body ) {
const relay = event . msys ?. relay_message ;
if ( ! relay ) continue ;
const { rcpt_to , msg_from , content } = relay ;
// Skip auto-replies
if ( isAutoReply ( relay )) {
continue ;
}
// Extract ticket ID
const match = rcpt_to . match ( / ^ reply \+ ( [ ^ @ ] + ) @/ );
if ( ! match ) {
// Not a reply to a ticket - create new ticket
const ticket = await createTicket ({
customerEmail: msg_from ,
subject: content . subject ,
body: content . text || stripHtml ( content . html )
});
// Send acknowledgment
await sendTicketResponse ( ticket ,
'Thank you for contacting us. We \' ve received your request and will respond shortly.'
);
continue ;
}
const ticketId = match [ 1 ];
const ticket = await getTicket ( ticketId );
if ( ! ticket ) {
console . warn ( `Ticket not found: ${ ticketId } ` );
continue ;
}
// Add customer reply to ticket
await addTicketMessage ( ticketId , {
from: msg_from ,
body: content . text || stripHtml ( content . html ),
direction: 'inbound' ,
messageId: getHeader ( content . headers , 'Message-ID' ),
inReplyTo: getHeader ( content . headers , 'In-Reply-To' )
});
// Update ticket status
await updateTicket ( ticketId , {
status: 'awaiting_response' ,
lastCustomerReply: new Date ()
});
// Notify assigned agent
if ( ticket . assignedTo ) {
await notifyAgent ( ticket . assignedTo , {
ticketId ,
customerEmail: msg_from ,
preview: ( content . text || '' ). substring ( 0 , 100 )
});
}
}
res . sendStatus ( 200 );
});
Webhooks Webhook payload structure
Routing Route emails to handlers
Metadata Track emails with metadata