Ghost Blog Digital Ocean email using Brevo without SMTP access
I had fun making this proxy to forward email to Brevo for a Ghost Blog
Digital Ocean droplets block SMTP connections on all ports,
SMTP ports 25, 465, and 587 are blocked on Droplets to prevent spam and other abuses on our platform. This block applies to all Droplets by default and includes traffic passing through a Reserved IP address.
https://docs.digitalocean.com/support/why-is-smtp-blocked/
This was a change that happened a while back, and silently my blog sign up stopped working, and I didn't know until recently. Subscribing to the blog gave error "Failed to sign up, please try again." to the end user.
Ghost has support for Mailgun API using HTTP connection as the mail transport, only I'm already using my Mailgun free quota on other projects, and during my search I see others that just can't get Mailgun verified due to having cell/mobile phone in a not accepted country (or so the poster says).
Ghost also has two mail connections, one used for transaction email, password resets, 2FA, signup confirmation, another used for sending newsletters, mass mail. The API key baked into config.production.json. Brevo also has an HTTP API, but it's shaped differently different endpoints, different payload format, different authentication. You can't just swap one for the other in the Ghost config, that won't work.
After much research, the obvious solution seemed to be to build a small proxy that sits between Ghost and the internet, accepts Mailgun-style requests, and forwards them on to Brevo API.
First Attempt: HTTP Proxy
I know my server already had Node.js on it, it is running Ghost. So I rolled up my sleeves opened Claude code and built a small server that mimicked the Mailgun API endpoint (POST /v3/:domain/messages), accepted the multipart form payload that Mailgun clients send, translated it into a Brevo-shaped JSON payload, and forwarded it on.
Much to my delight, it worked. I could hit it my proxy with "curl" using Mailgun style post, and the email arrived via Brevo! (after adding the IP to allowed senders - that didn't set me back 10 mins at all, longer than to code the proxy)
Then I updated Ghost config to use it. Finding that to my dismay Ghost's Mailgun transport (which is actually node mailer?) looks to ignore host and port fields in the config. I was getting a DNS failure (EAI_AGAIN). I am sure I could have dug into that some more, perhaps I was not providing the correct config settings. Using non-smtp config is undocumented, only found by someone else digging into the source code, maybe I was doing it wrong.
Aha: Add an SMTP Listener
I just pivoted and changed it to a SMTP proxy. Now I suspect there are 100 ways to do this on using linux but I was already invested in my proxy that was already installed as a service and mapped to ports, so I kept going with that.
I made my proxy a an SMTP listener, bound to localhost, Ghost can talk to it on port 2525. When an email arrives over SMTP, parse it, translate it into a Brevo API call, submit it to Brevo.
My coworker, Claude added the smtp server and mailparser npm packages to handle the SMTP side. The proxy now runs two listeners side by side:
- Port 3000 - the original Mailgun HTTP API, for anything that talks to Mailgun via HTTP - now pointless and will most likely take that back out.
- Port 2525 - a local SMTP server, for Ghost (and anything else that speaks SMTP)
Both funnel into the same sendViaBrevo() function.
With the SMTP listener in place, I updated Ghost's config to use transport: "SMTP" pointing at 127.0.0.1:2525, restarted Ghost, and signups started working.
Deploying
The proxy runs as a service on the droplet, so it starts automatically on boot and restarts if it crashes. Secrets (the Brevo API key and the fake Mailgun key) live in an ".env" file with restricted permissions. Thankfully my coworker Claude knows a few things about security.
How to Replicate what I did
Here's everything you need to set this up yourself on a Digital Ocean droplet (or any Ubuntu server running Ghost).
What You'll Need
- A server with Node.js installed (if you're running Ghost, you already have it)
- A Brevo account with an API key
- Your Ghost blog running on the same server
Step 1: Create the project
mkdir /opt/mailgun-proxy && cd /opt/mailgun-proxy
Create package.json:
{
"name": "mailgun-proxy",
"version": "1.1.0",
"description": "Mailgun-compatible API + SMTP proxy that forwards to Brevo",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"axios": "^1.6.0",
"basic-auth": "^2.0.1",
"express": "^4.18.2",
"mailparser": "^3.6.5",
"multer": "^1.4.5-lts.1",
"smtp-server": "^3.13.0"
}
}
Step 2: Create the server
Create server.js:
const express = require("express");
const multer = require("multer");
const axios = require("axios");
const basicAuth = require("basic-auth");
const { SMTPServer } = require("smtp-server");
const { simpleParser } = require("mailparser");
const app = express();
const upload = multer();
const HTTP_PORT = process.env.PORT || 3000;
const SMTP_PORT = process.env.SMTP_PORT || 2525;
const BREVO_API_KEY = process.env.BREVO_API_KEY || "";
const MAILGUN_API_KEY = process.env.MAILGUN_API_KEY || "";
function authMiddleware(req, res, next) {
if (!MAILGUN_API_KEY) return next();
const creds = basicAuth(req);
if (!creds || creds.name !== "api" || creds.pass !== MAILGUN_API_KEY) {
res.set("WWW-Authenticate", 'Basic realm="Mailgun-compatible API"');
return res.status(401).json({ message: "Unauthorized" });
}
next();
}
function parseAddress(str) {
if (!str) return null;
const match = str.match(/^(.+?)\s*<(.+?)>$/);
if (match) return { name: match[1].trim(), email: match[2].trim() };
return { email: str.trim() };
}
function parseAddressList(str) {
if (!str) return [];
return str.split(",").map((s) => parseAddress(s.trim())).filter(Boolean);
}
async function sendViaBrevo({ from, to, cc, bcc, subject, text, html, replyTo, attachments }) {
const payload = {
sender: parseAddress(from),
to: parseAddressList(to),
subject,
};
if (cc) payload.cc = parseAddressList(cc);
if (bcc) payload.bcc = parseAddressList(bcc);
if (replyTo) payload.replyTo = parseAddress(replyTo);
if (text) payload.textContent = text;
if (html) payload.htmlContent = html;
if (attachments && attachments.length > 0) {
payload.attachment = attachments.map((a) => ({
name: a.filename || "attachment",
content: (a.content || Buffer.alloc(0)).toString("base64"),
}));
}
const res = await axios.post("https://api.brevo.com/v3/smtp/email", payload, {
headers: {
"api-key": BREVO_API_KEY,
"Content-Type": "application/json",
},
});
return res.data.messageId || `<${Date.now()}@proxy>`;
}
// HTTP endpoint (Mailgun-compatible)
app.post(
["/v3/:domain/messages", "/messages"],
authMiddleware,
upload.any(),
async (req, res) => {
try {
const { from, to, cc, bcc, subject, text, html, "h:Reply-To": replyTo } = req.body;
if (!from || !to || !subject) {
return res.status(400).json({ message: "Missing required fields: from, to, subject" });
}
const attachments = (req.files || [])
.filter((f) => f.fieldname === "attachment")
.map((f) => ({ filename: f.originalname, content: f.buffer }));
const messageId = await sendViaBrevo({ from, to, cc, bcc, subject, text, html, replyTo, attachments });
console.log(`[HTTP] Forwarded to Brevo — messageId: ${messageId}`);
return res.status(200).json({ id: messageId, message: "Queued. Thank you." });
} catch (err) {
const detail = err.response?.data || err.message;
console.error("[HTTP] Brevo error:", detail);
return res.status(500).json({ message: "Failed to send email", detail });
}
}
);
app.get("/health", (req, res) => res.json({ status: "ok" }));
app.listen(HTTP_PORT, () => {
console.log(`[HTTP] Mailgun-proxy listening on port ${HTTP_PORT}`);
});
// SMTP server
const smtpServer = new SMTPServer({
allowInsecureAuth: true,
authOptional: true,
onAuth(auth, session, callback) {
callback(null, { user: auth.username });
},
onData(stream, session, callback) {
simpleParser(stream, async (err, parsed) => {
if (err) {
console.error("[SMTP] Parse error:", err);
return callback(err);
}
try {
const from = parsed.from?.text || "";
const to = parsed.to?.text || "";
const cc = parsed.cc?.text || "";
const bcc = parsed.bcc?.text || "";
const subject = parsed.subject || "(no subject)";
const text = parsed.text || "";
const html = parsed.html || "";
const replyTo = parsed.replyTo?.text || "";
const attachments = (parsed.attachments || []).map((a) => ({
filename: a.filename,
content: a.content,
}));
const messageId = await sendViaBrevo({ from, to, cc, bcc, subject, text, html, replyTo, attachments });
console.log(`[SMTP] Forwarded to Brevo — messageId: ${messageId}`);
callback();
} catch (e) {
console.error("[SMTP] Brevo error:", e.response?.data || e.message);
callback(new Error("Failed to forward to Brevo"));
}
});
},
});
smtpServer.listen(SMTP_PORT, "127.0.0.1", () => {
console.log(`[SMTP] Listening on 127.0.0.1:${SMTP_PORT}`);
});
smtpServer.on("error", (err) => {
console.error("[SMTP] Server error:", err);
});
Step 3: Create the .env file
nano /opt/mailgun-proxy/.env
# Your Brevo API key — from https://app.brevo.com/settings/keys/api
BREVO_API_KEY=your-brevo-api-key-here
# A secret string your apps will send as the Mailgun API key
# Can be anything — it's just used to protect the HTTP endpoint
MAILGUN_API_KEY=your-chosen-secret-here
PORT=3000
SMTP_PORT=2525
Lock down the file:
chmod 600 /opt/mailgun-proxy/.env
Step 4: Install dependencies
cd /opt/mailgun-proxy
npm install --omit=dev
Step 5: Create the systemd service
Find your Node.js path first:
which node
Create /etc/systemd/system/mailgun-proxy.service, replacing /usr/local/bin/node if your path differs:
[Unit]
Description=Mailgun → Brevo proxy
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/mailgun-proxy
EnvironmentFile=/opt/mailgun-proxy/.env
ExecStart=/usr/local/bin/node /opt/mailgun-proxy/server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mailgun-proxy
[Install]
WantedBy=multi-user.target
Enable and start it:
systemctl daemon-reload
systemctl enable mailgun-proxy
systemctl start mailgun-proxy
systemctl status mailgun-proxy
You should see active (running).
Step 6: Test it
Check the health endpoint:
curl http://localhost:3000/health
# {"status":"ok"}
Send a test email via the HTTP endpoint:
curl -s --user "api:your-chosen-secret-here" \
http://localhost:3000/v3/sandbox.example.com/messages \
-F from="Test <[email protected]>" \
-F to="[email protected]" \
-F subject="Proxy test" \
-F text="If you got this, the proxy is working."
Step 7: Configure Ghost
Edit /var/www/ghost/config.production.json and update the mail section:
"mail": {
"transport": "SMTP",
"options": {
"host": "127.0.0.1",
"port": 2525,
"secure": false,
"ignoreTLS": true
}
}
Restart Ghost:
cd /var/www/ghost && ghost restart
Then go to Ghost Admin > Settings > Email newsletter > Send test to confirm it works. Try signing up for subscription to test transactional email.
Viewing logs
journalctl -u mailgun-proxy -f
Final Notes
Once it's running you have two entry points SMTP on port 2525 for Ghost, and the Mailgun HTTP API on port 3000 for anything that uses the Mailgun SDK. Both forward through to Brevo. Neither is exposed to the internet; they only listen on localhost.
I left the other proxy in as I'm lazy and also someone reading this may be trying to solve a slightly different problem for which this is good.
You need to:
- Verify your sending domain in Brevo - under Senders & IPs → Domains
- Add server IP address to sending IP list - I didn't
- Add SPF and DKIM DNS records for your domain - Brevo walks you through this in the same section
The whole thing is about 100 lines of Node.js and took no time to get running, i am so pleased.