Search by

oliverthiele / ot-mailcatcher

oliverthiele

Mailcatcher - Capture outgoing mails as files instead of sending them, review them in the backend and check them for the usual configuration mistakes.

Package info

github.com/oliverthiele/ot-mailcatcher

Homepage

Type:typo3-cms-extension

pkg:composer/oliverthiele/ot-mailcatcher

Statistics

Installs: 73

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.7.0 2026-08-30 18:16 UTC

README

Captures every outgoing mail as a file instead of sending it, shows the result in a backend module, and reports the usual mail configuration mistakes in wording an editor can act on.

TYPO3 Packagist Version PHP License Changelog

Features

  • Nothing leaves the machine while it is on. Mails are written to var/mailcatcher/ as .eml files instead of being sent. A process that cannot capture refuses to send rather than falling back to real delivery.
  • And they are not lost. Once the catcher is off, captured mails can be delivered to their original recipients — one at a time in the module, or in bulk from the command line.
  • One file per mail. TYPO3's own mbox transport appends every message to a single file without a separator line, which leaves no reliable boundary to split them again — two mails sent within the same request then cannot be told apart. A form finisher sending a receiver notification and a sender confirmation hits exactly that case.
  • Rules in plain language. Ten rules report the usual mistakes, each with one sentence stating the problem and one stating what correct looks like.
  • The same rules in CI. A token-protected HTTP API returns the findings as stable identifiers, so an end-to-end test can assert on them.
  • A test can ask before it sends. The API answers what the catcher's state actually is — including "switched on but not capturing" — so a test that triggers a mail can skip instead of delivering to real recipients. This is the answer no catcher that merely intercepts can give.
  • Impossible to forget. While the catcher is active it says so in the system information toolbar, in the Reports module, and in a banner on every backend page.
  • It never claims more than it can deliver. The switch in the backend module and the actual capturing are two different things — the latter needs one block in additional.php. If that block is missing, the extension says so instead of reporting that no mail is being sent.
  • Locked out of Production unless explicitly allowed, so an administrator cannot silence a live site by accident.
  • Nothing escapes while it is on. A process that may not capture refuses to send rather than falling back to real delivery.
  • Captured mail can still be delivered. Delete the test and debug mails, then send what is left to the original recipients.

Requirements

TYPO3 13.4 LTS, 14.3 LTS
PHP 8.2 or newer

Installation

composer require oliverthiele/ot-mailcatcher

Then add the transport switch at the end of config/system/additional.php — after any block that rewrites the MAIL array:

use OliverThiele\OtMailcatcher\Mail\FileTransport;
use OliverThiele\OtMailcatcher\Service\MailcatcherState;

if (class_exists(MailcatcherState::class) && MailcatcherState::isActive()) {
    $GLOBALS['TYPO3_CONF_VARS']['MAIL']['transport'] = FileTransport::class;
}

Why both this block and the extension's own wiring? They cover different bootstraps, and neither covers everything:

ext_localconf.php (in the extension) additional.php (this block)
Normal frontend, backend, CLI yes yes
After project code rewrites MAIL yes — loads later depends on placement
Reduced bootstrap, e.g. the install tool's mail test no yes

The install tool's mail test under Environment calls BootService::getContainer() without loading extension configuration, so ext_localconf.php never runs there and the mail would be delivered for real. config/system/additional.php is read by every bootstrap and closes that gap.

If the block is missing, the backend says so: normal mail is still captured through the extension's own wiring, and the module reports that reduced bootstraps are not covered.

While the catcher is switched on, no mail leaves the system, on any process. Where a process may not run the catcher — a Production context without MAILCATCHER_ALLOWED=1, which the command line resolves even when the web server sets a development context — mail is refused with an exception rather than delivered. Loud beats silently wrong: the alternative is a scheduler task delivering a bulk send to real recipients while the backend reports that nothing is being sent.

Configuration

Two environment variables, both optional:

Variable Effect
MAILCATCHER_ALLOWED 1 permits the catcher in the Production context. Without it, Production refuses to switch on — a forgotten catcher there stops all mail silently. Only the literal 1 unlocks; true or yes do nothing.
MAILCATCHER_API_TOKEN Enables the test API. While empty the route answers 404 and stays completely closed. Generate one with openssl rand -hex 32.

Both are validated. The backend module and the Reports module report an unlocked Production context, a MAILCATCHER_ALLOWED value that is silently ignored, an API token on a Production system, and a token short enough to guess.

The command line does not inherit the web server's context. Where a web server sets TYPO3_CONTEXT=Development through fastcgi_param, SetEnv or similar, CLI runs still default to Production — and there the catcher stays locked unless MAILCATCHER_ALLOWED=1 is set in the .env loaded for that context. Mail from a console command or a scheduler task is then delivered for real while the backend reports that nothing is being sent. If console runs should be captured too, set the variable; the extension reports the gap as allowedMissing either way.

Switch the catcher on and off in System → Mailcatcher. The state lives in var/mailcatcher/state.json, not in settings.php, which is version-controlled in most projects and rewritten by TYPO3 on its own.

Usage

Backend module

System → Mailcatcher lists the captured mails with a finding count, and shows headers, findings, HTML, plain text, source and attachments per mail. The HTML part is served through its own route into a sandboxed iframe, so foreign mail content never shares the backend document.

Rules

Identifier Severity
senderIsWebsiteVisitor error
unresolvedTypo3Link error
leftoverPlaceholder error
emptySubject error
missingReplyTo warning
missingTextPart warning
relativeLink warning
insecureLink warning
brokenEncoding warning
recipientEqualsSender hint

Add a project-specific rule by implementing MailCheckInterface — it is picked up through the ot_mailcatcher.check service tag, no change to this package needed.

Test API

Requires MAILCATCHER_API_TOKEN; every request carries it in the X-Mailcatcher-Token header. Without a configured token the routes answer 404 rather than 403 — an endpoint that does not exist reveals nothing about what it would have guarded.

Endpoint Purpose
GET /_mailcatcher/api/status The catcher's own state, answered whether it is on or off
GET /_mailcatcher/api/messages List, optionally filtered by to and subject
GET /_mailcatcher/api/messages/{identifier} One mail including text, HTML and attachment metadata
DELETE /_mailcatcher/api/messages Remove all captured mails

The message routes additionally require an active catcher. status deliberately does not: a route that only answers while the catcher is on could never report the two states a caller most needs to hear — that it is off, or that it is on but not wired up.

curl -H "X-Mailcatcher-Token: $MAILCATCHER_API_TOKEN" \
     https://example.ddev.site/_mailcatcher/api/messages

Guarding a test that sends

A test that submits a form sends real mail whenever the catcher is not capturing. On a staging system cloned from live those recipients are real customers, so the question has to be asked before submitting:

{
  "status": "notTakingEffect",
  "mailIsBeingSent": true,
  "enabled": true,
  "allowed": true,
  "wired": false,
  "enabledSince": "2026-08-30T18:04:12+02:00"
}

mailIsBeingSent is the field to branch on. The individual flags are there so a failure message can say why; recombining them in the caller duplicates logic that belongs in one place.

status Meaning Safe for a test that sends
active On and wired up yes — the mail is captured
notTakingEffect On, but the transport was never wired up no — mail goes out while the backend claims otherwise
locked On, but not permitted in this environment no
strayTransport Off, yet the transport points at the catcher nothing is sent, but nothing is captured either
inactive Off and not wired up no — normal delivery

Skipping is the right outcome rather than failing: a test that cannot run safely has not found a defect.

async function mailIsCaptured(request: APIRequestContext): Promise<boolean> {
    const response = await request.get('/_mailcatcher/api/status', {
        headers: { 'X-Mailcatcher-Token': process.env.MAILCATCHER_API_TOKEN ?? '' },
    });

    // 404 means no token configured or the wrong one — either way, do not send.
    if (!response.ok()) {
        return false;
    }

    const status = (await response.json()) as { mailIsBeingSent: boolean };

    return status.mailIsBeingSent === false;
}

test.beforeEach(async ({ request }) => {
    test.skip(!(await mailIsCaptured(request)), 'Mailcatcher is not capturing — refusing to send');
});

After a live incident

Switching the catcher on during a live incident is defensible because nothing is lost. Getting the mail out again afterwards:

  1. Switch the catcher off — normal delivery resumes.
  2. Delete the test and debug mails individually.
  3. Send in a mail's row — delivers that one mail to its original recipients.

Per mail on purpose: what is captured during an incident is a mixture, and the mails deserve to be judged separately. A three-day-old password reset belongs in the bin; the order confirmation next to it belongs in the recipient's inbox.

For a list too long to click through, use the command line:

typo3 mailcatcher:resend --dry-run          # what would go, and to whom
typo3 mailcatcher:resend --limit=20 --force=7

The run reports how many recipients lie outside the site's own domain and names them — the number that matters on a staging system cloned from live, where the captured mail carries real customer addresses. Outside a development context that count is also the confirmation: --force=7 only works while seven external recipients are pending, so it cannot be typed from memory.

A run stops after three failures in a row rather than working through the whole list against a relay that is refusing; everything unsent stays in place.

Sending is refused while the catcher is still on; the mails would go straight back into it. Delivered mails move to var/mailcatcher/sent/ rather than being deleted, so a delivery stays traceable and a failure never destroys the only copy. Each mail keeps its original headers, so the Date the recipient sees is the date it was captured.

mailcatcher:prune requires --force in a Production context: what it holds there may be real customer mail that nobody has received yet.

Command line

typo3 mailcatcher:testmail address@example.org   # sends a receiver/sender pair in one run

typo3 mailcatcher:resend --dry-run               # what would go out, and to whom
typo3 mailcatcher:resend --limit=20 --force=7    # deliver, confirmed by the external count

typo3 mailcatcher:prune --days=30 --dry-run      # what the retention would remove
typo3 mailcatcher:prune --days=30 --force        # --force is required in a Production context

mailcatcher:testmail deliberately sends a pair of mails in a single run: that is the case a single-file catcher loses, so it doubles as the check that this one does not.

License

GPL-2.0-or-later. See LICENSE.

Author

Oliver Thiele — oliver-thiele.de