Back to Blog

Self-Hosted Postiz Emails Not Arriving? Setting Up Amazon SES and Fixing the Nodemailer Message Format

August 28, 20266 min read

While configuring email for a self-hosted Postiz installation, I ran into a misleading failure: Postiz reported that an activation email had been sent, Amazon SES accepted it with a successful SMTP response, and yet the message never appeared in Gmail—not even in Spam or Promotions.

A simple test sent through the same server, credentials, sender, and SES endpoint arrived immediately. That narrowed the problem to the way Postiz was constructing its email rather than the network or SES itself.

This article explains how to configure Postiz with Amazon SES, diagnose each layer of the delivery path, and fix Postiz-generated messages that disappear.

How Postiz email works

Self-hosted Postiz supports Resend and Nodemailer. Nodemailer is the SMTP client Postiz uses to connect to Amazon SES or another mail service. The official Postiz email documentation notes that configuring email also makes activation mandatory for new users.

Email can be needed for account activation, password resets, team invitations, and posting success or failure alerts. A broken configuration can therefore lock users out.

Configure Amazon SES

Start in the AWS region you intend to use. SES identities, SMTP credentials, sandbox status, and quotas are regional.

  1. Create a verified identity for the sending domain or exact sender address.
  2. Enable Easy DKIM and publish the DNS records supplied by SES.
  3. Wait for the identity and DKIM status to become verified.
  4. Request production access if the account remains in the sandbox.
  5. Create dedicated SES SMTP credentials.

SES SMTP credentials are not normal AWS access keys, and they are unique to a region. AWS covers this in its SMTP credential guide. Keep them outside Git and do not print them during testing.

Configure Postiz

services:
  postiz:
    environment:
      EMAIL_PROVIDER: nodemailer
      EMAIL_HOST: email-smtp.YOUR-REGION.amazonaws.com
      EMAIL_PORT: "587"
      EMAIL_SECURE: "false"
      EMAIL_USER: ${POSTIZ_EMAIL_USER}
      EMAIL_PASS: ${POSTIZ_EMAIL_PASS}
      EMAIL_FROM_NAME: Postiz
      EMAIL_FROM_ADDRESS: [email protected]

Port 587 with EMAIL_SECURE=false uses STARTTLS. SES also supports implicit TLS on port 465 with EMAIL_SECURE=true. See AWS's SMTP connection documentation.

After changing the environment, recreate the application container:

docker compose up -d --force-recreate postiz

Then check the logs and confirm Postiz selected the Nodemailer provider.

Test SMTP independently

Before repeatedly pressing “resend activation,” send a minimal message from the same container. That exercises the same DNS, firewall, credentials, sender, endpoint, and network route.

const nodemailer = require("nodemailer");
const transport = nodemailer.createTransport({
  host: process.env.EMAIL_HOST,
  port: Number(process.env.EMAIL_PORT || 587),
  secure: process.env.EMAIL_SECURE === "true",
  auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }
});

const result = await transport.sendMail({
  from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM_ADDRESS}>`,
  to: "[email protected]",
  subject: "Direct SES delivery test",
  text: "Plain-text test through the Postiz SMTP configuration."
});

console.log({
  accepted: result.accepted,
  rejected: result.rejected,
  response: result.response,
  messageId: result.messageId
});

In my case, the direct message reached Gmail. That proved the server could reach SES, the credentials and region matched, the sender was accepted, and Gmail could receive mail from the SES account.

“250 Ok” is not inbox delivery

An SMTP 250 Ok means the next server accepted responsibility. It does not prove the destination put the message in an inbox. Record the SES message ID and check identity status, DKIM, sandbox status, suppression, bounces, complaints, and delivery events.

Check the Temporal worker

Current Postiz releases use Temporal for background workflows. The API can queue an activation message while no worker is polling the queue. This produces a deceptive sequence: the endpoint returns success, a workflow exists, no SMTP attempt occurs, and the user receives nothing.

Confirm that the orchestrator finishes startup, exposes its health listener, and has an active poller for the main task queue. A process manager saying “online” proves only that a launcher process exists.

In this installation, the orchestrator stalled while initializing many social-provider queues. Temporarily excluding provider queues allowed the core main queue to start and process transactional mail. That was a diagnostic workaround; required social queues must be restored after finding the queue that blocks startup.

The Postiz message-format bug

After restoring the worker, SES accepted the activation messages but Gmail still did not display them. Direct mail continued to arrive. The difference was the MIME content generated by Postiz's Nodemailer provider.

In the version examined, Postiz supplied the same HTML document as both the plain-text and HTML bodies:

await transporter.sendMail({
  from,
  to,
  subject,
  text: html,
  html: html
});

The text/plain part therefore contained HTML tags, inline CSS, and a long activation URL. A proper multipart email should contain readable plain text alongside formatted HTML.

The first MIME fix was not enough

The obvious correction was to generate a genuine text alternative while retaining the styled HTML message. Links must be converted before tags are removed so the plain-text version preserves destinations such as activation and password-reset URLs.

That change improved the MIME structure, and a formatted test message reached Gmail. However, a real Postiz activation email containing the styled wrapper and long signed activation URL was still hidden, even though SES accepted it. A manually generated plain-text activation message to the same mailbox arrived immediately.

This distinction mattered: a generic test message passing did not prove that the actual security-sensitive transactional message would pass the same filters.

The final fix: plain-text-only transactional email

The reliable production choice was to omit the HTML MIME part from Postiz transactional email and send only the cleaned text:

function htmlToText(html) {
  return String(html || "")
    .replace(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gis, "$2 ($1)")
    .replace(/<br\s*\/?\s*>/gi, "\n")
    .replace(/<\/p\s*>/gi, "\n\n")
    .replace(/<\/div\s*>/gi, "\n")
    .replace(/<[^>]+>/g, " ")
    .replace(/&nbsp;/gi, " ")
    .replace(/&amp;/gi, "&")
    .replace(/[ \t]+/g, " ")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

await transporter.sendMail({
  from: emailFromName + " <" + emailFromAddress + ">",
  to,
  subject,
  text: htmlToText(html),
  ...(replyTo ? { replyTo } : {})
});

The important difference is that there is deliberately no html property. Activation links, password-reset links, invitations, and alerts remain usable because anchor destinations are preserved in the text conversion.

The tradeoff is visual: transactional messages are simpler. For this installation, reliable receipt was more important than decorative styling. A future upstream template can restore HTML after it is tested with real activation and password-reset messages—not merely a generic test.

Make the container fix survive recreation

Editing a running container is temporary. One controlled workaround is to keep the corrected provider beside the production Compose override and mount it read-only:

services:
  postiz:
    volumes:
      - ./node.mailer.provider.js:/app/apps/orchestrator/dist/libraries/nestjs-libraries/src/emails/node.mailer.provider.js:ro

This patch is version-specific. Before upgrading Postiz, check whether upstream fixed the provider, compare the new implementation, remove the override if possible, and retest both direct SMTP and real Postiz-generated mail.

A reliable diagnostic order

  1. Confirm the user exists and requires activation.
  2. Verify Postiz loaded Nodemailer rather than an empty provider.
  3. Confirm the Temporal main queue has a worker.
  4. Send a direct plain-text SMTP test.
  5. Record accepted and rejected recipients, response, and message ID.
  6. Check SES region, identity, DKIM, sandbox, suppression, and events.
  7. If direct mail arrives but Postiz mail does not, compare MIME parts.
  8. Test a corrected application message.
  9. Recreate the container and test again.

The larger lesson

“Sent” has several stages: the application requests mail, the queue accepts it, a worker processes it, SMTP accepts it, the destination processes it, and the recipient can see it. Each stage requires its own evidence.

Here there were two separate problems: a background worker that did not finish initializing and a malformed plain-text MIME part. Fixing only one still left users without activation email. The final proof was a Postiz-formatted message delivered through the corrected provider—not merely another 250 Ok.

Need help building this?

I turn ideas like these into production-ready software. Let's talk about your project.

Get a Free Quote