How To Send Ghost CMS Newsletters Without Using Mailgun.

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!

Den Kaiyer

I spend hours trying, testing and troubleshooting FOSS and Lifetime Deals to give you no-BS guides and honest, real-world reviews.