Tracking email replies with IMAP API

One of the spin-off projects from Nodemailer is IMAP API, a daemon application to access IMAP accounts over REST. In this blog post I would like to describe how to use IMAP API to track email replies.

Reply tracking is useful when building integrations with users’ email accounts. Let’s say our service sends out emails as if these were sent by the user, eg. automated sales emails, and now the recipient replies to such message. Reply signal is usually a strong indicator that might change the status of a cold lead to a hot lead, so in most cases we would want to catch it for automated lead processing.

Tracking replies is basically a filtered use case of tracking all new messages. You get a notification that a message was added to INBOX (or Junk if you care about potential false-positive spam messages). Then perform basic checks to detect if this message is a “normal” message or a reply. If it is a reply then validate if it is a reply to a message we actually care about. And finally, check if it is not an automated out of office reply because there is no value in these (besides validating that the address still exists).

Send a message to track replies for

Sending a message to track replies for should be rather simple. Use any kind of message settings you like and in addition do the following:

  • Use a predefined (but otherwise unique) Message-ID header. This is important as the replies will reference this value. Store Message-ID to your database so it would be possible to later check which message was replied to.
  • Try to suppress automated replies by using X-Auto-Response-Suppress header. This does not guarantee that you would not get any OOF emails but it is at least some help.

In IMAP API you can use the following config when submitting messages for delivery:

{
  "messageId": "<[email protected]>",
  "headers": {
    "X-Auto-Response-Suppress": "OOF"
  },
  "from": "....
}

Make sure you store "<[email protected]nder.com>" to database as a reference to the sent message.

I guess that’s about it when sending messages. Next start waiting for replies.

Process email notifications

IMAP API sends webhook notifications for every received message. When processing such notifications there is an important distinction with “regular” email servers and GSuite/Gmail-like servers to be aware of.

For all “normal” email servers IMAP API reports the folder name the message was found in (eg. “INBOX”) in the webhook data but for Gmail accounts IMAP API reports folder names only for “All Mail”, “Junk” and “Trash”.

In such case you would have to check the “labels” value (an array of strings) for actual message placement. Eg. if a message has label \Inbox then this message was found from the INBOX. Labels array includes special-use tags for special folders (\Inbox, \Sent, \Important etc.) and folder name strings for regular folders. So if you see a labels array like the following:

{
  …,
  "path": "[Google Mail]/All Mail",
  "specialUse": "\\All",
  "event": "messageNew",
  "data": {
    …,
    "labels": [
      "\\Inbox",
      "My Stuff"
    ]
  }
}

Then it means the message was stored both in INBOX and in a folder called “My Stuff”. Regular IMAP servers do not allow storing the same message in multiple folders, so for these you would get 2 notifications, one for INBOX and one for “My Stuff”.

{
  …,
  "path": "INBOX",
  "specialUse": "\\Inbox",
  "event": "messageNew",
  "data": {
    …
  }
}

Detect replies

In general, reply detection is quite straightforward. Just check that the message is placed in INBOX (which means that either the webhook specialUser value is \Inbox or labels value, if set, includes the tag \Inbox) and look for the In-Reply-To header which is included in message data under the inReplyTo value. If this value is the same as any Message-ID we previously used to send out a message then this is a reply to this message.

{
  …,
  "path": "[Gmail]/All Mail",
  "specialUse": "\\All",
  "event": "messageNew",
  "data": {
    "id": "AAAAAQAAMqo",
    …,
    "inReplyTo": "<[email protected]>",
    "labels": [
      "\\Important",
      "\\Inbox"
    ]
  }
}

In this example we can look up the previously sent unique Message-ID value "<[email protected]>" so the received message must be a reply to this stored message.

Not done yet

At this point we know that the message is a reply but we do not know if it’s an actual reply or an automated response. We did use the OOF suppression header but it only filters out some of the automated responses, not all. To be able to determine reply status more precisely we have to fetch the extended message information from IMAP API using the “id” value in the webhook:

/v1/account/account_id/message/AAAAAQAAMqo

Extended message information includes several important properties. We are mostly interested in the “headers” object that includes parsed message headers. IMAP API parses headers into a structure where lowercase header key is the object key and all header values for this key are presented as an array of strings. For most headers you would only have an array with a single string value but for some (eg. “received”) there might be several values.

The first thing to look for from the headers is Return-Path value. If it is empty (and in this case "<>" equals empty) then we can ignore this message as empty Return-Path usually means a bounce. An actual email address means that we can continue processing.

{
  "id": "AAAAAQAAMqo",
   …,
  "headers": {
    …,
    "return-path": [
      "<[email protected]>"
    ],
    "in-reply-to": [
      "<[email protected]>"
    ]
    …
  }
}

Next we should look for standard auto-reply headers like Auto-Submitted. If this header is set and it has any other value than “no” then you can ignore this message. We could also check headers like List-ID or List-Unsubscribe. Even if it is a reply then usually replies from mailing lists are not meant specifically for a single user, so such replies might not mean that the recipient is a hot sales lead.

Another thing to look out for is checking if the email subject has a prefix like “Out of Office:” or “Auto:”, these messages should be discarded as well.

At last…

If the messages passed all these checks then at last we could declare this message as a reply to our previous message. Depending on the nature of the automation we may or may not care about the actual reply content. For example if we only use the fact of replying as a signal for calculating lead “hotness” then we probably do not need any additional information. If we do care about the reply contents then we can use the /message endpoint to fetch message text or relevant attachments for whatever processing we need to perform.