Mastodon — Triaging Crowdfunding / Mention-Spam Accounts¶
If you run a Mastodon instance, sooner or later you (or your users) start getting tagged by accounts you've never interacted with, posting donation appeals with a link and a wall of hashtags. Some are real people in desperate situations; some are recycled-link scams. Either way, when an account is broadcasting a solicitation at you rather than replying to you, it's a moderation question, not a conversation.
This article is the runbook for telling the two apart, investigating both the account and its origin instance, and choosing an action that's proportionate instead of nuking eight years of legit federation over two bad actors.
TL;DR¶
- A mention is broadcast spam, not engagement, when it's a standalone post (not a reply) that tags a large fixed list of accounts and carries a donation link, usually from a throwaway profile on an open-registration instance.
- Investigate before acting: pull the account's age/stats/bio and check whether the post is a reply or a 40-way blast (SQL below). Profile the origin instance via its public
nodeinfo. - Default action is an account-level block, which also federates and removes their follow of you. Escalate to domain-limit / domain-block only when one instance produces repeat offenders.
- Keep a log so single incidents that are actually a pattern become visible.
Signals that a mention is broadcast solicitation¶
Score it on how many of these hold:
| Signal | Why it matters |
|---|---|
Standalone post, not a reply (in_reply_to_account_id IS NULL) but still tags you |
They're broadcasting, not responding |
| Tags a large fixed recipient list (e.g. 40+) | Mass distribution; the same list reused across senders = coordination |
Donation link in post or bio (chuffed.org, gofundme, paypal.me, ko-fi) |
The payload |
| Throwaway profile — days old, few followers, follows you but you don't follow back | Disposable, baiting a profile view |
| Mass-follow ratio — following thousands / few hundred followers | Engagement farming |
| "I am not a scammer" disclaimer in bio | Known red-flag phrase |
| Origin instance: open registration, no approval | Easy throwaway-account farm |
Judgment, not a purity test
Many of these accounts are real people. The goal is not to adjudicate need — it's to stop broadcast solicitation aimed at you and track the source instances. Prefer the lightest action that stops it.
Investigate the account¶
Connect to the DB on the instance:
Profile + stats for a suspect (age, post count, follower ratio, bio):
SELECT a.username||'@'||a.domain,
to_char(a.created_at,'YYYY-MM-DD') AS first_seen_locally,
st.statuses_count, st.followers_count, st.following_count,
left(regexp_replace(COALESCE(a.note,''),'<[^>]+>','','g'),200) AS bio
FROM accounts a LEFT JOIN account_stats st ON st.account_id=a.id
WHERE a.domain='<INSTANCE>' AND a.username='<HANDLE>';
Is the mention a reply or a blast? standalone=t with a high num_tagged is the tell:
SELECT a.username, to_char(s.created_at,'YYYY-MM-DD HH24:MI') AS posted,
s.in_reply_to_account_id IS NULL AS standalone,
(SELECT count(*) FROM mentions mm WHERE mm.status_id=s.id) AS num_tagged
FROM mentions m JOIN statuses s ON s.id=m.status_id
JOIN accounts a ON a.id=s.account_id
JOIN accounts me ON me.id=m.account_id AND me.username='<YOU>' AND me.domain IS NULL
WHERE a.username='<HANDLE>' AND a.domain='<INSTANCE>'
ORDER BY s.created_at DESC;
All recent direct mentions of you (sweep for the wider pattern):
SELECT to_char(n.created_at,'YYYY-MM-DD HH24:MI') AS when,
a.username||COALESCE('@'||a.domain,'@local') AS who,
COALESCE(s.uri,'') AS uri,
left(regexp_replace(COALESCE(s.text,''),'<[^>]+>','','g'),200) AS body
FROM notifications n
JOIN accounts recip ON recip.id=n.account_id AND recip.username='<YOU>' AND recip.domain IS NULL
JOIN accounts a ON a.id=n.from_account_id
LEFT JOIN mentions m ON m.id=n.activity_id AND n.activity_type='Mention'
LEFT JOIN statuses s ON s.id=m.status_id
WHERE n.type='mention' ORDER BY n.created_at DESC LIMIT 40;
Profile the origin instance¶
Don't judge an instance by one bad account. Pull its public metadata — no auth needed:
# Software, version, user counts, registration policy
NI=$(curl -s https://<INSTANCE>/.well-known/nodeinfo | python3 -c 'import sys,json;print(json.load(sys.stdin)["links"][-1]["href"])')
curl -s "$NI" | python3 -m json.tool # software, openRegistrations, usage.users
# Title, contact/admin, rules, registration approval flag
curl -s https://<INSTANCE>/api/v2/instance | python3 -m json.tool
What to read off it:
openRegistrations: true+approval_required: false→ throwaway-account farm; expect more of the same.totalUsersvsactiveMonth→ a huge dormant base is typical of sign-up-and-leave farms.- Federation age on your side — how long you've known the instance, how many of its accounts you cache. A long, broad relationship argues against a domain block.
- The instance's own rules — many ban "backlink accounts" / harassment, which the mass-tag fundraising violates. That makes reporting to its admin a legitimate, in-policy path.
-- What your instance already knows about the domain
SELECT (SELECT count(*) FROM accounts WHERE domain='<INSTANCE>') AS known_accounts,
(SELECT count(*) FROM statuses s JOIN accounts a ON a.id=s.account_id WHERE a.domain='<INSTANCE>') AS cached_statuses,
(SELECT to_char(min(created_at),'YYYY-MM-DD') FROM accounts WHERE domain='<INSTANCE>') AS first_seen,
(SELECT count(*) FROM domain_blocks WHERE domain='<INSTANCE>') AS is_domain_blocked;
The escalation ladder¶
| Level | Action | Effect | When |
|---|---|---|---|
| 1 | Mute | You stop seeing them; silent | Borderline; you don't want to cut them off |
| 2 | Block (account) | Cuts mentions, removes their follow, federates to their instance | Default first action |
| 3 | Report to source admin | Forwards the offending posts to their moderators | Repeat or egregious; in-policy on most instances |
| 4 | Domain-limit (silence) | Their posts show only if you follow that account | One instance, multiple offenders |
| 5 | Domain-block (suspend) | Severs all known accounts + federation | Instance is predominantly abuse |
Blocking from a user account (federates + removes follow)¶
There is no tootctl accounts block. Do it through the model's BlockService so it tears down the relationship and federates correctly:
# run as the mastodon user:
# sudo -u mastodon bash -c 'cd /home/mastodon/live && RAILS_ENV=production bin/rails runner /tmp/block.rb'
me = Account.find_by(username: "<YOU>", domain: nil)
%w[Handle1 Handle2].each do |u|
t = Account.find_by(username: u, domain: "<INSTANCE>")
next puts("NOTFOUND #{u}") if t.nil?
BlockService.new.call(me, t)
puts "BLOCKED #{u} blocking=#{me.blocking?(t)} they_follow_me=#{t.following?(me)}"
end
blocking=true with they_follow_me=false confirms the block landed and the follow was severed.
Instance-level actions¶
Domain-limit / domain-block live in the admin UI (Moderation → Federation) or via tootctl:
# Silence (limit) — posts hidden unless followed
RAILS_ENV=production bin/tootctl domains ... # or set severity=silence in the admin UI
# Suspend (block) the whole instance
RAILS_ENV=production bin/tootctl ... # admin UI "Add domain block" is the safe path
Reach for the lightest hammer
A domain block is rarely the right first move against an established instance — you lose every legit account and years of federation to swat a couple of accounts. Block the accounts, report them to the source admin, and only escalate the instance when it demonstrates a sustained, multi-actor pattern.
Automate the watch¶
Triaging by hand only works if you remember to look. A small cron job on the instance itself turns this into an exception report — silent on quiet days, one email when something trips.
Run it on the Mastodon host, not a laptop: it's already always-on, it can query its own Postgres locally (no SSH hop, no key material in the loop), and it almost certainly already has working outbound mail from logwatch/fail2ban.
Detection criteria — flag a mention when all hold:
- standalone (
in_reply_to_account_id IS NULL) — replies are nearly always genuine - tags >= N accounts (5 is a reasonable floor; raise it if legit group threads trip it)
- sender is not already blocked
- sender's instance is not already domain-blocked/limited
Report the account age, post count, follower/following ratio, and a donation-link heuristic matched against bio and profile fields (many accounts put the link in a field, not the bio):
CASE WHEN COALESCE(a.note,'') || COALESCE(a.fields::text,'')
~* '(chuffed|gofundme|gofund[.]me|paypal[.]me|ko-fi|kofi[.]com|givesendgo|cash[.]app|venmo)'
THEN 'YES' ELSE '-' END AS donation_link
Wire it up as /etc/cron.d/<name> running daily, and have the script exit silently when the count
is zero so it generates no routine noise.
Blocking deletes the notification rows
BlockService purges the blocked account's rows from notifications. An account you actioned
disappears from these queries entirely — it does not linger as "handled". Two consequences:
the "already blocked" filter is belt-and-braces rather than the real mechanism, and you cannot
re-run yesterday's query to audit what you blocked. Log offenders at the time you act, or the
evidence is gone.
Prove the positive path before you trust it
A conditional alert script that exits cleanly on a quiet system has proven nothing — you only
exercised the branch that was always going to work. Force a match (drop a filter, lower the
threshold), confirm the report renders, then do a real send and check the mail log for
status=sent. Two bugs that specifically break the positive branch while leaving the quiet
path perfect: set -e aborting on a false && list, and psql not interpolating :vars inside
-c. Both are written up in
Alert Script Never Emails.
"Open registration" is not one thing
The throwaway-farm signal is specifically open registration with no approval. An instance
that is open but approval-required (and reason-required) is not handing out disposable
accounts, so that particular justification for a domain-level action does not apply — check
registrations.enabled and registrations.approval_required in /api/v2/instance before
citing it. A repeat-offender pattern can still justify escalation on its own; just be honest
about which rationale you are actually relying on.
Expect the signal mix to drift, too. Early offenders are often days-old throwaways; later ones may be established accounts with months of history and real posting activity. When that happens the throwaway/profile-age signals stop firing and the durable signal is the behaviour: an unsolicited standalone post mass-tagging a copied recipient list.
Keep a log¶
Track offenders and source instances over time so a "one-off" that's actually a campaign becomes visible, and so domain-level decisions are evidence-based. A simple table — date, account, instance, signals, action — plus an instance-watch table with each source's registration policy and offender count is enough.