Bandage for Craft CMS

Configuration

Settings live in two places, and which one you want depends on whether the answer could ever differ between two forms on the same site.

  • Bandage → Settings — storage, retention, the honeypot field name, the digest. Global by nature.
  • Bandage → Forms → a form — recipients, spam, validation, routing, attachments, the autoresponder, integrations. Per form.

Forms are project config. They are configuration, not content: reviewable in a pull request, deployed with the rest of the config, and identical in every environment.

Plugin settings

SettingDefaultWhat it does
Store submissionsonThe master switch. Off means Bandage still filters spam and still validates, but keeps no record.
Store message bodyonOff keeps who wrote and when, and drops what they said.
Store metadataonIP address, user agent, referrer.
Retention (days)0Delete submissions older than this. Zero keeps them forever.
Spam retention (days)30Delete spam sooner than everything else.
Anonymise after (days)0Blank stored IP addresses, keep the message. Zero disables.
Honeypot field namebandage_websiteChange it per site — bots learn field names.
Timestamp field namebandage_tsCarries the signed render time. Must differ from the honeypot.
Unread badgeonThe count on the CP nav item.
Queue notificationsoffSend the admin email on the queue rather than in the request.
DigestoffSee below.
Spam rejection messagebuilt inShown when the spam action is reject.
Duplicate rejection messagebuilt inShown when a duplicate is rejected.

Retention is off by default on purpose. It is a legal question with a technical answer, and the answer being "never" until somebody decides otherwise is the safe direction to be wrong in.

Form profiles

Lite has one form, default, created at install. Pro adds as many as you like, each addressed by handle from the template:

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

A profile holds:

Recipients. To, Cc and Bcc, one address per line or comma-separated. Leave To empty to fall back to Contact Form's own toEmail setting. Addresses may be $ENV_VARS.

Subject and body templates. Twig object templates, rendered against the submission. Leave them empty and Contact Form's own subject and body are used unchanged. See Usage.

Everything below.

Spam

Every check scores. Nothing vetoes. When the total reaches the threshold (default 5), the action applies:

ActionStoredSentVisitor sees
Quarantine (default)the ordinary success message
Rejectan error
Flagthe ordinary success message

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 at it.

Quarantine is the default because the bot must not learn it was caught. Reject exists for forms where a human false positive needs to be told to try another way.

The checks

CheckDefault scoreNotes
Honeypot10A field people can't see. On by default.
Time trap8Minimum 3s, maximum 86400s. The timestamp is signed, so the minimum can't be edited in the page.
Rate limit10Off (0). Submissions per IP per window (default 3600s).
Blocked words6Literal phrases, one per line, case-insensitive.
Blocked patterns8Regular expressions, one per line, tested against the whole submission.
Blocked senders10Addresses, or *@domain.com wildcards.
Blocked IPs10Addresses or CIDR ranges.
Disposable email6Off. Throwaway-mail providers.
Require MX8Off. Costs a DNS lookup per submission.
Link count5Off (0). Scores past N links in the body.
CAPTCHA10See below.

Scoring rather than vetoing is what makes a honeypot hit on its own forgivable — a password manager filling a hidden field is not a bot — while a honeypot hit plus a twelve-link body is not.

Set any score to 0 to run a check for the record without letting it decide anything, or raise one above the threshold to make it decisive on its own.

A maximum age below the minimum makes every submission spam, so it is refused at save. The site owner would otherwise find out from the person who couldn't reach them.

CAPTCHA

reCAPTCHA v2 (checkbox), reCAPTCHA v3 (invisible), hCaptcha and Cloudflare Turnstile. Each needs a site key and a secret; v3 also takes a minimum score (default 0.5) below which Google's verdict counts against the sender.

The markup comes out of craft.bandage.form() automatically. To place it yourself:

{{ craft.bandage.form('support', { captcha: false }) }}
…
{{ craft.bandage.captcha('support') }}

Verification fails open on a provider outage and closed on a genuine failure. A network error reaching Google is not evidence about the visitor; a returned "no" is. An outage that silently blocked every enquiry would be worse than the spam it kept out.

Field rules (Pro)

Contact Form validates two things: that fromEmail is an email address and that message is not empty. Rules cover the rest.

Types: required, email, URL, number, whole number, matches pattern, length, one of, not one of. Each takes an optional custom message and an optional condition.

Every type except required passes on an empty value. "Must be a valid email address" on a field the visitor deliberately left blank is a rule fighting its own optionality — pair it with a required rule if you mean both.

Attachments

Off by default. Switch it on and pick a volume, and uploads are copied there before the email goes out.

SettingMeaning
VolumeRequired once attachments are on.
SubpathOptional folder inside the volume. Accepts {{ now|date('Y/m') }}-style Twig.
Max attachmentsPer submission. 0 for no limit.
Max sizeMegabytes per file. 0 means whatever PHP allows.
Allowed extensionsEmpty means Craft's own allowedFileExtensions.

Bandage validates uploads during validation, so a rejected file produces an error the visitor can see. Contact Form checks extensions after the send event and returns false having added nothing to the model — which is how you get "there was a problem with your submission" about a form in which nothing is marked wrong.

The attachment record copies filename, size and kind rather than pointing at the asset, so it still says what was sent after somebody empties the volume.

Duplicates

HandlingEffect
Allow (default)Nothing.
Flag as spamScores at the threshold, so it lands in the same review queue as everything else.
Reject with an errorThe visitor is told they already sent this.

The window is 300 seconds by default. Compared fields defaults to sender plus body; name specific value paths to narrow it.

Autoresponder (Pro)

A confirmation back to the person who wrote in, in Twig and Markdown, optionally conditional, with their own answers and their reference included.

Max per hour defaults to 3, per recipient address, and you should think before setting it to zero. An autoresponder is a mail relay that takes its destination from the request body. Without a cap, anyone can point the form at a third party and have your domain — with your real SPF record — mail-bomb them.

Integrations (Pro)

Webhooks (signed JSON), 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.

SettingNotes
Endpointhttp:// or https:// only. $ENV_VAR supported.
MethodPOST, PUT or PATCH. Webhook only.
SecretProduces X-Bandage-Signature: sha256=…, an HMAC over the exact bytes sent.
FieldsSend only these value paths. Empty sends everything.
Include metadataIP address and user agent. Off by default.
Include spamOff — which is rather the point of flagging it.
Timeout160 seconds, default 10.
Attempts110, default 3.

Only http(s) is accepted, and that is a security boundary rather than tidiness: a file:// endpoint handed to cURL is a request-forgery primitive with a settings screen in front of it, and whoever can reach that screen is not necessarily the person who owns the server.

The digest

Off by default. Daily or weekly, to whichever addresses you name.

It 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.

Craft has no scheduler, so it needs a cron entry:

php craft bandage/digest/send

Next

  • Usage — Twig, conditions, routing, exports, events
  • Troubleshooting — when a message doesn't arrive