Bandage for Craft CMS

Usage

Twig

The front-end surface is deliberately tiny. One tag is required; the rest are conveniences.

TagWhat it does
craft.bandage.form(handle, options)The hidden inputs. The one required tag.
craft.bandage.captcha(handle, options)CAPTCHA markup on its own, for templates that place it themselves
craft.bandage.errors(submission)Per-field errors, keyed by the label you already know
craft.bandage.reference()The reference of the submission just stored
craft.bandage.submissions(criteria)An element query for stored submissions
craft.bandage.forms()Every configured form
craft.bandage.getForm(handle)One form, or null

craft.bandage.form() takes options:

{{ craft.bandage.form('support', {
  captcha: false,                       {# suppress the CAPTCHA markup #}
  theme: 'dark',                        {# passed through to the provider #}
  honeypotLabel: 'Leave this blank',    {# the visually-hidden label #}
}) }}

The honeypot is hidden with inline CSS rather than a class, because Bandage does not own your stylesheet and a field that is only hidden if a stylesheet loaded is a field real people fill in. It carries tabindex="-1", autocomplete="off" and aria-hidden="true" — a honeypot that catches screen-reader users is a bug, not a spam filter.

Errors

Rule failures land under the name the input already has, so both of these work:

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

{# identical #}
{{ submission.getErrors('message[Phone]')|first }}

craft.bandage.errors() unwraps the message[…] bracket for you and leaves everything else — fromEmail, message — under its own name.

The reference

Every stored submission gets a short code like K4TP-9WQR. There is no I, O, 0 or 1 in the alphabet, because the whole point is that somebody can read it down a phone.

It is not the element ID. Quoting that tells the recipient how much mail you get.

Show it on the thank-you page:

{% set ref = craft.bandage.reference() %}
{% if ref %}
  <p>Thanks — your reference is <strong>{{ ref }}</strong>.</p>
{% endif %}

It is a flash, so it survives exactly one redirect and never turns up later attached to somebody else's message.

Querying submissions

{% set recent = craft.bandage.submissions({
  form: 'support',
  isSpam: false,
  limit: 10,
}).all() %}

{% for submission in recent %}
  {{ submission.reference }} — {{ submission.fromEmail }}
{% endfor %}

It is an ordinary element query, so orderBy, search, dateCreated and the rest behave the way they do everywhere else in Craft.

Conditions

Routing rules, redirect rules, field rules, autoresponders and integrations all take conditions, written one per line as field operator value:

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

Fields: fromName, fromEmail, subject, body, or message.Whatever for one of the extra keys your template posts.

Operators: is, isNot, contains, notContains, startsWith, endsWith, matches, isEmpty, isNotEmpty, greaterThan, lessThan.

Comparisons are case-insensitive and trimmed. greaterThan and lessThan fail both ways on a value that isn't a number, rather than silently coercing it to zero — so "budget over 5000" doesn't match somebody who wrote "not sure".

A rule set to match all needs every line; any needs one.

A text syntax rather than a nested repeater, because conditions are the inner list of a list of rules — and a repeater inside a repeater is a great deal of JavaScript to express something an author can read in one line, and can paste, diff and review.

Recipient routing (Pro)

Rules run in order. The first one that matches applies, unless you clear stop on match, in which case later rules can add to it.

Rule: Sales
  message.Department is Sales
  To:  sales@example.com

Rule: Urgent — copy the on-call
  body contains urgent
  Cc:  oncall@example.com
  (stop on match: off)

Rule: Archive everything
  (no conditions)
  Bcc: archive@example.com

A rule with no conditions matches everything, which is how the catch-all Bcc above is written. A rule with no To addresses adds copies without moving the enquiry.

One thing worth knowing about what happens underneath: Contact Form's send loop sets To once per recipient and sends once per recipient. When Bandage has Cc or Bcc to add it collapses that to a single delivery and moves the extra To addresses into Cc — otherwise a Cc arrives once per recipient, and every To after the first is silently dropped.

Conditional redirects (Pro)

Contact Form's redirect is hashed into the page by redirectInput() at render time, which is before anybody has answered anything. Redirect rules decide afterwards, from what they said:

Rule: Support
  message.Department is Support
  URL: support/thanks

A site path, a full URL, or a template with {fromName}-style placeholders. Rules do not apply to Ajax submissions — those get JSON, not a redirect.

Templating notifications

Subject lines, body templates, autoresponders and redirect URLs are Twig object templates:

Enquiry from {fromName} about {message.Department}

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

Note the difference from conditions, which is easy to trip over: a condition reads message.Phone as one flat key, while a notification template resolves the same text as two lookups against a nested variable. Both spellings are written the same way; they just resolve differently, and the nested one is the one that works in a template.

Bodies are Markdown, so a blank line is a paragraph.

Attachments

With attachments switched on, uploads are copied into your chosen volume and listed on the submission alongside the message. The record keeps filename, size and kind independently of the asset, so it still says what was sent after somebody empties the folder — the normal end of a volume full of strangers' uploads.

Export

From the element index: select submissions, Export. Every submitted field becomes a column of its own.

Fixed columns come first — id, reference, form, status, fromName, fromEmail, subject, body, toEmails, spamScore, spamReasons, ipAddress, site, dateCreated, sentAt — then one column per message[…] key seen anywhere in the results.

Cells beginning =, +, - or @ are prefixed before they are written. 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 the CSV in Excel.

Console

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

prune applies the retention settings and does nothing if none are switched on. Craft's own garbage collection queues the same job, so this is for sites where GC doesn't run often enough to matter.

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

Events

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');
    }
});
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

Your own check is a score and a reason, same as the built-in ones, and the threshold decides what happens to it.

Permissions

Five, under Bandage in the user group settings: view, manage, delete and export submissions, and manage forms. Settings are admin-only.

Next

  • Troubleshooting — when a message doesn't arrive
  • FAQ — what it costs and what it touches