Ghost Debugging 4: SMTP Email Bug

Previously in Ghost Debugging 3: Stepping through code, we saw how to set break points at key stages of application initialization.

In this post we will set a breakpoint where the email error is being generated and step around the code to see why info@nodeholder.com is getting replaced with noreply@localhost.

Finding the error

In Ghost 5.47.1 the subscribe button does not work because Ghost fails to send a message to the SMTP server configured in /var/www/ghost/ config.production.json, discussed on the forum  here.

Debug Information:
    OS: Ubuntu, v20.04.4 LTS
    Node Version: v16.15.0
    Ghost Version: 5.47.1
    Ghost-CLI Version: 1.21.0
    Environment: production
    Command: 'ghost doctor'
Output of ghost doctor

This is NodeJS log message that handles the authorize by email request:

Jul 09 03:06:26 ghost-sfo2-01 node[137344]: [2023-07-09 10:06:26] INFO "POST /members/api/send-magic-link/" 400 901ms
Ghost console output of development version

This is the stack leading to the error:

- createMailError()
- SMTPConnection._formatError()
- SMTPConnection._actionRCPT()
- SMTPConnection.()
- SMTPConnection._processResponse()
- SMTPConnection._onData()
- TLSSocket.SMTPConnection._onSocketData()

All recipients were rejected

Reason: Can't send mail - all recipients were rejected: 553 5.7.1 <noreply@notes.nodeholder.com>: Sender address rejected: not owned by user info@nodeholder.com.
Error from SMTP server via Ghost NodeJS console output

The SMTP server is saying that the sender must be the owner of the mail server as designated by the email address info@nodeholder.com.  And yet info@nodeholder.com is what is specified in both ghost.{development,production}.json.

We can surmise that SMTPConnection._onData() is a callback that handles SMTP data and it failed, thus calling createMailError. But why?

Using the debugger

Subscribe and watch the terminal output of the NodeJS process:

Attempting to subscribe

Console output of node process

Error: Can't send mail - all recipients were rejected: 553 5.7.1 
<noreply@localhost>: Sender address rejected: not owned by user info@nodeholder.com

 at createMailError   
(/var/www/ghost/versions/5.47.1/core/server/services/mail/GhostMailer.js:70:12)

at TLSSocket.SMTPConnection._onSocketData 
(/var/www/ghost/versions/5.47.1/node_modules/nodemailer/lib/smtp-connection/index.js:193:44)
Ghost console output

GhostMailer.js

The error was generated by nodemailer/lib/smtp-connection/index.js and reported by core/server/services/mail/GhostMailer.js. Either GhostMailer passed the wrong info to the smtp-connection, or, the smtp-connection is broken.

Searching the GhostMailer.js file we see where the message is sent, lets re-run and break there:

GhostMailer attempting to send message to SMTP server

Note the message being passed: from is noreply@localhost. That should be info@nodeholder.com as specified in config.development.json. This message doesn't have a chance of working since the SMTP server requires a proper FROM field and noreply@localhost is not the valid email address.

The call stack shows sendMagicLink calls sendMail which calls send. One of these is the culprit. Lets step into sendMail(message).

getAuthEmailFromAdress() returns unexpected value

getAuthEmailFromAddress

We see the from field is set by config.getAuthEmailFromAddress(). Let's step in. The config object is a MembersConfigProvider class which is doing the semantic switch from "get an authentication email address" to "get email support address".

MembersConfigProvider changing from AuthEmail to Email Support

Root cause of bug

It seems we have found the root cause: MembersConfigProvider: getAuthEmailFromAddress() should be getting an address that is more like "smtpFromAddress" which would translate directly to the semantics used in config.production.js.

Following the erroneous path

Here's MemoryCache.js getting the wrong value of noreply.

Cache manager working but wrong variable is being request

Okay, so we've seen that when preparing to communicate with the SMTP server, Ghost calls getAuthEmailFromAddress but that reaches into a memory cache key/value of members_support_address='noreply' which is incorrect.  Instead, it should be getting the SMTP from field in config.production.json which is info@nodeholder.com.  

  "mail": {
    "from": "'NH Info' <info@nodeholder.com>",
    "transport": "SMTP",
    "options": {
      "host": "smtp.xxxxxxxx.com",
      "port": 587,
      "auth": {
        "user": "info@nodeholder.com",
        "pass": "xxxxxxx"
      }
    }
config.developmet.json

createMailError confirmation

Lets let it run to createMailError() and see what the smtp-connection returned:

SMTP Error: noreply@localhost, should be info@nodeholder.com

The SMTP server reports the wrong FROM value

The SMTP server (smtp.somecompany.com) expects the from field to be info@nodeholder.com but in actuality it is noreply@localhost. This conflict with what ghost:config reports (`info@nodeholder.com`). This implies the Ghost system is not honoring the configuration information in ghost.{development,production}.json files.

Solution

Quick and dirty

A workaround is to hardcode the string returned by getAuthEmailFromAddress found in:

/var/www/ghost/versions/5.47.1/core/server/services/members/MembersConfigProvider.js
Class that contains getAuthEmailFromAddress()
class MembersConfigProvider {
  getAuthEmailFromAddress() {
    return "info@nodeholder.com";
    // return this.getEmailSudpportAddress();
}
Lines 38-40 of MembersConfigProvider.js

This means the underlying SMTP calls will be satisfied for this particular instance  but this is a brittle fix.

For good measure, lets capture the SMTP traffic as described in Ghost Debugging 5: Capturing network packets to confirm our analysis.