Skip to content

Email adapter#28247

Open
andreascreten wants to merge 2 commits into
TryGhost:mainfrom
madewithlove:main
Open

Email adapter#28247
andreascreten wants to merge 2 commits into
TryGhost:mainfrom
madewithlove:main

Conversation

@andreascreten
Copy link
Copy Markdown

@andreascreten andreascreten commented May 29, 2026

After a couple of tries in the past years, I am giving this one more go.

I understand and respect that the core team has no interest in maintaining other mail providers than Mailgun. But in the interest of the community, it would be great to allow other providers via 3rd party packages. We, for example, have been banned from Mailgun without any explanation and cannot restore our account.

In this implementation, I try to make as few changes as possible (even not moving the email-provider-mailgun and email-analytics-provider-mailgun; ideally, they move into the adapter as well). This way, I hope to reduce the load on the core team from this change while enabling anyone else to implement new email providers.

The implementation of the Postmark provider can be found at madewithlove/ghost-email-postmark.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

Review Change Stack

Walkthrough

This PR introduces a pluggable email adapter framework that decouples Ghost core from Mailgun-specific implementation details. It defines an EmailProviderBase contract that all email adapters must implement, provides a concrete Mailgun adapter implementation, registers the system in the adapter manager, and refactors both email-service and email-analytics to dynamically load adapters at runtime. The changes enable future support for alternative email providers while maintaining backward compatibility with Mailgun as the default.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Email adapter' is vague and generic, using a non-descriptive term that doesn't convey the specific purpose or scope of this substantial feature addition. Consider a more specific title like 'Add email adapter pattern for third-party providers' or 'Introduce pluggable email adapter system' to better communicate the main change.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the intent to introduce an email adapter pattern enabling third-party email providers, provides context about past attempts, and includes a concrete example implementation, making it well-related to the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
ghost/core/core/server/services/email-service/email-service-wrapper.js (1)

78-81: 💤 Low value

Provider-specific branch leaks Mailgun details into the generic wrapper.

Hardcoding if (emailProvider === 'mailgun') { adapterConfig.labs = labs; } couples this generic adapter-loading path to one provider, partially defeating the abstraction. Consider always passing labs in adapterConfig and letting each adapter pick what it needs, so adding a new provider requires no edits here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/services/email-service/email-service-wrapper.js`
around lines 78 - 81, The current provider-specific branch leaks Mailgun details
by only setting adapterConfig.labs when emailProvider === 'mailgun'; instead,
always include labs on the adapterConfig so adapters can opt-in, i.e. assign
adapterConfig.labs = labs unconditionally (remove the if-check referencing
emailProvider), and let each adapter implementation read adapterConfig.labs if
needed; update any surrounding adapter-loading logic that constructs
adapterConfig to ensure labs is passed through.
ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js (1)

69-72: 💤 Low value

Provider-specific === 'mailgun' branch leaks provider knowledge into generic loading code.

Hard-coding which provider gets labs here couples this generic adapter-loading path to Mailgun and won't scale to community providers that may (or may not) need labs. Passing labs unconditionally is harmless for adapters that ignore it, or the per-provider dependency selection should live behind the adapter/factory.

♻️ Drop the provider-specific branch
             const adapterConfig = {
                 configService: config,
-                settingsCache: settings
+                settingsCache: settings,
+                labs
             };
-
-            // Add labs for Mailgun
-            if (emailProvider === 'mailgun') {
-                adapterConfig.labs = labs;
-            }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js`
around lines 69 - 72, The code currently special-cases emailProvider ===
'mailgun' and sets adapterConfig.labs only for Mailgun; remove that
provider-specific branch and instead attach labs to adapterConfig
unconditionally (i.e., ensure adapterConfig.labs = labs before adapter loading)
so adapters that need it receive it and others ignore it; reference
adapterConfig, emailProvider, and labs when making the change so you update the
generic adapter-loading path rather than coupling it to Mailgun.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ghost/core/core/server/adapters/email/index.js`:
- Around line 9-17: getEmailAdapter currently builds an adapterName and calls
adapterManager.getAdapter(adapterName) with no way to pass runtime dependencies;
modify getEmailAdapter to accept a second parameter (e.g., deps or options) and
thread that through to adapterManager.getAdapter so runtime deps like
configService, settingsCache, labs and errorHandler can be injected by callers
(see email-service-wrapper.js); update the function signature
(getEmailAdapter(feature, deps)) and ensure adapterManager.getAdapter is invoked
with those deps/options so callers can avoid reconstructing adapters.

In
`@ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js`:
- Around line 58-80: The analytics wrapper currently builds adapterConfig from
config/settings and labs but omits provider-specific options and the error
handler used by the sending adapter; fix this by merging
bulkEmailConfig[emailProvider] into adapterConfig (so provider-specific keys are
preserved) and include the same errorHandler that the sending path provides
(mirror what email-service-wrapper.js does) before instantiating AdapterClass
(from emailAdapter.getEmailAdapter().constructor), keeping the existing
conditional labs injection and then creating the new instance with the merged
adapterConfig.

In `@ghost/core/core/server/services/email-service/email-service-wrapper.js`:
- Around line 84-86: The code dereferences bulkEmailConfig[emailProvider]
without guarding against bulkEmailConfig being undefined; update the block
around emailProvider and adapterConfig so you only access
bulkEmailConfig[emailProvider] when bulkEmailConfig is present — e.g., change
the if to check bulkEmailConfig && bulkEmailConfig[emailProvider] (or use
optional chaining bulkEmailConfig?.[emailProvider]) before calling
Object.assign(adapterConfig, ...), ensuring you reference the existing symbols
bulkEmailConfig, emailProvider and adapterConfig.

In `@ghost/core/test/unit/server/adapters/email/Mailgun.test.js`:
- Line 55: The tests are attempting to replace Mailgun's native private fields
by setting _Mailgun__emailProvider/_Mailgun__analyticsProvider which only create
public properties and do not override the class private fields
(`#emailProvider/`#analyticsProvider); update the tests to inject stubbed
providers via Mailgun's public constructor/config seam (or add a supported
injection seam) so that the instance uses the provided email and analytics
providers when calling send() and fetchLatest(); specifically, modify
Mailgun.test.js to construct Mailgun with the stubbed provider instances (or
adjust Mailgun's constructor to accept an options object with
emailProvider/analyticsProvider) rather than assigning to
_Mailgun__emailProvider/_Mailgun__analyticsProvider, so send() and fetchLatest()
will delegate to the stubs.

---

Nitpick comments:
In
`@ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js`:
- Around line 69-72: The code currently special-cases emailProvider ===
'mailgun' and sets adapterConfig.labs only for Mailgun; remove that
provider-specific branch and instead attach labs to adapterConfig
unconditionally (i.e., ensure adapterConfig.labs = labs before adapter loading)
so adapters that need it receive it and others ignore it; reference
adapterConfig, emailProvider, and labs when making the change so you update the
generic adapter-loading path rather than coupling it to Mailgun.

In `@ghost/core/core/server/services/email-service/email-service-wrapper.js`:
- Around line 78-81: The current provider-specific branch leaks Mailgun details
by only setting adapterConfig.labs when emailProvider === 'mailgun'; instead,
always include labs on the adapterConfig so adapters can opt-in, i.e. assign
adapterConfig.labs = labs unconditionally (remove the if-check referencing
emailProvider), and let each adapter implementation read adapterConfig.labs if
needed; update any surrounding adapter-loading logic that constructs
adapterConfig to ensure labs is passed through.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fabfa5dc-d43c-4edb-84ee-b87ed95ae7be

📥 Commits

Reviewing files that changed from the base of the PR and between 4b6ad81 and 736ea27.

📒 Files selected for processing (9)
  • ghost/core/core/server/adapters/email/EmailProviderBase.js
  • ghost/core/core/server/adapters/email/Mailgun.js
  • ghost/core/core/server/adapters/email/index.js
  • ghost/core/core/server/services/adapter-manager/index.js
  • ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js
  • ghost/core/core/server/services/email-service/email-service-wrapper.js
  • ghost/core/core/shared/config/defaults.json
  • ghost/core/test/unit/server/adapters/email/EmailProviderBase.test.js
  • ghost/core/test/unit/server/adapters/email/Mailgun.test.js

Comment on lines +9 to +17
function getEmailAdapter(feature) {
let adapterName = 'email';

if (feature) {
adapterName += `:${feature}`;
}

return adapterManager.getAdapter(adapterName);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

getEmailAdapter provides no way to inject runtime dependencies.

The factory returns a fully-constructed instance from the adapter manager using only the static config from defaults.json (Mailgun: {}). Runtime deps like configService, settingsCache, labs, and errorHandler cannot be passed in, which forces consumers (the email-service wrapper) to construct the adapter a second time. Consider accepting a config/deps argument and threading it through to the adapter manager. See the related comment in email-service-wrapper.js.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/adapters/email/index.js` around lines 9 - 17,
getEmailAdapter currently builds an adapterName and calls
adapterManager.getAdapter(adapterName) with no way to pass runtime dependencies;
modify getEmailAdapter to accept a second parameter (e.g., deps or options) and
thread that through to adapterManager.getAdapter so runtime deps like
configService, settingsCache, labs and errorHandler can be injected by callers
(see email-service-wrapper.js); update the function signature
(getEmailAdapter(feature, deps)) and ensure adapterManager.getAdapter is invoked
with those deps/options so callers can avoid reconstructing adapters.

Comment on lines +58 to +80
try {
// Get unified email adapter instance (same one used for email sending)
const adapterInstance = emailAdapter.getEmailAdapter();

// Inject dependencies needed by the adapter
const AdapterClass = adapterInstance.constructor;
const adapterConfig = {
configService: config,
settingsCache: settings
};

// Add labs for Mailgun
if (emailProvider === 'mailgun') {
adapterConfig.labs = labs;
}

// Create a new instance for analytics (the email service has its own instance)
providers.push(new AdapterClass(adapterConfig));
} catch (error) {
logging.error(`[EmailAnalytics] Failed to load ${emailProvider} adapter: ${error.message}`);
logging.error(error.stack);
throw error;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1) Inspect the email adapter factory (getEmailAdapter)
fd -t f 'index.js' | rg -n 'adapters/email/index.js$'
rg -nP -C4 'getEmailAdapter'

# 2) Inspect the EmailProviderBase + Mailgun adapter constructors / destructured config keys
fd -t f 'EmailProviderBase.js' --exec cat {}
fd -t f 'Mailgun.js' -p 'adapters/email' --exec cat {}

Repository: TryGhost/Ghost

Length of output: 6440


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the Mailgun adapter file(s)
fd -t f -i 'mailgun\.js$' . || true
fd -t f -i '*mailgun*' ghost/core/core/server/adapters || true

# Inspect the actual Mailgun adapter implementation (whatever file exists)
# If multiple matches exist, print the first few.
matches=( $(fd -t f -i '*mailgun*' ghost/core/core/server/adapters -a 2>/dev/null | head -n 20) )
echo "MAILGUN_MATCHES=${`#matches`[@]}"
for f in "${matches[@]}"; do
  echo "---- $f (head) ----"
  sed -n '1,160p' "$f"
  echo "---- $f (tail) ----"
  tail -n 80 "$f"
done

# Inspect email adapter factory / adapterManager usage
sed -n '1,120p' ghost/core/core/server/adapters/email/index.js

# Inspect email-service wrapper's adapter loading block for comparison
sed -n '1,140p' ghost/core/core/server/services/email-service/email-service-wrapper.js

# Inspect email-analytics wrapper around the relevant lines (for exact config construction)
sed -n '1,140p' ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js

# Search for "labs" destructuring in adapters/providers
rg -n "labs" ghost/core/core/server/adapters/email ghost/core/core/server/services/email-analytics ghost/core/core/server/adapters -S || true
rg -nP "constructor\((\{)?\s*[^)]*labs" ghost/core/core/server/adapters -S || true

# Search for where getEmailAdapter is called with/without args
rg -n "getEmailAdapter\(" ghost/core/core/server/services/email-analytics ghost/core/core/server/services/email-service ghost/core/core/server/adapters -S || true

# Inspect adapterManager.getAdapter contract (if present)
fd -t f -i 'adapterManager\.js$' ghost/core/core/server || true
fd -t f -i '*adapter*manager*' ghost/core/core/server | head -n 20

Repository: TryGhost/Ghost

Length of output: 541


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate Mailgun adapter(s) deterministically
fd -t f -i '*mailgun*.js' ghost/core/core/server/adapters/email -a
fd -t f -i '*mailgun*.js' ghost/core/core/server/adapters -a

# 2) Print adapter factory + adapterManager usage
sed -n '1,140p' ghost/core/core/server/adapters/email/index.js || true
fd -t f -i '*adapter*manager*.js' ghost/core/core/server | head -n 50

# 3) Inspect Mailgun adapter implementation
MAILGUN_FILE="$(fd -t f -i 'Mailgun\.js$' ghost/core/core/server/adapters/email | head -n 1)"
echo "MAILGUN_FILE=$MAILGUN_FILE"
sed -n '1,240p' "$MAILGUN_FILE"
tail -n 120 "$MAILGUN_FILE"

# 4) Inspect EmailProviderBase (to see constructor shape and config expectations)
EMAIL_BASE_FILE="$(fd -t f -i 'EmailProviderBase.*\.js$' ghost/core/core/server/adapters/email ghost/core/core/server/adapters -a | head -n 1)"
echo "EMAIL_BASE_FILE=$EMAIL_BASE_FILE"
if [[ -n "${EMAIL_BASE_FILE:-}" ]]; then
  sed -n '1,220p' "$EMAIL_BASE_FILE"
fi

# 5) Inspect email-analytics wrapper and the mailgun-specific analytics provider
sed -n '1,220p' ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js
sed -n '1,220p' ghost/core/core/server/services/email-analytics/email-analytics-provider-mailgun.js

# 6) Inspect email-service wrapper for comparison of how adapters are instantiated/configured
sed -n '1,220p' ghost/core/core/server/services/email-service/email-service-wrapper.js

# 7) Search for exact usage of adapterConfig keys inside Mailgun adapter
rg -n --fixed-strings "configService" ghost/core/core/server/adapters/email/Mailgun.js || true
rg -n --fixed-strings "settingsCache" ghost/core/core/server/adapters/email/Mailgun.js || true
rg -n --fixed-strings "labs" ghost/core/core/server/adapters/email/Mailgun.js || true
rg -n --fixed-strings "constructor" ghost/core/core/server/adapters/email/Mailgun.js || true

# 8) Search for destructuring patterns in Email adapter constructor calls
rg -nP "getEmailAdapter\\(([^)]*)\\)" ghost/core/core/server/services/email-analytics ghost/core/core/server/services/email-service ghost/core/core/server/adapters/email/index.js || true
rg -nP "new .*Adapter\\((\\{)?[^}]*\\}" ghost/core/core/server/services/email-analytics ghost/core/core/server/services/email-service ghost/core/core/server/adapters/email -S || true

Repository: TryGhost/Ghost

Length of output: 350


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Cleanly locate Mailgun adapter(s) via safe glob
MAILGUN_FILES=( )
while IFS= read -r f; do MAILGUN_FILES+=("$f"); done < <(fd -t f --glob '*mailgun*.js' ghost/core/core/server/adapters/email)

echo "MAILGUN_FILES_COUNT=${`#MAILGUN_FILES`[@]}"
printf '%s\n' "${MAILGUN_FILES[@]}"

# Email adapter factory
echo "---- adapters/email/index.js ----"
sed -n '1,220p' ghost/core/core/server/adapters/email/index.js

# Find EmailProviderBase (exact file by name)
EMAIL_BASE_FILE="$(fd -t f --glob '*EmailProviderBase*.js' ghost/core/core/server/adapters/email ghost/core/core/server/adapters -a | head -n 1 || true)"
echo "EMAIL_BASE_FILE=$EMAIL_BASE_FILE"
if [[ -n "${EMAIL_BASE_FILE:-}" ]]; then
  sed -n '1,260p' "$EMAIL_BASE_FILE"
fi

# Inspect Mailgun adapter(s) + search for config keys
for f in "${MAILGUN_FILES[@]}"; do
  echo "---- $f (head) ----"
  sed -n '1,260p' "$f"
  echo "---- $f (search config keys) ----"
  rg -n --fixed-strings 'configService' "$f" || true
  rg -n --fixed-strings 'settingsCache' "$f" || true
  rg -n --fixed-strings 'labs' "$f" || true
  rg -n 'constructor\s*\(' "$f" || true
done

# Inspect analytics wrapper + email-analytics-provider-mailgun
echo "---- email-analytics-service-wrapper.js (relevant) ----"
sed -n '1,220p' ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js

echo "---- email-analytics-provider-mailgun.js (relevant) ----"
sed -n '1,260p' ghost/core/core/server/services/email-analytics/email-analytics-provider-mailgun.js

# Inspect email-service wrapper for how it passes config
echo "---- email-service-wrapper.js (relevant) ----"
sed -n '1,220p' ghost/core/core/server/services/email-service/email-service-wrapper.js

# Search for getEmailAdapter invocation patterns in wrappers
echo "---- getEmailAdapter usage in wrappers ----"
rg -n "getEmailAdapter\s*\(" ghost/core/core/server/services/email-analytics ghost/core/core/server/services/email-service ghost/core/core/server/adapters/email -S || true

Repository: TryGhost/Ghost

Length of output: 115


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) List Mailgun adapter files (use glob, not regex)
TMP1=/tmp/mailgun_files.txt
rm -f "$TMP1"
fd -t f --glob '*mailgun*.js' ghost/core/core/server/adapters/email > "$TMP1" || true

echo "MAILGUN_FILES_COUNT=$(wc -l < "$TMP1" 2>/dev/null || echo 0)"
echo "---- mailgun files ----"
cat "$TMP1" 2>/dev/null || true

# 2) Print adapter factory
echo "---- adapters/email/index.js ----"
sed -n '1,220p' ghost/core/core/server/adapters/email/index.js

# 3) Locate EmailProviderBase
TMP2=/tmp/email_base_files.txt
rm -f "$TMP2"
fd -t f --glob '*EmailProviderBase*.js' ghost/core/core/server/adapters > "$TMP2" || true
EMAIL_BASE_FILE="$(head -n 1 "$TMP2" 2>/dev/null || true)"
echo "EMAIL_BASE_FILE=${EMAIL_BASE_FILE:-<none>}"
if [[ -n "${EMAIL_BASE_FILE:-}" ]]; then
  sed -n '1,260p' "$EMAIL_BASE_FILE"
fi

# 4) Inspect Mailgun adapter(s)
while IFS= read -r f; do
  [[ -z "$f" ]] && continue
  echo "==================== $f ===================="
  sed -n '1,260p' "$f"
  echo "---- search config keys in $f ----"
  rg -n --fixed-strings 'configService' "$f" || true
  rg -n --fixed-strings 'settingsCache' "$f" || true
  rg -n --fixed-strings 'labs' "$f" || true
  echo "---- search constructor/config destructuring in $f ----"
  rg -nP "constructor\s*\(\s*\{\s*[^}]*\}\s*\)" "$f" || true
  rg -nP "constructor\s*\(\s*[^)]*\)" "$f" || true
done < "$TMP1"

# 5) Inspect wrappers
echo "---- email-analytics-service-wrapper.js ----"
sed -n '1,240p' ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js

echo "---- email-analytics-provider-mailgun.js ----"
sed -n '1,260p' ghost/core/core/server/services/email-analytics/email-analytics-provider-mailgun.js

echo "---- email-service-wrapper.js ----"
sed -n '1,240p' ghost/core/core/server/services/email-service/email-service-wrapper.js

# 6) Search getEmailAdapter usage patterns
echo "---- getEmailAdapter usage in wrappers ----"
rg -n "getEmailAdapter\s*\(" ghost/core/core/server/services/email-analytics ghost/core/core/server/services/email-service ghost/core/core/server/adapters/email/index.js -S || true

Repository: TryGhost/Ghost

Length of output: 27507


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# List all email adapters under adapters/email
echo "---- adapters/email/*.js ----"
fd -t f ghost/core/core/server/adapters/email | sort

# Inspect adapter-manager implementation for getAdapter contract
ADAPTER_MANAGER_FILE="$(fd -t f -i 'adapter-manager\.js$' ghost/core/core/server/services | head -n 1 || true)"
if [[ -n "${ADAPTER_MANAGER_FILE:-}" ]]; then
  echo "---- $ADAPTER_MANAGER_FILE (head) ----"
  sed -n '1,220p' "$ADAPTER_MANAGER_FILE"
  echo "---- $ADAPTER_MANAGER_FILE (search getAdapter) ----"
  rg -n "getAdapter" "$ADAPTER_MANAGER_FILE" || true
else
  echo "adapter-manager.js not found under services; searching broader..."
  ADAPTER_MANAGER_FILE="$(fd -t f -i '*adapter*manager*.js' ghost/core/core/server | head -n 1 || true)"
  echo "---- $ADAPTER_MANAGER_FILE (head) ----"
  sed -n '1,240p' "$ADAPTER_MANAGER_FILE"
  rg -n "getAdapter" "$ADAPTER_MANAGER_FILE" || true
fi

# Inspect analytics provider wrapper usage of errorHandler/labs (if any)
rg -n "errorHandler" ghost/core/core/server/services/email-analytics -S || true
rg -n "new AdapterClass" ghost/core/core/server/services/email-analytics -S || true

Repository: TryGhost/Ghost

Length of output: 560


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) List adapter files in the adapters/email directory
echo "---- ls ghost/core/core/server/adapters/email ----"
ls -la ghost/core/core/server/adapters/email || true

echo "---- fd files in ghost/core/core/server/adapters/email ----"
fd -t f . ghost/core/core/server/adapters/email | sort

# 2) Locate adapter-manager
echo "---- locate adapter-manager ----"
ADAPTER_MANAGER_FILES=$(fd -t f --full-path -i '*adapter*manager*.js' ghost/core/core/server || true)
echo "$ADAPTER_MANAGER_FILES" | head -n 20

ADAPTER_MANAGER_FILE=$(echo "$ADAPTER_MANAGER_FILES" | head -n 1 || true)
if [[ -n "${ADAPTER_MANAGER_FILE:-}" ]]; then
  echo "---- $ADAPTER_MANAGER_FILE (head) ----"
  sed -n '1,240p' "$ADAPTER_MANAGER_FILE"
  echo "---- $ADAPTER_MANAGER_FILE (getAdapter search) ----"
  rg -n "getAdapter" "$ADAPTER_MANAGER_FILE" || true
fi

# 3) Also inspect any nearby adapter-manager imports
echo "---- references to '../../services/adapter-manager' ----"
rg -n --fixed-strings "services/adapter-manager" ghost/core/core/server/adapters/email ghost/core/core/server/services | head -n 50

# 4) Inspect the email-service adapter factory (already known) for getEmailAdapter(feature) contract
echo "---- adapters/email/index.js (full) ----"
sed -n '1,120p' ghost/core/core/server/adapters/email/index.js

Repository: TryGhost/Ghost

Length of output: 1686


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate adapter-manager files safely (glob, not regex)
fd -t f --glob '*adapter*manager*.js' ghost/core/core/server/services -a | sort | head -n 20
fd -t f --glob '*adapter*manager*.js' ghost/core/core/server -a | sort | head -n 50

ADAPTER_MANAGER_FILE="$(fd -t f --glob '*adapter*manager*.js' ghost/core/core/server/services -a | sort | head -n 1 || true)"
echo "ADAPTER_MANAGER_FILE=${ADAPTER_MANAGER_FILE:-<none>}"

if [[ -n "${ADAPTER_MANAGER_FILE:-}" ]]; then
  sed -n '1,260p' "$ADAPTER_MANAGER_FILE"
  rg -n "getAdapter" "$ADAPTER_MANAGER_FILE" || true
  rg -n "email" "$ADAPTER_MANAGER_FILE" || true
fi

# Search for any other adapters extending EmailProviderBase (beyond adapters/email/Mailgun.js)
rg -n --glob '**/*.js' --glob '**/*.ts' "extends EmailProviderBase" ghost/core/core/server/adapters/email ghost/core/core/server | head -n 50 || true

# Search for adapterName usage like 'email:' or adapter registry keys
rg -n --fixed-strings "email:" ghost/core/core/server | head -n 50 || true
rg -n --fixed-strings "getAdapter('email" ghost/core/core/server | head -n 50 || true
rg -n --fixed-strings "getAdapter(\"email" ghost/core/core/server | head -n 50 || true

Repository: TryGhost/Ghost

Length of output: 16463


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect adapter-manager module export from services/adapter-manager
ls -la ghost/core/core/server/services/adapter-manager || true
fd -t f --glob 'ghost/core/core/server/services/adapter-manager/*' ghost/core/core/server/services/adapter-manager -a | sort

# Print entrypoints (index.js or main file)
for f in \
  ghost/core/core/server/services/adapter-manager/index.js \
  ghost/core/core/server/services/adapter-manager.js \
  ghost/core/core/server/services/adapter-manager/*.js
do
  if [[ -f "$f" ]]; then
    echo "---- $f ----"
    sed -n '1,260p' "$f"
  fi
done

# Search for adapterManager.getAdapter wrappers or adapters registration
rg -n "registerAdapter\\(" ghost/core/core/server | head -n 50
rg -n "new AdapterManager" ghost/core/core/server | head -n 50
rg -n "adapterManager\\.getAdapter" ghost/core/core/server | head -n 50

# Specifically find where adapter type 'email' is registered
rg -n "registerAdapter\\(['\"]email['\"]" ghost/core/core/server | head -n 50 || true
rg -n "registerAdapter\\(\"email\"" ghost/core/core/server | head -n 50 || true

Repository: TryGhost/Ghost

Length of output: 16749


Align email analytics adapter instantiation/config with email sending

The adapters/email/Mailgun.js constructor expects {configService, settingsCache, labs, errorHandler}, and this analytics wrapper passes configService/settingsCache and conditionally labs for mailgun—so the key names match for Mailgun.

However, the wrapper still recreates the adapter via emailAdapter.getEmailAdapter().constructor and (unlike email-service-wrapper.js) does not provide an errorHandler and does not merge bulkEmailConfig[emailProvider] into adapterConfig. For any provider/adaptor that relies on provider-specific options (or error handling) this can leave the analytics adapter misconfigured even while sending works.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ghost/core/core/server/services/email-analytics/email-analytics-service-wrapper.js`
around lines 58 - 80, The analytics wrapper currently builds adapterConfig from
config/settings and labs but omits provider-specific options and the error
handler used by the sending adapter; fix this by merging
bulkEmailConfig[emailProvider] into adapterConfig (so provider-specific keys are
preserved) and include the same errorHandler that the sending path provides
(mirror what email-service-wrapper.js does) before instantiating AdapterClass
(from emailAdapter.getEmailAdapter().constructor), keeping the existing
conditional labs injection and then creating the new instance with the merged
adapterConfig.

Comment on lines +84 to +86
if (bulkEmailConfig[emailProvider]) {
Object.assign(adapterConfig, bulkEmailConfig[emailProvider]);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard bulkEmailConfig access for consistency.

Line 52 reads bulkEmailConfig?.provider (treating an absent bulkEmail config as valid), but line 84 dereferences bulkEmailConfig[emailProvider] unguarded, which would throw a TypeError if bulkEmail is undefined. Although defaults.json always defines bulkEmail, the inconsistency is a latent footgun.

🛡️ Proposed guard
-        if (bulkEmailConfig[emailProvider]) {
+        if (bulkEmailConfig?.[emailProvider]) {
             Object.assign(adapterConfig, bulkEmailConfig[emailProvider]);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/services/email-service/email-service-wrapper.js`
around lines 84 - 86, The code dereferences bulkEmailConfig[emailProvider]
without guarding against bulkEmailConfig being undefined; update the block
around emailProvider and adapterConfig so you only access
bulkEmailConfig[emailProvider] when bulkEmailConfig is present — e.g., change
the if to check bulkEmailConfig && bulkEmailConfig[emailProvider] (or use
optional chaining bulkEmailConfig?.[emailProvider]) before calling
Object.assign(adapterConfig, ...), ensuring you reference the existing symbols
bulkEmailConfig, emailProvider and adapterConfig.

});

// Replace the internal provider with our mock
adapter._Mailgun__emailProvider = emailProvider;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for any babel config that would transform server code
fd -H -t f -i 'babel.config*' 
fd -H -t f -i '.babelrc*'
# Check whether the mangled private-field convention is used anywhere else in the repo
rg -nP '_[A-Za-z]+__[A-Za-z]' -g '!**/node_modules/**' | head -50
# Inspect mocharc / test setup for ghost/core to confirm no transpilation hook
fd -H -t f -i '.mocharc*' ghost/core

Repository: TryGhost/Ghost

Length of output: 50371


Fix Mailgun unit test seam: _Mailgun__emailProvider/_Mailgun__analyticsProvider don’t override native private fields.

ghost/core/core/server/adapters/email/Mailgun.js delegates send()/fetchLatest() to native ES private fields #emailProvider and #analyticsProvider. In ghost/core/test/unit/server/adapters/email/Mailgun.test.js the test sets _Mailgun__emailProvider (lines 55/79/94) and _Mailgun__analyticsProvider (line 109), which only creates unrelated public properties and won’t replace this.#emailProvider/this.#analyticsProvider, so the delegation assertions won’t be exercising the stubs. Inject the provider instances via the constructor/config seam (or add a supported seam) instead of mutating _Mailgun__*.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/test/unit/server/adapters/email/Mailgun.test.js` at line 55, The
tests are attempting to replace Mailgun's native private fields by setting
_Mailgun__emailProvider/_Mailgun__analyticsProvider which only create public
properties and do not override the class private fields
(`#emailProvider/`#analyticsProvider); update the tests to inject stubbed
providers via Mailgun's public constructor/config seam (or add a supported
injection seam) so that the instance uses the provided email and analytics
providers when calling send() and fetchLatest(); specifically, modify
Mailgun.test.js to construct Mailgun with the stubbed provider instances (or
adjust Mailgun's constructor to accept an options object with
emailProvider/analyticsProvider) rather than assigning to
_Mailgun__emailProvider/_Mailgun__analyticsProvider, so send() and fetchLatest()
will delegate to the stubs.

@belatedly
Copy link
Copy Markdown

I really hope this gets merged this time. I do admin for a friend and we went with self-hosting Ghost as a Patreon alternative. That was 2 years ago, and we’ve never had a newsletter go out once to our ~25 recipients because Mailgun limits free accounts to less than 10 recipients at a time.

For the past 2 days I have been trying to upgrade the Mailgun account from numerous devices, browsers, and credit cards, and their system fails to submit the purchase every time. I filed a ticket and their customer service is abysmal, replying with the FAQ link and telling me to try things I documented in the ticket already.

I started looking at alternatives to Ghost this morning because Mailgun clearly isn’t interested in resolving my issue, and a friend I’d vented to ended up seeing this and sending it my way. If this gets merged it will save a lot of frustration and work for us and our subscribers. Please consider it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants