Skip to content

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:

ssh <your-mastodon-host>
sudo -u postgres psql mastodon_production

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.
  • totalUsers vs activeMonth → 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.