Better Helpdesk
Email

Send email

Connect the mail sender your app already uses, so customers get replies and receipts and agents hear about new conversations.

Better Helpdesk sends no mail of its own. It hands every outgoing message to an email.send function you write, and that function decides what the message looks like and which service delivers it. Leave email out and nothing is sent; everything else works as before.

Write the adapter

The adapter receives a HelpdeskEmail, one of five kinds, and turns it into a call to your mailer. This one sends plain text over SMTP with Nodemailer:

lib/helpdesk-email.ts
import type { HelpdeskEmail } from 'better-helpdesk';
import nodemailer, { type Transporter } from 'nodemailer';

let transport: Transporter | undefined;
const from = 'Acme Support <support@example.com>';

function render(message: HelpdeskEmail) {
  switch (message.kind) {
    case 'customer-reply':
      return {
        subject: `Re: ${message.subject ?? message.reference}`,
        text: [
          message.body,
          message.agentName,
          message.ratingLinks &&
            `Solved? ${message.ratingLinks.good}\nNot solved? ${message.ratingLinks.bad}`,
        ]
          .filter(Boolean)
          .join('\n\n'),
        replyTo: message.replyTo,
        inReplyTo: message.inReplyTo,
        references: message.inReplyTo,
      };
    case 'customer-receipt':
      return {
        subject: `We got your message (${message.reference})`,
        text: [
          `Thanks for writing. ${message.responderName ?? 'We'} will answer here.`,
          message.backOn && `We are back on ${new Date(message.backOn).toDateString()}.`,
          message.bookingUrl && `Or book a call: ${message.bookingUrl}`,
        ]
          .filter(Boolean)
          .join('\n\n'),
        replyTo: message.replyTo,
      };
    case 'agent-new':
    case 'agent-reminder':
      return {
        subject: message.reopened ? `Reopened: ${message.subject}` : message.subject,
        text: `${message.body}\n\n${message.url}`,
      };
    case 'agent-mention':
      return {
        subject: `${message.authorName} mentioned you in ${message.reference}`,
        text: `${message.body}\n\n${message.url}`,
      };
  }
}

export const email = {
  async send(message: HelpdeskEmail) {
    transport ??= nodemailer.createTransport(process.env.SMTP_URL);
    await transport.sendMail({ from, to: message.to, ...render(message) });
  },
};

The transport is created on the first send rather than when the module loads, so next build, which loads the module without your production secrets, does not need SMTP_URL.

Then pass it to buildHelpdesk as email:

lib/helpdesk.ts
import { email } from './helpdesk-email';

export const helpdesk = buildHelpdesk({
  // ...
  email,
});

The adapter above is email-smtp.ts in the repository's examples. The same adapter on Resend is email-resend.ts, and on Postmark email-postmark.ts.

Every email is queued as a job and sent when the jobs run. Until something runs them, no mail leaves at all. Set that up in Run jobs and retention.

Which kind goes where

customer-reply and customer-receipt go to the customer. A reply goes out two minutes after the agent sends it, and not at all if the customer has read it in the widget by then; a receipt goes out only from an inbox with receipt: true. agent-new, agent-reminder and agent-mention go to agents. Email messages says when each kind is sent and lists its fields.

An agent-new or agent-reminder goes to every agent who has opened the agent UI in the last 30 days, or only to the assignee when the conversation is assigned to one of them. A mention goes to every agent picked, however long ago they last opened it, as long as they have not been removed; Remove someone from the team covers removal. Agent emails always carry locale: 'en'; customer emails carry the customer's locale, en, de, fr or it, so a template can pick its language from it.

Two fields need a fallback. A customer-reply has no subject when the message that opened the conversation was not proven, for instance because someone typed their address into a public form, so the adapter above falls back to the reference. Proving the address later does not bring the subject back; Identity and trust explains why. agentName can be an empty string, which filter(Boolean) drops.

Thread replies back into the conversation

When the customer answers your email, the answer should land in the same conversation. Two things make that work:

  • Pass replyTo through, and set replyToAddress in the config so it carries the reference, such as support+ACME-1042@example.com.
  • Pass inReplyTo through as the In-Reply-To header. It holds the message id of the customer's last email in the thread, when there is one.

Receiving those answers is set up in Receive email.

Let customers rate from the email

A customer-reply carries ratingLinks when the conversation is resolved by the time the email goes out, as it is after an agent uses Send and resolve, and nobody has rated it yet. Put both links in the email, as the adapter above does.

Each link opens a small page at {basePath}/rate/ on the origin of your adminUrl. The page asks the customer to confirm with a button, so a mail scanner that follows every link rates nothing. "Not solved" opens the conversation again, waiting on your team. The links are signed with a key the package keeps in its own settings table, so there is nothing to configure, and they work for 30 days.

Send HTML as well

Message bodies are plain text with a small Markdown subset, written to read well unrendered, which is why the adapter above sends them as they are. For an HTML part, render the body with RichText from better-helpdesk/rich inside the customer-reply case, and return the result as html next to text:

lib/helpdesk-email.ts
import { RichText } from 'better-helpdesk/rich';
import { createElement } from 'react';

async function render(message: HelpdeskEmail) {
  const { renderToStaticMarkup } = await import('react-dom/server');
  switch (message.kind) {
    case 'customer-reply':
      return {
        // subject, text and the headers as above
        html: renderToStaticMarkup(createElement(RichText, { text: message.body })),
      };
    // the other cases as above
  }
}

export const email = {
  async send(message: HelpdeskEmail) {
    transport ??= nodemailer.createTransport(process.env.SMTP_URL);
    await transport.sendMail({ from, to: message.to, ...(await render(message)) });
  },
};

react-dom/server is loaded inside the function on purpose: with Turbopack, next build fails on a static import of it in a module that a route handler imports. Loading it there makes render async, so send awaits it.

Pass compact: true as well when the body sits inside a paragraph of your own template: it renders inline only, with lists as lines.

When a send fails

Throw. A job whose handler throws is tried again, up to five attempts with growing waits between them, and then parked with its last error in the helpdesk.job table. A send that fails silently is never retried. Run jobs and retention shows how to find parked jobs.

On this page