Skip to main content
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

1

Send with Reply-To

Send an email with a unique reply-to address that encodes the conversation ID.
2

Recipient Replies

When the recipient hits reply, their email goes to your inbound domain.
3

Match Reply

Extract the conversation ID from the recipient address and match to the original email.
4

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);
});

Using Email Headers for Threading

Standard email headers help track conversation threads:
HeaderPurpose
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

// 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

// 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);
});