updates.thunderbird.net (UTN)
What’s New and Donation Appeal pages are hosted on updates.thunderbird.net.
Setup
Install the dependencies
Follow the basic setup instructions in README.md. For compressed image assets, also run:
uv sync --group image
Run the dev server
uv run build-site.py --watch --updates --debug --enus
This builds for en-US only and rebuilds when you change site files. The output should look like:
Rendering updates in en-US only.
Building pages for en-US...
Updating website when templates, CSS, or JS are modified. Press Ctrl-C to end.
HTTP Server running on: http://localhost:8000
The default address is http://localhost:8000 (change with --port).
A Donations Appeal Page
Let’s look at the December 2024 Donation Appeal page as an example.
The source for this page is /sites/updates.thunderbird.net/thunderbird/128.0/dec24/index.html.
It is built from the following:
a Jinja template
styles from one or more compiled LESS files
high and normal resolution images in multiple formats: SVG, png, jpg, webp, and avif
If you are new to Jinja, read the “Template Designer Documentation” and keep it handy as you look through some of the existing whats-new and appeal pages.
Where to put files
For an appeal page whose URL path will be /en-US/thunderbird/128.0/dec24/, the files are organized as follows:
The Jinja template is /sites/updates.thunderbird.net/thunderbird/128.0/dec24/index.html
The LESS file is at /assets/less/appeals/dec24.less
SVGs and raster images (e.g, .png, .jpg) go in different directories:
SVGs go in
/media/svg/appeal/dec24Raster images go in
/media/img/thunderbird/appeal/dec24
(If this were a “what’s new” page, those paths would have been something like /media/svg/whatsnew/dec24 and /media/img/thunderbird/whatsnew/dec24.)
To generalize, we created the following directory paths for our files:
Type |
Location |
|---|---|
Jinja template |
|
SVGs |
|
Images |
|
In addition to following these conventions, some files (such as .less styles) require configuration. We’ll take a look at each file type in the remainder of this section.
Templates
Generally, Jinja templates will go somewhere inside the thunderbird or thunderbird-android folders. These are the folders that get rendered by our custom static site generator.
Each appeal or what’s new page will either be associated with a particular release or a particular project.
For example, if you were making a what’s new page for Thunderbird 400.0 ESR you would create your index.html at sites/updates.thunderbird.net/thunderbird/400.0esr/whats-new/index.html.
Appeals are associated with the most recent ESR; the December 2024 appeal is associated with the 128.0 ESR, which gives us the file path /sites/updates.thunderbird.net/thunderbird/128.0/dec24/index.html
Lastly, an appeal may be attached to a specific project. For example, the donation appeal for Thunderbird for Android goes in sites/updates.thunderbird.net/thunderbird-android/40.0/appeal.
Creating A New Page
A copy-and-pastable template is included in sites/updates.thunderbird.net/includes/_templates/basic-page.html.
Simply copy and paste that template to its new home. Once resettled, change the active_page Jinja variable at the top of the template to match your page name in kebab-case. For the December 2024 appeal, we specified the following:
{% set active_page = "appeal-dec24" %}
This will namespace your page for styling by giving you the class page-<active_page>. Additionally, the page_title and page_desc variables controls the rendered HTML page title and page description respectively.
The includes folder
The includes folder is used for jinja includes, extends, and macros.
The donation_button macro is one that we’ll look at more closely in a later section.
LESS files
Working with .less files involves two steps:
Creating the
.lessfile in the appropriate directoryTelling the build tool about the new
.lessfile.
Just like with templates and images, we follow a pattern. The December 2024 appeal .less file path is /assets/less/appeals/dec24.less
Once you’ve created this file, add an entry for it to the UPDATES_CSS dict in the settings.py file. This configures the build tool to compile your new .less file.
There should be several entries in UPDATES_CSS. Follow the established naming convention when adding a key; the value is the path to the new less file you’ve just created.
'appeal-jul26-style': ['less/appeals/jul26.less'],
Restart the dev server after editing settings.py.
Images
When exporting from Figma or Zeplin, export two versions: 1x and 2x resolution. Name them using the -high-res suffix (not the industry-standard @2x):
1x:
forest-roc.png2x:
forest-roc-high-res.png
Save both to the appropriate image directory (see Where to put files).
Creating compressed image assets
Run tools/compress_assets.py whenever you add or update .png/.jpg raster images. This is not needed for SVGs.
python tools/compress_assets.py -r -o media/img/thunderbird/appeal/mycoolappeal/
The -r flag searches recursively and -o overwrites previously compressed files.
Working with Jinja templates
Linking the compiled CSS
The compiled CSS filename is based on the key you added to the UPDATES_CSS dict. For example, a key of 'appeal-jul26-style' compiles to css/appeal-jul26-style.css. Link it in your template via the base_css block using the static() helper:
{% block base_css %}
<link href="{{ static('css/appeal-jul26-style.css') }}" rel="stylesheet" type="text/css"/>
{% endblock %}
Using the high_res_img() helper
The high_res_img() helper renders a <picture> element with automatic srcset for the -high-res variant. Pass the path to the 1x version – the helper finds the 2x version automatically.
Use the alt_formats parameter to let the browser use compressed formats (webp, avif) when available:
{{ high_res_img('thunderbird/appeal/dec24/forest-roc.png', {'alt': _('')}, alt_formats=('webp', 'avif')) }}
Localization
All user-facing text must be wrapped for translation using one of two methods:
_()for short strings:
<p class="closing-text">{{ _('The Thunderbird Team') }}</p>
{% trans %}blocks for longer text:
{% trans trimmed %}
Meet Thunderbird, the <strong>email and productivity</strong> app that maximizes your freedoms.
{% endtrans %}
HTML tags are allowed inside translatable strings but should be minimized – volunteer translators may accidentally break them. Always translate aria-label and alt text too:
<h1 id="appeal-heading" aria-label="{{ _('Help Keep Thunderbird Alive!') }}">
{{ _('Help Keep <span>Thunderbird Alive</span>') }}
</h1>
See l10n_tools/readme.md for extracting strings for translation.
Accessibility
Use aria-label on interactive elements whose purpose isn’t obvious from their text (e.g. icon-only buttons):
<a id="donate-footer" class="btn btn-no-bg" aria-label="{{ _('Donate') }}"
href="{{ donate_url(...) }}">
<span aria-hidden="true" class="heart-svg">{{ svg('donate-heart') }}</span>
{{ _('Donate') }}
</a>
Use aria-hidden="true" and empty alt on purely decorative images. Add meaningful alt text to all other images.
Test with a screen reader (macOS: VoiceOver; Linux: Orca). If the reader doesn’t pause between sentences, add a period.
Updating the baked appeal redirect
Thunderbird uses a static appeal URL at updates.thunderbird.net/thunderbird/appeal. This is controlled by UPDATES_REDIRECTS in settings.py. When your appeal is ready to go live, update the ('thunderbird', 'appeal') key to point to the new appeal’s URL key.
Keys are tuples based on the URL path: updates.thunderbird.net/thunderbird/release/sep25r becomes ('thunderbird', 'release', 'sep25r'). Values are dot-separated URL keys: updates.release.appeal.sep25r.
A/B Testing Appeal Variants
Rather than duplicating templates for each variant, appeal templates use Jinja2 block inheritance. A variant only contains the parts that differ.
How it works
The base template (typically the “a” variant) wraps variable content in named blocks ({% block appeal_headline %}, {% block appeal_body %}). Variants {% extends %} the base and override only those blocks. Everything else is inherited.
The starter template already has these blocks.
Creating an A/B variant
Example: campaign jul26a exists, you want a jul26b variant with a different headline.
1. Ensure the base template has blocks
If you copied from the starter template, the blocks are already there. Otherwise, wrap the relevant sections:
{# jul26a/index.html -- the base template #}
{% block content %}
<section id="appeal-body">
{% block appeal_headline %}
<h1 id="appeal-heading" aria-label="{{ _('Original Headline') }}">
{{ _('Original <span>Headline</span>') }}
</h1>
{% endblock %}
<div id="appeal-letter" class="letter-container font-xl">
<section id="donate-button-container">
{{ donate_button(...) }}
</section>
<div id="letter-contents">
{% block appeal_body %}
<p>{{ _('Body text here.') }}</p>
{% endblock %}
{# The heart SVG, closing text, etc. live outside the block -- variants inherit them. #}
<div class="heart-container">...</div>
<p class="closing-text">{{ _('The Thunderbird Team') }}</p>
</div>
</div>
</section>
{% endblock %}
2. Create the variant file
jul26b/index.html only sets its UTM parameters and overrides the blocks that differ:
{# jul26b/index.html -- only the headline differs #}
{% set utm_content = utm_content|default('jul26b') %}
{% set donation_base_url = donation_base_url|default(url('updates.140.appeal.jul26b.donate')) %}
{% extends "thunderbird/140.0/jul26a/index.html" %}
{% block page_title %}{{ _('Alternative Headline') }}{% endblock %}
{% block appeal_headline %}
<h1 id="appeal-heading" aria-label="{{ _('Alternative Headline') }}">
{{ _('Alternative <span>Headline</span>') }}
</h1>
{% endblock %}
Override appeal_body as well if the body copy differs. If only the UTM content changes, the file is even shorter – just set the variables and extend the base.
3. Register the variant in settings.py
Add the template path to APPEAL_DONATE_PAGES:
APPEAL_DONATE_PAGES = [
...
'thunderbird/140.0/jul26a/index.html',
'thunderbird/140.0/jul26b/index.html', # <-- add this
]
This single list drives everything: URL mappings are derived automatically (e.g. updates.140.appeal.jul26b and updates.140.appeal.jul26b.donate), and the builder auto-generates the /donate/ subpage. You do not need to create jul26b/donate/index.html manually.
Restart the dev server after editing settings.py.
4. Pick the winner
Once testing is complete, update UPDATES_REDIRECTS in settings.py to point the canonical appeal URL at the winning variant:
UPDATES_REDIRECTS = {
('thunderbird', 'appeal'): 'updates.140.appeal.jul26b',
...
}
Block reference
Block |
Wraps |
Override when… |
|---|---|---|
|
HTML |
Variant has a different headline |
|
The |
Testing different headlines |
|
Body paragraphs |
Testing different copy |
|
CSS |
Variant uses a different stylesheet |
|
Header area (illustration, gradient) |
Fundamentally different layout |
Most A/B tests only need page_title and appeal_headline. If a variant has a completely different layout, make it a standalone template extending includes/base/base.html directly.
How donate subpages work
Every appeal has a companion /donate/ subpage. The appeal page (loaded inside Thunderbird) links to /donate/, which opens in the user’s browser with the FundraiseUp modal active.
For every template in APPEAL_DONATE_PAGES, the builder auto-generates the donate subpage by re-rendering the appeal template with:
donation_base_url = None– triggers the FRU modal in-page instead of linking outdisable_donation_blocked_notice = False– shows the blocked-donation fallback notice
No manual donate files needed. A few pre-2025 legacy appeals (115.0/nov24, 115.0/dec24, 128.0/nov24, 128.0/dec24) still use hand-written donate files.
Examples
Variant |
Relationship |
File |
|---|---|---|
|
Base template |
|
|
Extends 2a, overrides headline |
|
|
Extends 2a, overrides headline + body |
|
|
Extends nov25b, changes only UTM params |
|