How sending works

The hourly send job step by step — what it selects, how the throttle counts, the five-failure circuit breaker, chunked persistence, which transport carries the mail, and what you must do yourself about deliverability and consent.

7 min readUpdated 3 August 2026cron, smtp, deliverability, dkim, spf

There is no queue worker, no scheduler and no send button that sends. There is one cron job, processMailwizardCampaigns, and it runs once an hour. Everything an operator finds confusing about MailWizard's timing follows from that.

The run, step by step

  1. Select every campaign whose status is ACTIVE, with its template joined on. Nothing else is considered — a PENDING, PAUSED, STOPPED, COMPLETED or CANCELLED campaign is invisible to this job.

  2. Read the recipient list. If it cannot be read as a list at all, the campaign is paused and the reason logged, and the run carries on with the others. If the campaign has no recipients, it is skipped and left exactly as the operator set it.

  3. Check the template exists. A campaign whose template has been deleted is logged and skipped.

  4. Check the speed. Anything that is not a positive number pauses the campaign — otherwise it would stay ACTIVE and make no progress forever.

  5. Walk the recipients in order, sending to each one still PENDING until speed attempts have been made. Each recipient becomes SENT or FAILED immediately, with the attempt count and, on failure, the transport's error message recorded on the recipient itself.

  6. Write the list back every ten recipients, and again at the end of the campaign.

  7. Complete the campaign if no recipient is left PENDING.

Then the job moves to the next campaign, and the whole thing happens again an hour later.

The throttle counts attempts, not successes

speed is spent on every address the job tries, whether or not the send succeeded. That distinction is the difference between a bad hour and a destroyed campaign.

An earlier build counted only successful sends. With the mail provider down, nothing counted, so the limit did not apply at all: a single run walked the entire recipient list, marked every address failed, and closed the campaign as COMPLETED. Since a completed campaign had every control disabled, there was no way back from the interface.

Counting attempts caps the damage of an outage at one run's worth of recipients.

The five-failure circuit breaker

Bad addresses are scattered through a list. Five failures in a row are not bad addresses — they are a provider that is down, throttling you, or rejecting your authentication.

So after five consecutive failures the job stops sending for that campaign for the rest of the run. Recipients it never reached stay PENDING, the next hour picks up where it stopped, and the sending log records why the run ended early.

The consequence to plan around: a transient provider hiccup costs you an hour, not a campaign. If you see a campaign advancing five recipients per hour when its speed is 200, the breaker is tripping every run and the problem is your mail transport, not MailWizard.

Chunked persistence

Recipient statuses are written back to the database every ten sends rather than once at the end. A crash or a restart mid-campaign therefore risks re-sending at most ten emails, not everything the run had already sent.

If a write-back fails, the job stops sending for that campaign immediately — continuing without persisted statuses would make every further send re-sendable on the next restart.

What actually carries the mail

Each send is a direct call to the platform's mailer with three values: the recipient address, the campaign's subject, and the template's exported HTML.

Transactional email — password resets, KYC notices, deposit confirmations — is enqueued on Redis and processed by a worker with retries. MailWizard is not. Its sends are synchronous calls made from inside the cron run.

Three things follow. There is no automatic retry for a failed campaign recipient; retrying is an operator action. There is no queue to inspect — what you see on the campaign page is the whole record. And the global MAIL_DISABLED kill switch, which the queue honours, is not consulted, so a staging copy of production with an ACTIVE campaign will send real email to real customers.

The transport itself is whatever APP_EMAILER selects — nodemailer-service, nodemailer-smtp, nodemailer-sendgrid or local. MailWizard has no settings of its own and no per-campaign sender. See Environment variables.

Because nothing is templated on the way out:

  • The HTML is delivered verbatim. No merge tags are resolved.
  • No plain-text alternative is generated. The message is HTML-only.
  • No unsubscribe URL is inserted, and no List-Unsubscribe header is set.
  • No notification preference is checked. A user who opted out of platform emails still receives a campaign they are a recipient of.

Deliverability — what you have to do yourself

MailWizard gives you a sender and a rate limit. Everything that decides whether your mail reaches an inbox is outside it.

SPF, DKIM and DMARC records for the domain in your From address. Without them, bulk mail from a new sender goes to spam essentially by default.

Note the trap: the APP_NODEMAILER_DKIM_PRIVATE_KEY, _DOMAIN and _SELECTOR variables are only read by the local sendmail transport. On SMTP, service or SendGrid they are ignored, and DKIM signing is the provider's job — configure it in their console and publish their DNS records.

Every MailWizard campaign goes out as the same From address as your password resets, because the sender is global. A marketing send that collects complaints degrades the reputation of the address your customers need to receive login codes on.

If your provider allows a second authenticated sender, the safest arrangement is a separate subdomain and a separate account, switched in .env for the period you are sending — accepting that this changes the sender for the whole platform while it is set.

A brand-new sending domain that emits a thousand messages in an hour looks exactly like a compromised account. Start with a low Emails per hour, watch the failure count, and raise it over days.

The throttle is per campaign, not per install. Two campaigns at 100 each are 200 emails an hour against one mail account.

There is no bounce processing. A dead address fails, gets three attempts if you retry it, and stays in the list. Repeated delivery to invalid addresses is one of the strongest negative signals a mailbox provider measures.

The recipient picker draws from your own registered users, which is a reasonable starting point, but registration is not the same as consent to marketing and it is certainly not the same as a working mailbox.

Nothing in the product adds an unsubscribe link, records an opt-out, or excludes anyone from a future campaign. If you send marketing email in a jurisdiction with an opt-out requirement — most of them — you must add a real link to your template and process the results yourself.

Keeping that footer in a saved block is the only practical way to make sure every template carries it.

Each failed recipient card shows the transport's own error text. 550, 553 and "no such user" are permanent — remove the address. 421, 454, "too many login attempts" and connection timeouts are your provider throttling you — lower the speed and retry later.

Monitoring a run

Two places tell you what happened.

The cron log. processMailwizardCampaigns broadcasts a line per decision — campaigns found, targets parsed, each send attempted, each failure with its reason, and why a run ended early. This is the only place a paused-by-the-job campaign explains itself.

The Campaigns screen. The Stalled Sends figure counts campaigns that are ACTIVE but have not been written to in over an hour. Since the job rewrites a campaign row every time it drains a chunk, an untouched ACTIVE campaign means the worker is dead, wedged, or never picked it up. On a healthy install that number is zero; anything else is an incident, and the first thing to check is whether the cron process is running at all.

Delivery state lives inside the campaign's recipient JSON. There is no queryable column for recipients, sent or failed, so no report, chart or export can aggregate across campaigns. The analytics on the Campaigns screen report on campaign rows — how many are sending, stalled, paused or never started — not on mail.

If you need delivery reporting, it has to come from your mail provider's own dashboard.

Next: The admin screens, or the API reference if you intend to drive campaigns from a script.