Usage
Twig
The front-end surface is deliberately tiny. One tag is required; the rest are conveniences.
| Tag | What 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');
}
});
| Event | When |
|---|---|
Spam::EVENT_AFTER_SPAM_CHECK | After the built-in checks, to add your own |
Submissions::EVENT_BEFORE_STORE | Before a submission is written |
Submissions::EVENT_AFTER_STORE | After 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