majorwiki/02-selfhosting/services/mastodon-mention-spam-crowdfunding.md
MajorLinux 2def4c6f30 wiki: add Mastodon crowdfunding/mention-spam triage runbook
Runbook for telling broadcast fundraising solicitation from genuine
mentions: signal checklist, SQL to investigate the account and its
origin instance via nodeinfo, BlockService snippet, and a proportionate
escalation ladder (mute -> block -> report -> domain-limit -> domain-block).
Registered in SUMMARY.md and the self-hosting section index.
2026-06-22 13:49:35 -04:00

8.8 KiB

title description tags created updated
Mastodon — Triaging Crowdfunding / Mention-Spam Accounts How to tell broadcast fundraising solicitation from genuine mentions, investigate the account and its origin instance with SQL + nodeinfo, and pick a proportionate moderation action.
mastodon
moderation
abuse
federation
self-hosting
2026-06-22 2026-06-22

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

[!warning] 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

[!tip] 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.

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.