Building BandTools: Merge Tags
14 September 2026
In the previous BandTools post I wrote about a model concern that protects singleton records. This time the subject is something musicians actually type: merge tags, the placeholders that turn a newsletter template into a message with the sender’s name, links, and social profiles filled in.
If you have ever used Mailchimp, the syntax will look familiar. In BandTools you write *|USER:NAME|* in the body of a newsletter or a confirmation email, and the application replaces it with the account holder’s name when the message is rendered. There are tags for the public profile URL, a mailto link, the newsletter name and description, the archive URL, and a social link for every platform the musician has configured. The message subject is available as *|MSG:SUBJECT|*.
The replacement lives in a single class, MergeTag. It is not a Rails concern this time: just a registry of tag names mapped to procs, plus a replace method that walks the text. The built-in tags look like this:
TAGS = {
"USER:NAME" => ->(user, _, _) { user.name },
"USER:EMAIL" => ->(user, _, _) {
mail_link(user.email_address)
},
"USER:URL" => ->(user, _, request) {
hyperlink(
"#{request.base_url}/u/#{user.username}"
)
},
"USER:NEWSLETTER_NAME" => ->(user, _, _) {
user.setting.newsletter_name
},
"USER:NEWSLETTER_DESCRIPTION" => ->(user, _, _) {
inline_html(user.setting.newsletter_description)
},
"USER:NEWSLETTER_ARCHIVE_URL" => ->(user, _, _) {
newsletter_archive_url(user)
},
"USER:WEBSITE_URL" => ->(user, _, _) {
hyperlink(user.website_url)
},
"MSG:SUBJECT" => ->(_, newsletter, _) {
newsletter.subject if newsletter
}
}.merge(SOCIAL_TAGS).freeze
Each proc receives the user, the newsletter, and the current request, and ignores whichever arguments it does not need. USER:NAME returns a plain string. USER:EMAIL and USER:URL return HTML links, marked html_safe so they are not escaped later. USER:NEWSLETTER_DESCRIPTION goes through a helper that unwraps Action Text HTML so a formatted description can sit inline in a sentence rather than breaking out into its own block.
The social tags are not listed by hand. They are built from the SocialLink platform registry, so adding a new platform automatically creates a merge tag for it:
SOCIAL_TAGS = SocialLink::PLATFORMS
.each_with_object({}) do |(platform, config), tags|
tags[config[:merge_tag]] = ->(user, _, _) {
url = user.social_link_url(platform)
url.present? ? hyperlink(url) : ""
}
end.freeze
If the musician has not set a Bandcamp or Instagram URL, the tag becomes an empty string rather than a broken link or a leftover placeholder. Unknown tags such as *|UNKNOWN:TAG|* are left untouched, because they are not in the registry at all.
The replace method is where the interesting details live:
def self.replace(text, user, newsletter, request)
TAGS.each do |tag, proc|
begin
value = proc.call(user, newsletter, request).to_s
unless value.html_safe?
value = ERB::Util.html_escape(value)
end
rescue StandardError
value = "[MISSING DATA - #{tag}]"
end
# The block form inserts values literally, including
# backslashes that gsub's replacement string would treat
# as backreferences.
text = text.gsub(/\*\|#{tag}\|\*/) { value }
end
text
end
Three decisions here are worth unpacking.
The first is HTML escaping. A display name is untrusted input. Dropping it raw into a newsletter would be a cross-site scripting hole if it contained markup. Plain values therefore go through ERB::Util.html_escape. Tags that already produce markup, such as a mailto link, are marked html_safe and skip that step so the angle brackets survive. The tests cover both sides: a name containing HTML is escaped, and USER:EMAIL still renders as a real anchor rather than an escaped tag.
The second is the rescue. If a proc raises, because a setting is missing or a helper is called with a half-built object, the reader still gets a message. The placeholder “[MISSING DATA - USER:NAME]” is ugly on purpose. A failed send because one tag blew up would be worse, and a silently blank name would be harder to debug.
The third is the block form of String#gsub. The replacement-string form treats backslashes as instructions: \1 is a backreference, not two characters. A value that happens to contain a backslash would be mangled. Passing a block inserts the string literally, which is what you want when the replacement comes from user data rather than from the regular expression itself.
One tag is gated on the account’s plan. Newsletter archives are a paid feature, so USER:NEWSLETTER_ARCHIVE_URL does not invent a URL the musician cannot offer:
def self.newsletter_archive_url(user)
if PlanFeatureMatrix.instance.has_feature?(
user, :newsletter_archive)
domain = user.newsletter_archive_domain
url = if domain && domain.enabled?
Rails.application.routes.url_helpers
.custom_domain_root_url(host: domain.hostname)
else
Rails.application.routes.url_helpers
.newsletters_url(username: user.username)
end
hyperlink(url)
else
"*|USER:NEWSLETTER_ARCHIVE_URL|*"
end
end
If the plan includes archives, the helper builds a link to the custom domain when one is enabled, or to the default archive path otherwise. If it does not, the tag is left in the text. A free-plan user who pastes the archive tag still sees the placeholder in preview, rather than a URL that would 404 for their subscribers.
The behaviour is covered by a focused test suite. One example asserts that a proc which raises becomes a visible placeholder. Another asserts that the archive tag stays put when the feature is off:
test "returns [MISSING DATA] if tag raises an exception" do
bad_user = Object.new
input = "*|USER:NAME|* *|USER:EMAIL|*"
output = MergeTag.replace(input, bad_user, nil, @request)
assert_equal "[MISSING DATA - USER:NAME] [MISSING DATA - USER:EMAIL]", output
end
test "falls back to tag text if user lacks newsletter_archive feature" do
plan = @free_user.billing_subscription.plan
plan.stub(:feature, false) do
input = "*|USER:NEWSLETTER_ARCHIVE_URL|*"
output = MergeTag.replace(input, @free_user, @newsletter, @request)
assert_equal "*|USER:NEWSLETTER_ARCHIVE_URL|*", output
end
end
MergeTag.replace is substitution, not the last word on HTML. After the tags have been filled in, rendering goes through a shared helper that converts Action Text attachments and applies an allowlist to the completed markup. Those are separate jobs: the registry decides what each placeholder means, and the renderer decides what HTML is allowed to leave the application.
I like this design because adding a tag is adding a hash entry, and adding a social platform is adding a row to the SocialLink registry. The replace method stays small, the escaping rule is one line, and the tests can exercise each tag without standing up a mailer. For a feature musicians will paste into every newsletter, that is the kind of boring reliability you want.