Bolt CMS Docs
Sign in

Platform

Sending email

BOLT_MAIL is a single-class SMTP library — send, queue, log, and blacklist email with no dependencies. Configured once, it is available to core and every extension.

What BOLT_MAIL is

includes/BOLT_MAIL.php is Bolt's core email library — the mail companion to the SQL data layer. It is one self-contained, static class that speaks SMTP directly over PHP stream sockets, so there is nothing to install: no Composer, no SwiftMailer or PHPMailer, no vendor directory. It does four jobs:

  • Sends over SMTP — STARTTLS, implicit SSL, or plaintext, with AUTH PLAIN/LOGIN.
  • Queues messages for a later batch drain (for example, from cron).
  • Logs every attempt to a database table, with its status and any error.
  • Blacklists addresses so they are never contacted.

Email is an optional capability. If SMTP is not configured, calls log the attempt and return an error array — they never throw or halt the request. The page and API front controllers already load the class, so it is available app-wide, including from every extension:

require_once ROOT_DIR . '/includes/BOLT_MAIL.php';   // already loaded by index.php and api/index.php

Configuration

Credentials live in config/mail.php, which returns an array — the same server-side-only pattern as config/database.php (the whole config/ directory is denied to the web by config/.htaccess):

// config/mail.php
return [
    'host'        => 'smtp.example.com',  // BLANK disables email (a graceful no-op)
    'port'        => 587,
    'username'    => 'apikey',
    'password'    => 'secret',
    'encryption'  => 'tls',               // 'tls' = STARTTLS/587, 'ssl' = implicit/465, '' = none/25
    'from_email'  => 'no-reply@example.com',
    'from_name'   => 'Example',
    'reply_to'    => '',
    'timeout'     => 15,
    'verify_peer' => true,                // keep true in production
];

Values resolve in order: BOLT_MAIL::configure([...])config/mail.phpgetenv('BOLT_MAIL_*'). You can keep secrets out of the repo by leaving blanks in the file and setting BOLT_MAIL_HOST, BOLT_MAIL_USERNAME, and so on in production. When host is blank the library is disabled: every send is logged as skipped rather than transmitted, and nothing errors out.

The two tables it needs (mail and mail_blacklist) are created automatically the first time you use the library — there is no install step. They are ordinary BOLT_SQL objects, so the target MySQL database must exist and be configured.

Sending an email

The everyday call is the global wrapper bolt_mail_send(), or BOLT_MAIL::send(). The recipient may be a string or an ['email' => ..., 'name' => ...] pair; the body is HTML by default. Every send returns a result array and logs a row regardless of the outcome:

$result = bolt_mail_send('user@example.com', 'Welcome!', '<p>Glad you could join.</p>');

if ($result['success']) {
    // Handed to the SMTP server. $result['id'] is the mail-log row id.
} else {
    // $result['status'] is 'failed' | 'blacklisted' | 'skipped'
    // $result['errors'][0]['message'] explains why.
}

Pass options for anything beyond the basics:

BOLT_MAIL::send(
    ['email' => 'user@example.com', 'name' => 'Dana'],
    'Your receipt',
    '<h1>Thanks!</h1>',
    [
        'from_email'      => 'billing@example.com',  // override the configured sender
        'from_name'       => 'Example Billing',
        'reply_to'        => 'support@example.com',
        'cc'              => 'records@example.com',   // string or array
        'bcc'             => ['audit@example.com'],
        'is_html'         => true,
        'text_body'       => 'Thanks!',              // optional plain-text alternative
        'idempotency_key' => 'receipt-1042',         // dedupes repeat sends
    ]
);

Options

OptionDefaultPurpose
to_name''Display name when the recipient is given as a bare string.
from_emailconfig from_emailPer-message sender address.
from_nameconfig from_namePer-message sender display name.
reply_toconfig reply_toReply-To address.
cc / bcc[]Extra recipients (string or array). BCC is not written into the headers.
is_htmltrueSend as text/html (else text/plain).
text_body''Plain-text alternative; produces a multipart/alternative message.
idempotency_key''If a row with this key already exists, the duplicate is skipped.
max_attempts3Retry ceiling used when draining the queue.
headers[]Extra raw headers (reserved/structural headers are ignored).

Queueing & the drain

For bulk or deferred mail, queue() stores a pending row and returns immediately without contacting the server. A separate drain delivers them in batches, reusing one SMTP connection:

bolt_mail_queue('user@example.com', 'Weekly digest', $html);   // status: 'queued'

// Later — deliver up to 50 queued messages, oldest first:
$summary = BOLT_MAIL::process_queue(50);
// => ['success' => true, 'processed' => 12, 'sent' => 11, 'failed' => 1, 'skipped' => 0, 'contended' => 0, 'reaped' => 0]

Run the drain unattended with the bundled CLI script from cron (filesystem access is the trust boundary — no HTTP, session, or token is involved):

# every 5 minutes
*/5 * * * * php /path/to/bolt-cms-docs/bin/process-mail-queue.php >> /var/log/bolt-mail.log 2>&1

The admin dashboard's Email panel also has a Process queue now button for an on-demand drain. Failed messages stay queued and retry on the next pass until they reach max_attempts; a permanent (5xx) rejection fails immediately.

Draining is safe to run concurrently. Each message is claimed with an atomic compare-and-set — a single guarded UPDATE … WHERE id = ? AND status = 'queued' — before it is sent, so two overlapping drains (say, cron firing while you click Process queue now) never deliver the same message twice. A drain that loses the race for a row simply skips it; those rows are reported in the result's contended count.

It also self-heals. If a drainer is killed mid-send, its row is left marked sending; on the next run process_queue() first re-queues any row that has been sending longer than its reap threshold (the optional second argument, default 900 seconds) and reports them as reaped. Keep that threshold comfortably above your SMTP timeout so a genuinely in-flight message is never re-queued. You can also sweep on demand with BOLT_MAIL::reap_stuck($seconds); attempts already used are preserved, so a message that keeps stalling still exhausts max_attempts instead of looping forever.

Blacklisting addresses

The suppression list is checked immediately before every transmit — for both send() and each row in process_queue() — so an address added after a message is queued is still honored. Matching is case-insensitive.

BOLT_MAIL::blacklist('spam@example.com', 'hard bounce');   // add (or re-activate)
BOLT_MAIL::is_blacklisted('SPAM@example.com');             // true
BOLT_MAIL::unblacklist('spam@example.com');                // soft-remove (kept for the audit trail)

A send to a blacklisted address is not transmitted: it returns success => false with status => 'blacklisted' and logs a row recording the suppression.

How it is stored

BOLT_MAIL persists everything through BOLT_SQL. The mail table is both the queue and the log: a row is inserted, walks a status lifecycle, and once terminal it is the log entry.

  • queuedsendingsent — the happy path.
  • failed — transport error or out of attempts; the reason is in the error column.
  • blacklisted — suppressed before sending.

Because they are ordinary objects, you can read the log with the normal helpers:

$recent = get_items('mail', [
    'conditions' => [ [ ['status', '=', 'failed'] ] ],
    'orderBy'    => ['created' => 'DESC'],
    'limit'      => 20,
]);

$queuedCount = count_objects('mail', ['conditions' => [ [ ['status', '=', 'queued'] ] ]]);

Return values

Results use Bolt's standard envelope, so they read like any other API result. Success is ['success' => true, 'id' => N, 'status' => 'sent'|'queued']; a graceful failure is ['success' => false, 'id' => N|null, 'status' => ..., 'errors' => [['message' => ...]]]. The status tells you which path was taken:

statusMeaning
sentAccepted by the SMTP server.
queuedStored for a later process_queue() drain.
failedA transport or validation error; see the error column / message.
blacklistedThe recipient is on the suppression list; nothing was sent.
skippedSMTP is not configured (blank host); nothing was sent.

Security

  • Header injection is stripped: carriage returns, line feeds, and null bytes are removed from every header field (recipients, names, subject, extra headers) before a message is stored or sent. Only the body may contain newlines.
  • TLS is verified by default and never silently downgraded — if you request tls but the server does not offer STARTTLS, the send aborts rather than leaking credentials over a cleartext channel. Set verify_peer => false only for a local development sink.
  • Credentials are never logged or returned. The error column holds only protocol reasons, and the admin status view omits the username and password.
  • The blacklist is enforced twice — in send() and again per row in process_queue() — each time immediately before transmit.

Admin dashboard

The admin dashboard includes an Email panel: live SMTP status, queued / sent / failed counts, a filterable mail log, a Send test email action, a Process queue now button, and a blacklist manager. Every endpoint behind it is admin-gated server-side.

See the SQL data layer for the storage that backs the log and blacklist, and extensions for calling bolt_mail_send() from your own packaged features.

Organizations JavaScript libraries