How Reply Tracking Works
Variable Reply-To Addresses
The most reliable way to track replies is using variable (plus) addressing in the reply-to field:Copy
// 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'
});
relay.relay_delivery webhook event:
Copy
[
{
"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:Copy
// 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);
});
Using Email Headers for Threading
Standard email headers help track conversation threads:| Header | Purpose |
|---|---|
Message-ID | Unique identifier for each email |
In-Reply-To | Message-ID of the email being replied to |
References | Chain of Message-IDs in the thread |
Copy
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:Copy
// 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):Copy
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)
Copy
// 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();
}
Strategy 2: Custom Headers
Copy
// 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
Copy
// 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
Copy
// 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:Copy
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
Copy
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);
});