Plugin screenshot thumbnail 1/7
Plugin screenshot thumbnail 2/7
Plugin screenshot thumbnail 3/7
Plugin screenshot thumbnail 4/7
Plugin screenshot thumbnail 5/7
Plugin screenshot thumbnail 6/7
Plugin screenshot thumbnail 7/7

Bandage

Everything Craft's Contact Form plugin doesn't do.

Contact Form is deliberately tiny: it takes a POST, validates an email address and a message, and sends one email. That's the whole plugin. It's a good plugin — but a site running it has no record of what it received, no defence against the spam a public form attracts within a week, no way to tell the sender you got their message, no way to send the enquiry anywhere but one fixed address, and no way to recover the fortnight of enquiries that vanished while the mail server was down.

Bandage adds all of that without changing your form. It attaches through the only three events Contact Form fires, so contact-form/send is still the action you post to, submission is still Contact Form's model, and submission.getErrors() still works exactly as documented. Adopting Bandage means adding one tag to a template you already have.

Requires Craft CMS 5.3+, PHP 8.2+, and craftcms/contact-form 3.x.


Installation

composer require justinholtweb/craft-bandage
php craft plugin/install bandage

Contact Form is a dependency, so Composer will fetch it. If you haven't installed it in Craft yet:

php craft plugin/install contact-form

Getting started

Add one tag to the form you already have:

<form method="post" accept-charset="UTF-8">
  {{ csrfInput() }}
  {{ actionInput('contact-form/send') }}
  {{ redirectInput('contact/thanks') }}

  {# This is the only line Bandage needs. #}
  {{ craft.bandage.form() }}

  <input type="email" name="fromEmail" value="{{ submission is defined ? submission.fromEmail }}">
  <textarea name="message[body]"></textarea>
  <input type="text" name="message[Phone]">

  <button type="submit">Send</button>
</form>

That tag renders three hidden inputs: which form profile applies, a honeypot, and a signed timestamp. From that moment every submission is stored, scored for spam, and reviewable in the control panel under Bandage → Submissions.

For a second form with its own behaviour, name it:

{{ craft.bandage.form('support') }}

What it adds

Stored submissions

Every message becomes a Craft element, so the element index, search, exporter, trash, per-user permissions and a field layout are Craft's own rather than a second implementation of each.

The field layout is the part people miss: it's where your team records what happened next — who followed up, what the outcome was — on the same screen as the message itself. It is not the visitor's form; their answers arrive as whatever message[...] keys your Twig posts.

Each submission gets a short human-quotable reference (K4TP-9WQR), so somebody can read it down a phone. Not the element ID: quoting that tells the recipient how much mail you get.

Spam defence

CheckWhat it catches
HoneypotA field people can't see and bots fill in
Time trapA form submitted in 400ms, or one sitting open for three days
Rate limitThe same address submitting twelve times in a minute
Word / pattern blocklistsLiteral phrases, and regular expressions
Sender / IP blockingAddresses, *@domain.com wildcards, CIDR ranges
Throwaway addressesMailinator and friends
MX checkA sender domain that can't receive mail at all
Link countingThe cheapest signal there is
CAPTCHAreCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile

Checks score rather than veto. A honeypot hit on its own can be forgiven — a password manager filling a hidden field is not a bot — while a honeypot hit plus a twelve-link body is not. When the score reaches the threshold, one of three things happens:

  • Quarantine — stored, marked spam, nothing sent, visitor sees the ordinary success message.
  • Reject — stored, marked spam, nothing sent, visitor sees an error.
  • Flag — sent anyway, but marked, so you can tune the thresholds against real traffic.

All three store the message. A spam folder nobody can review is deletion with extra steps, and the only way to find a false positive is to be able to look.

Add your own signals:

use justinholtweb\bandage\services\Spam;
use justinholtweb\bandage\events\SpamCheckEvent;

Event::on(Spam::class, Spam::EVENT_AFTER_SPAM_CHECK, function(SpamCheckEvent $e) {
    if (str_ends_with((string)$e->submission->fromEmail, '.example')) {
        $e->verdict->add('ourCheck', 10, 'reserved TLD');
    }
});

Field validation

Contact Form validates two things: that fromEmail is an email address and that message isn't empty. Every other field on your form is unvalidated, and a visitor who mistypes a phone number finds out never.

Rules cover required, email, URL, number, whole number, pattern, length, and allow/deny lists, each optionally conditional. Errors land under the name the input already has:

{% set errors = craft.bandage.errors(submission) %}
{{ errors.Phone ? errors.Phone|first }}

{# or, equivalently #}
{{ submission.getErrors('message[Phone]')|first }}

Recipient routing

The most-asked-for thing Contact Form can't do — its recipients come from one setting and are the same for every submission the site ever takes.

message.Department is Sales   →  sales@example.com
body contains urgent          →  Cc: oncall@example.com

A rule with no conditions matches everything, which is how you write a catch-all Bcc. A rule with no to addresses adds copies without moving the enquiry.

Conditional redirects

Contact Form's redirect is hashed into the page by redirectInput() at render time — which is before anybody has answered anything. Bandage decides afterwards, from what they actually said.

Autoresponder

A confirmation back to the sender, with their own answers and their reference, in Twig and Markdown, optionally conditional.

It is rate limited per recipient address, and that is not a nicety: an autoresponder is a mail relay that takes its destination from the request body. Without a cap, anyone can point your contact form at a third party and have your domain — with your real SPF — mail-bomb them.

Integrations

Signed JSON webhooks, Slack and Microsoft Teams. Everything is delivered on the queue: an endpoint that has gone down must not hold the visitor's browser open, and must never fail their submission.

Webhooks carry X-Bandage-Signature: sha256=…, an HMAC over the exact bytes sent.

Attachments

Contact Form attaches uploads to the email and lets PHP delete the temporary files. The only surviving copy is in whichever inbox the message reached — and if the send failed, there is none.

Bandage copies them into an asset volume, with per-file size, count and extension limits. It also fixes a real gap: Contact Form checks extensions after the send event and, on failure, returns false having added no error to the model — so the visitor is told "there was a problem with your submission, please check the form" about a form in which nothing is marked wrong.

Export, retention and the digest

CSV export from the control panel and the console, with each submitted field expanded into a column of its own, and leading =, +, -, @ neutralised — every value in that file came from a stranger, and =HYPERLINK("http://evil","Click") in a name field is a live link the moment somebody opens it in Excel.

Retention deletes submissions after N days, spam sooner, and can blank stored IP addresses while keeping the message. All off by default: a plugin that quietly deletes your correspondence is worse than one that keeps it.

The digest email exists for a failure that is otherwise silent. A form stops working — a mail server changed, a recipient bounced, a spam rule got too tight — and nobody notices, because no email arriving looks exactly like a quiet week. A digest that says "0 submissions, 3 undelivered" is the first thing that makes the difference visible.


Template reference

TagWhat it does
craft.bandage.form(handle)The hidden inputs. The one required tag.
craft.bandage.captcha(handle)CAPTCHA markup on its own, if you want to place it yourself
craft.bandage.errors(submission)Per-field errors, keyed by the label you know
craft.bandage.reference()The reference of the submission just stored
craft.bandage.submissions()A query for stored submissions
craft.bandage.forms()Every configured form

craft.bandage.form() accepts options: { captcha: false } to suppress the CAPTCHA markup, { theme: 'dark' } to pass through to the provider, { honeypotLabel: '…' } to reword the visually-hidden label.

Templating notifications

Subject, body and autoresponder templates are Twig object templates:

Enquiry from {fromName} about {message.Department}

Available: fromName, fromEmail, subject, body, reference, submission, and fields (aliased as message) holding your extra keys.

Conditions

One per line, written field operator value:

message.Department is Sales
body contains urgent
fromEmail matches @(gmail|outlook)\.com$
subject isNotEmpty

Fields are fromName, fromEmail, subject, body, or message.Whatever. Operators: is, isNot, contains, notContains, startsWith, endsWith, matches, isEmpty, isNotEmpty, greaterThan, lessThan.

Console

php craft bandage/submissions/prune [--dryRun] [--limit=500]
php craft bandage/submissions/export [--form=support] [--path=/tmp/out.csv]
php craft bandage/submissions/purge-spam
php craft bandage/digest/send [--force] [--since='-7 days']

Craft has no scheduler, so the digest needs a cron entry.

Events

EventWhen
Spam::EVENT_AFTER_SPAM_CHECKAfter the built-in checks, to add your own
Submissions::EVENT_BEFORE_STOREBefore a submission is written
Submissions::EVENT_AFTER_STOREAfter it is written

Editions

Lite (free)Pro ($79)
Stored submissions, field layout, search, trash
Spam scoring, honeypot, time trap, blocklists, CAPTCHA
Attachment capture and limits
CSV export, retention, the digest
Multiple form profiles
Field validation rules
Recipient routing
Conditional redirects
Autoresponder
Webhooks, Slack, Microsoft Teams

Pro is $79, with a $29 annual renewal for updates. Lite is free and stays free.

Downgrading never deletes configuration — it stops it applying.

Documentation

Full documentation is at justinholt.com/plugins/craft-bandage/docs.

Licence

Proprietary. See LICENSE.md.

Installation Instructions

To install this plugin, copy the command above to your terminal.

Reviews

This plugin doesn't have any reviews.

Active Installs
0
Version
5.0.0
License
Craft
Compatibility
Craft 5
Last release
August 21, 2026