The Problem:
- Ghost uses Mailgun’s API (not SMTP) for newsletters.
- Configuring mail__transport=SMTP only affects transactional emails (login links, invites).
- Bulk emails still try to hit Mailgun and fail with Unauthorized: Forbidden if you don’t have a Mailgun account.
The Solution:
We create a tiny Node.js proxy that exposes the same endpoints as Mailgun’s API but delivers via any SMTP server you want.
Step 1 — Proxy Code
Create a folder mgproxy/ inside your Ghost volume and add three files:
package.json
{
"name": "mgproxy",
"private": true,
"type": "module",
"dependencies": {
"basic-auth": "2.0.1",
"express": "4.19.2",
"multer": "1.4.5-lts.1",
"nodemailer": "6.9.14"
}
}
package-lock.json
{
"name": "mgproxy",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": { "name": "mgproxy", "dependencies": { "basic-auth": "2.0.1", "express": "4.19.2", "multer": "1.4.5-lts.1", "nodemailer": "6.9.14" } }
}
}
Server.js
import express from "express";
import multer from "multer";
import nodemailer from "nodemailer";
import auth from "basic-auth";
const app = express();
const upload = multer();
const PORT = 8080;
app.use(express.urlencoded({ extended: true }));
// ENV (set via Docker or your process manager)
const MG_API_KEY = process.env.MG_API_KEY || ""; // shared secret for Ghost -> proxy
const SMTP_HOST = process.env.SMTP_HOST || "smtp.example.com";
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
const SMTP_SECURE = String(process.env.SMTP_SECURE || "false") === "true";
const SMTP_USER = process.env.SMTP_USER || "user";
const SMTP_PASS = process.env.SMTP_PASS || "";
const FORCE_FROM = process.env.FORCE_FROM || ""; // optional fixed From
// SMTP transport
const transport = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: { user: SMTP_USER, pass: SMTP_PASS }
});
// Helpers
const extractEmail = (s) => {
if (!s) return "";
const m = String(s).match(/<([^>]+)>/);
return (m ? m[1] : s).trim().toLowerCase();
};
const splitAddresses = (s) => String(s || "").split(",").map(x => x.trim()).filter(Boolean);
const safeParseJSON = (s) => { try { return JSON.parse(s); } catch { return null; } };
function applyMerge(str, vars = {}, extras = {}) {
if (!str) return str;
let out = String(str);
out = out.replace(/%recipient\.([A-Za-z0-9_]+)%/g, (_, key) => {
const v = vars[key]; return (v === undefined || v === null) ? "" : String(v);
});
out = out.replace(/%unsubscribe_url%/g, extras.unsubscribe_url || "");
out = out.replace(/%tag_unsubscribe_email%/g, extras.tag_unsubscribe_email || "");
return out;
}
app.get("/healthz", (_req, res) => res.json({ ok: true }));
// Mailgun-compatible endpoint
app.post("/v3/:domain/messages", upload.none(), async (req, res) => {
// Auth: Basic api:MG_API_KEY
const creds = auth(req);
if (!creds || creds.name !== "api" || (MG_API_KEY && creds.pass !== MG_API_KEY)) {
return res.status(401).json({ message: "Unauthorized" });
}
// Inputs
const domain = req.params.domain;
const fromRaw = req.body.from;
const toRaw = req.body.to;
const ccRaw = req.body.cc;
const bccRaw = req.body.bcc;
const subjectRaw = req.body.subject;
const textRaw = req.body.text;
const htmlRaw = req.body.html;
const replyToRaw = req.body["h:Reply-To"] || req.body["h:reply-to"] || req.body.replyTo;
const rvStr = req.body["recipient-variables"] || req.body["recipient_variables"] || "";
const recipientVariables = safeParseJSON(rvStr) || {};
const toList = splitAddresses(toRaw);
if (toList.length === 0) return res.status(400).json({ message: "to is required" });
// Collect custom headers (Mailgun uses h:Header-Name)
const headers = {};
for (const [k, v] of Object.entries(req.body)) {
if (k.toLowerCase().startsWith("h:")) headers[k.slice(2)] = v;
}
// Acknowledge immediately to prevent Ghost retries
const ackId = `<mgproxy.${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
res.status(200).json({ id: ackId, message: "Queued. Thank you." });
// Background send (fire-and-forget)
setImmediate(async () => {
const jobs = [];
for (const toEntry of toList) {
const email = extractEmail(toEntry);
const vars = recipientVariables[email] || {};
const unsubscribe_url =
vars.unsubscribe_url ||
vars.list_unsubscribe ||
vars.manage_account_url || "";
const tag_unsubscribe_email = vars.tag_unsubscribe_email || "";
const merged = (s) => applyMerge(s, vars, { unsubscribe_url, tag_unsubscribe_email });
// Per-recipient headers
const perHeaders = {};
const rawLU = headers["List-Unsubscribe"] || headers["list-unsubscribe"] || "";
const rawLUP = headers["List-Unsubscribe-Post"] || headers["list-unsubscribe-post"] || "";
if (rawLU) perHeaders["List-Unsubscribe"] = merged(rawLU);
if (rawLUP) perHeaders["List-Unsubscribe-Post"] = merged(rawLUP);
for (const [hk, hv] of Object.entries(headers)) {
const lk = hk.toLowerCase();
if (lk === "list-unsubscribe" || lk === "list-unsubscribe-post") continue;
perHeaders[hk] = merged(hv);
}
const msg = {
from: FORCE_FROM || merged(fromRaw),
to: toEntry, // preserve display name if present
subject: merged(subjectRaw),
text: merged(textRaw) || undefined,
html: merged(htmlRaw) || undefined,
headers: perHeaders
};
const rt = merged(replyToRaw);
if (rt) msg.replyTo = rt;
// CC/BCC with per-recipient merges can duplicate mail; omit for safety
if (ccRaw) msg.cc = undefined;
if (bccRaw) msg.bcc = undefined;
jobs.push(
transport.sendMail(msg).catch(e => {
console.error("SMTP send failed for", email, e);
})
);
}
await Promise.allSettled(jobs);
});
});
// 404
app.use((_req, res) => res.status(404).json({ message: "Not found" }));
app.listen(PORT, () => console.log(`mgproxy listening on :${PORT}`));
Step 2 — Docker Compose
Add the following to your docker compose file:
mgproxy:
image: node:20-alpine
working_dir: /config/mgproxy
command: ["sh","-lc","npm install --omit=dev && node server.js"]
restart: always
environment:
MG_API_KEY: my-shared-secret
SMTP_HOST: smtp.example.com
SMTP_PORT: 587
SMTP_SECURE: "false"
SMTP_USER: smtp-user
SMTP_PASS: smtp-password
volumes:
- ghost-config:/config
expose:
- "8080"
Step 3 — Ghost Config
Edit config.production.json in your Ghost volume:
"bulkEmail": {
"mailgun": {
"baseUrl": "http://mgproxy:8080",
"apiKey": "my-shared-secret",
"domain": "mail.example.com"
}
}
Step 4 — Restart and Test
Restart your docker compose and run this to check it works.
docker exec -it $(docker ps -qf name=mgproxy) wget -qO- http://localhost:8080/healthz
If it is setup correctly it should return: {"ok":true}
Done!
You’ve now tricked Ghost into sending newsletters through any SMTP by running a lightweight Mailgun-compatible proxy.
This solution not only works with standard SMTP but it also works with SES, Postmark, Mailjet, or your own Postfix. Best of all it works with Resend! Which I am using on their free plan and highly recommend for its simplicity and ease of use.
Thanks for reading!