Transactional email settings for Docker self hosting.

I have just had a whole lot of fun chasing an issue whereby users cannot subscribe because my basic SMTP configuration was using the wrong sender address, derived from but not the same as my SMTP configuration in my .env file. I am a Wintel admin rather than a coder/programmer-don’t lay the hate on me, I used Claude to solve this. And I also asked for a summary to pass on to the developers in case it is helpful for others/future releases.

Ghost transactional email: incorrect sender address derived from members_support_address

Summary

When Ghost is installed using the official Docker build script, the members_support_address setting is written to the database with the literal value "noreply" during initial setup. This value is then passed through a helper chain that appends the site’s hostname (derived from the url config) to produce a fully-qualified email address. Where the configured sending domain differs from the site hostname — for example, the site is hosted at blog.mydomain.com but transactional email is sent from mailers@mydomain.com — the constructed address noreply@blog.mydomain.com is rejected by the SMTP provider.

This is distinct from bulk email configuration, which is handled separately. The issue affects all transactional mail: magic links, signup confirmations, and member signin emails.


Environment

  • Ghost version: 6.30.0

  • Installation method: official Ghost Docker build script

  • Deployment: self-hosted, Docker Compose


Error observed

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

Error Code: EENVELOPE


Root cause

1. Initial database value

The Docker build script writes members_support_address to the settings table with the value "noreply" during first-run setup. This is presumably intended as a placeholder meaning “use the default address”, but the string "noreply" is a partial email address, not a sentinel value that triggers a fallback to mail.from.

Verified via:

SELECT `key`, value FROM settings
WHERE `key` = 'members_support_address';

-- Returns: noreply

2. Helper chain interprets the partial address

settings-helpers.jsgetMembersSupportAddress():

getMembersSupportAddress() {
    let supportAddress = this.settingsCache.get('members_support_address');

    if (!supportAddress) {
        return EmailAddressParser.stringify(this.getDefaultEmail());
    }

    supportAddress = supportAddress || 'noreply';

    // Any fromAddress without domain uses site domain
    if (supportAddress.indexOf('@') < 0) {
        return `${supportAddress}@${this.getDefaultEmailDomain()}`;
    }
    return supportAddress;
}

Because "noreply" contains no @ character, the helper appends the default email domain — derived from the site URL — producing noreply@blog.mydomain.com.

Note: the if (!supportAddress) guard on line 3 does not catch this case, because "noreply" is a non-empty string.

3. The constructed address is used as the envelope sender

members/api.js builds the Nodemailer transporter for all transactional mail and sets the from field via config.getEmailSupportAddress(), which calls getMembersSupportAddress():

transporter: {
    sendMail(message) {
        let msg = Object.assign({
            from: config.getEmailSupportAddress(),
            subject: 'Signin',
            forceTextContent: true
        }, message);
        return ghostMailer.send(msg);
    }
}

This from value is used as the SMTP envelope sender. When it does not match the authenticated SMTP user (mailers@mydomain.com), the SMTP server rejects the message.

4. mail.from config is not consulted

The mail.from value (set via mail__from in the .env file) is correctly present in Ghost’s config and returns the right address when queried directly. However, the transactional email path does not read mail.from — it reads exclusively from members_support_address in the database. Environment variable configuration has no effect on this code path.


Code path summary

POST /members/api/send-magic-link/
  → members/api.js: transporter.sendMail()
      → config.getEmailSupportAddress()
          → MembersConfigProvider.getEmailSupportAddress()
              → settingsHelpers.getMembersSupportAddress()
                  → settingsCache.get('members_support_address')
                      → returns "noreply"  ← database value set by build script
                  → "noreply" has no "@", so append getDefaultEmailDomain()
                  → returns "noreply@blog.mydomain.com"
      → ghostMailer.send({ from: "noreply@blog.mydomain.com", ... })
          → SMTP RCPT TO: <noreply@blog.mydomain.com>
              → 553 rejected by SMTP server


Workaround

Update members_support_address directly in the database:

UPDATE settings
SET value = 'mailers@mydomain.com'
WHERE `key` = 'members_support_address';

This value is database-resident and persists across container restarts and Ghost upgrades.


Suggested fixes

Option A — Treat "noreply" as a sentinel value

In getMembersSupportAddress(), check for the literal string "noreply" and fall back to getDefaultEmail() (which correctly reads mail.from) rather than constructing an address from it:

if (!supportAddress || supportAddress === 'noreply') {
    return EmailAddressParser.stringify(this.getDefaultEmail());
}

Option B — Consult mail.from as the fallback

When members_support_address is absent, empty, or set to "noreply", fall back to config.get('mail:from') before falling back to the URL-derived default.

Option C — Do not write "noreply" during installation

The Docker build script should leave members_support_address unset (or empty) during initial setup, allowing the if (!supportAddress) guard in getMembersSupportAddress() to trigger the correct fallback path.


Notes

  • The getNoReplyAddress() and getDefaultEmail() helpers both return the correct mail.from value at runtime. The problem is isolated to getMembersSupportAddress() receiving a non-empty partial address from the database.

  • The EmailAddressService (email-address-service.js) correctly resolves addresses and is not involved in this failure path. It is used for bulk/newsletter email, not transactional.

  • The bug is not reproducible when members_support_address is queried in isolation via Node — it only manifests at runtime because the database-cached value differs from what mail.from would return.

You can set the support address being used from this in the UI. I believe its in the newsletter settings, form there you can set the sender address.