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.js → getMembersSupportAddress():
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()andgetDefaultEmail()helpers both return the correctmail.fromvalue at runtime. The problem is isolated togetMembersSupportAddress()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_addressis queried in isolation via Node — it only manifests at runtime because the database-cached value differs from whatmail.fromwould return.