Skip to main content
Route incoming emails to different handlers based on the recipient address, subject line, content, or custom rules. This allows you to build sophisticated email processing workflows.

Routing by Recipient

The most common routing pattern is based on the recipient email address:
app.post('/webhooks/inbound', async (req, res) => {
  const { to, from, subject, text, html } = req.body.data;
  const recipient = to[0].toLowerCase();

  // Route based on recipient address
  if (recipient.startsWith('support@')) {
    await createSupportTicket({ from, subject, body: text || html });
  } else if (recipient.startsWith('sales@')) {
    await notifySalesTeam({ from, subject, body: text || html });
  } else if (recipient.startsWith('feedback@')) {
    await storeFeedback({ from, subject, body: text || html });
  } else if (recipient.startsWith('billing@')) {
    await routeToBilling({ from, subject, body: text || html });
  } else {
    await handleUnknownRecipient({ recipient, from, subject });
  }

  res.sendStatus(200);
});

Variable Address Routing

Use variable addresses (plus addressing) to encode data in the email address:
app.post('/webhooks/inbound', async (req, res) => {
  const { to, from, subject, text } = req.body.data;
  const recipient = to[0];

  // Parse variable address: reply+{ticketId}@mail.example.com
  const match = recipient.match(/^reply\+([^@]+)@/);

  if (match) {
    const ticketId = match[1];
    await addReplyToTicket(ticketId, { from, subject, body: text });
  }

  res.sendStatus(200);
});

Common Variable Address Patterns

PatternUse CaseExample
reply+{ticketId}@Support ticket repliesreply+TKT-1234@mail.example.com
unsubscribe+{userId}@Email unsubscriptionsunsubscribe+usr_abc@mail.example.com
confirm+{token}@Email confirmationsconfirm+xyz789@mail.example.com
order+{orderId}@Order communicationsorder+ORD-5678@mail.example.com
notify+{channelId}@Channel notificationsnotify+ch_slack@mail.example.com

Routing by Subject

Route emails based on subject line keywords or patterns:
app.post('/webhooks/inbound', async (req, res) => {
  const { from, subject, text } = req.body.data;
  const subjectLower = subject.toLowerCase();

  if (subjectLower.includes('urgent') || subjectLower.includes('emergency')) {
    await createHighPriorityTicket({ from, subject, body: text });
  } else if (subjectLower.includes('unsubscribe')) {
    await processUnsubscribeRequest({ from });
  } else if (subjectLower.match(/re:\s*ticket #\d+/i)) {
    const ticketNum = subjectLower.match(/ticket #(\d+)/i)[1];
    await addReplyToTicket(ticketNum, { from, body: text });
  } else {
    await createStandardTicket({ from, subject, body: text });
  }

  res.sendStatus(200);
});

Routing by Sender Domain

Route based on the sender’s domain for B2B workflows:
app.post('/webhooks/inbound', async (req, res) => {
  const { from, subject, text } = req.body.data;
  const senderDomain = from.split('@')[1].toLowerCase();

  // Check if sender is from a known customer domain
  const customer = await findCustomerByDomain(senderDomain);

  if (customer) {
    await routeToAccountManager(customer.accountManagerId, {
      from,
      subject,
      body: text,
      customerId: customer.id
    });
  } else {
    await routeToGeneralInbox({ from, subject, body: text });
  }

  res.sendStatus(200);
});

Content-Based Routing

Route based on email content analysis:
app.post('/webhooks/inbound', async (req, res) => {
  const { from, subject, text, html } = req.body.data;
  const content = text || stripHtml(html);
  const contentLower = content.toLowerCase();

  // Detect intent from content
  if (contentLower.includes('cancel') && contentLower.includes('subscription')) {
    await routeToCancellations({ from, subject, body: content });
  } else if (contentLower.includes('refund') || contentLower.includes('money back')) {
    await routeToRefunds({ from, subject, body: content });
  } else if (contentLower.includes('bug') || contentLower.includes('error')) {
    await routeToTechnicalSupport({ from, subject, body: content });
  } else {
    await routeToGeneralSupport({ from, subject, body: content });
  }

  res.sendStatus(200);
});

Multi-Criteria Routing

Combine multiple routing criteria for complex workflows:
app.post('/webhooks/inbound', async (req, res) => {
  const { to, from, subject, text, html, spamScore } = req.body.data;
  const recipient = to[0].toLowerCase();
  const content = text || html;

  // Define routing rules
  const rules = [
    {
      name: 'spam',
      condition: () => spamScore > 5,
      action: async () => await quarantineAsSpam({ from, subject })
    },
    {
      name: 'vip',
      condition: () => isVipCustomer(from),
      action: async () => await routeToVipSupport({ from, subject, body: content })
    },
    {
      name: 'ticket-reply',
      condition: () => recipient.match(/^reply\+/),
      action: async () => {
        const ticketId = recipient.match(/^reply\+([^@]+)@/)[1];
        await addReplyToTicket(ticketId, { from, body: content });
      }
    },
    {
      name: 'sales',
      condition: () => recipient.startsWith('sales@'),
      action: async () => await notifySalesTeam({ from, subject, body: content })
    },
    {
      name: 'default',
      condition: () => true,
      action: async () => await createSupportTicket({ from, subject, body: content })
    }
  ];

  // Execute first matching rule
  for (const rule of rules) {
    if (rule.condition()) {
      await rule.action();
      break;
    }
  }

  res.sendStatus(200);
});

Routing with Priority Queues

For high-volume applications, use priority queues:
import Queue from 'bull';

const highPriorityQueue = new Queue('email-high', redisUrl);
const normalPriorityQueue = new Queue('email-normal', redisUrl);
const lowPriorityQueue = new Queue('email-low', redisUrl);

app.post('/webhooks/inbound', async (req, res) => {
  const { from, subject, text, spamScore } = req.body.data;

  // Determine priority
  let queue;
  if (isVipCustomer(from) || subject.toLowerCase().includes('urgent')) {
    queue = highPriorityQueue;
  } else if (spamScore > 3) {
    queue = lowPriorityQueue;
  } else {
    queue = normalPriorityQueue;
  }

  // Add to appropriate queue
  await queue.add({
    from,
    subject,
    body: text,
    receivedAt: new Date().toISOString()
  });

  res.sendStatus(200);
});

// Process queues with different concurrency
highPriorityQueue.process(10, processEmail);
normalPriorityQueue.process(5, processEmail);
lowPriorityQueue.process(2, processEmail);

Catch-All Addresses

Handle any address on your inbound domain:
app.post('/webhooks/inbound', async (req, res) => {
  const { to, from, subject, text } = req.body.data;
  const recipient = to[0];
  const localPart = recipient.split('@')[0].toLowerCase();

  // Known addresses
  const knownAddresses = ['support', 'sales', 'feedback', 'billing'];

  if (knownAddresses.includes(localPart)) {
    await routeToKnownHandler(localPart, { from, subject, body: text });
  } else {
    // Catch-all: log and potentially notify admin
    await logUnknownAddress(recipient, { from, subject });
    await notifyAdmin(`Unknown address: ${recipient}`);
  }

  res.sendStatus(200);
});
Catch-all addresses can receive a lot of spam. Consider implementing spam filtering before processing catch-all emails.

Forwarding Emails

Forward received emails to another address:
app.post('/webhooks/inbound', async (req, res) => {
  const { from, fromName, subject, text, html, attachments } = req.body.data;

  // Forward to internal team
  await lettr.emails.send({
    from: 'forwarded@yourdomain.com',
    to: ['team@yourdomain.com'],
    subject: `[Forwarded] ${subject}`,
    html: `
      <p><strong>Originally from:</strong> ${fromName} &lt;${from}&gt;</p>
      <hr>
      ${html || `<pre>${text}</pre>`}
    `,
    attachments: attachments.map(att => ({
      filename: att.filename,
      url: att.url
    }))
  });

  res.sendStatus(200);
});

Error Handling

Implement proper error handling in your routing logic:
app.post('/webhooks/inbound', async (req, res) => {
  try {
    const { to, from, subject, text } = req.body.data;
    const recipient = to[0];

    await routeEmail(recipient, { from, subject, body: text });

    res.sendStatus(200);
  } catch (error) {
    console.error('Routing error:', error);

    // Store failed email for manual review
    await storeFailedEmail(req.body.data, error.message);

    // Return 200 to prevent retries if it's a processing error
    // Return 500 to trigger retry if it's a temporary error
    if (error.isTemporary) {
      res.sendStatus(500);
    } else {
      res.sendStatus(200);
    }
  }
});