Software Journal
Web Development Updated Sep 2, 2026 17 min read

MealDB Explorer: Flask Recipe App with TheMealDB API

Explore how MealDB Explorer builds a Flask recipe app with TheMealDB API — search, filter by ingredient, category, area, and random meals with Python requests.

Portrait of Benmalek Zohir

Benmalek Zohir

Contributor

Share
MealDB Explorer: Flask Recipe App with TheMealDB API

MealDB Explorer: Flask Recipe App with TheMealDB API

A verified deep-dive into azario0/mealdb-explorer — a beautiful Flask application that integrates with the TheMealDB API to search meals by name, browse alphabetically, filter by ingredient, category or area, and discover random recipes, all from a single Python file.

If you searched for “Flask TheMealDB API tutorial,” “Python recipe app with TheMealDB,” “Flask render_template_string example,” or “how to build a meal search app with Flask and requests,” this guide breaks down a working implementation file-by-file — every claim verified against the cloned repository.

What Is MealDB Explorer?

MealDB Explorer is a responsive, glassmorphism-styled web app defined almost entirely in app.py (686 lines). It wraps TheMealDB’s free JSON API (https://www.themealdb.com/api/json/v1/1/) behind a clean Flask navigation bar:

  • Home — welcome overview
  • Search Meal — free-text name search
  • List by Letter — alphabetical browsing A-Z
  • Random Meal — surprise discovery
  • Filter by Ingredient / Category / Area — targeted exploration

All rendering is server-side with no separate frontend build step and no database — perfect for beginners learning Flask API integration and for developers wanting a minimal example of consuming a third-party REST API with Python.

Programming Languages Used

This is a polyglot but Python-centric project:

LanguageRoleWhere
PythonFlask server, TheMealDB API fetching, routing, Jinja2 context preparationapp.py, notebook.ipynb
HTML (Jinja2)Single massive HTML_TEMPLATE string (560 lines) rendered via render_template_string — includes conditional blocks for forms and meal layoutsapp.py#L9-L568
CSS (embedded)Gradient background, glassmorphism cards, responsive grid, animations — inside the same <style> blockapp.py#L18-L354
JavaScript (embedded)Two progressive-enhancement handlers: form submit loading state and meal-card hover liftapp.py#L539-L565
Jupyter Notebook (IPython)Prototyping TheMealDB calls and HTML display helpers before Flask integrationnotebook.ipynb

No TypeScript, no frontend bundler, no ORM. Python drives everything; HTML/CSS/JS are inlined for zero-config deployment.

Technology Stack at a Glance

LayerTechnologyPurposeVerified In
Web FrameworkFlask (Flask, render_template_string, request, jsonify unused but imported)HTTP routing + server-side renderingapp.py#L1, app.py#L587
HTTP ClientrequestsGET https://www.themealdb.com/api/json/v1/1/{endpoint} with raise_for_status() + JSON decodingapp.py#L2, app.py#L572-L574
External APITheMealDB (free, no auth)6 endpoints: search.php?s=, search.php?f=, random.php, filter.php?i=, c=, a=app.py#L6, README.md#api-integration
TemplatingJinja2 via Flask{% if data.meals[0].strInstructions %} branching for detailed vs grid cardsapp.py#L460-L518
StylingEmbedded CSS + Google Fonts (Roboto) + Font Awesome 6Glassmorphism, gradients, responsive grid, animationsapp.py#L16-L17, app.py#L18-L354
PrototypingJupyter + IPython.display HTML/ImageInteractive fetch_api_data + display_meal_* helpersnotebook.ipynb

Installation: README.md instructs pip install flask requests — both imports are at the top of app.py#L1-L2. No requirements.txt is committed (the structure doc mentions it as optional).

Project Structure

Verified with ls -R (2 commits, main branch):

mealdb-explorer/
├── app.py              # 686-line monolith: Flask app + HTML_TEMPLATE + routes
├── notebook.ipynb      # 457-line prototype: 4 code cells + 3 demo execution cells
├── README.md           # 172-line docs: features, quick start, API endpoints, roadmap
└── LICENSE             # MIT

Note the intentional simplicity: one Python file is the entire application. app.py holds both backend logic and frontend template — no templates/ or static/ directories. This makes deployment trivial (python app.py) but trades off maintainability.

Architecture Overview — How the Pieces Fit Together

Browser
  │
  ├─► GET /                              ──► home() @ app.py#L587 renders HTML_TEMPLATE with welcome message
  ├─► GET /search_meal_by_name_form     ──► shows form (show_search_meal_by_name_form=True) @L593
  │       └─► GET /search_meal_by_name?name=Arrabiata ──► fetch_data("search.php", {"s": name}) @L631 ──► TheMealDB ──► render grid or detail
  ├─► GET /list_meals_by_first_letter_form              ──► form @L599
  │       └─► GET /list_meals_by_first_letter?letter=a ──► fetch_data("search.php", {"f": letter}) @L641
  ├─► GET /random_meal                   ──► fetch_data("random.php") @L646 → always detailed view
  ├─► GET /filter_by_main_ingredient_form               ──► form @L605
  │       └─► GET /filter_by_main_ingredient?ingredient=chicken_breast ──► fetch_data("filter.php", {"i": ...}) @L656
  ├─► GET /filter_by_category_form                      ──► form @L611
  │       └─► GET /filter_by_category?category=Seafood ──► fetch_data("filter.php", {"c": ...}) @L668
  └─► GET /filter_by_area_form                          ──► form @L618
          └─► GET /filter_by_area?area=Italian         ──► fetch_data("filter.php", {"a": ...}) @L680

fetch_data() @L570: requests.get(BASE_URL + endpoint, params) → raise_for_status() → response.json()
                  → on RequestException → {"error": str(e), "status_code": ...}
                  → on ValueError → {"error": "Failed to decode JSON..."}

Two route families:

  1. Form routes (*_form, no external call) — render HTML_TEMPLATE with a boolean like show_search_meal_by_name_form=True to display the correct <form> and instructional message (see Form Routes Deep Dive).
  2. API routes (/search_meal_by_name, etc.) — call fetch_data(), then render the same template with data=data and a dynamic title (see API Routes Deep Dive).

The template decides what to show via cascading Jinja conditionals at app.py#L376-L535.

Deep Dive 1: The Flask App & Inline Template — A Single-File Monolith

Imports & App Setup — app.py#L1-L6

from flask import Flask, jsonify, render_template_string, request
import requests
app = Flask(__name__)
BASE_URL = "https://www.themealdb.com/api/json/v1/1/"
  • render_template_string (not render_template) is used because the template is a Python string constant, not a file in templates/ — a deliberate single-file design.
  • jsonify is imported at L1 but never used — likely leftover from prototyping.
  • BASE_URL at L6 is the free TheMealDB tier 1 (no API key). The port is 5001 at L686 to avoid colliding with common Flask defaults on 5000.

HTML_TEMPLATE — app.py#L9-L568 (560 lines)

This is not a typical Flask pattern but an effective teaching choice:

  • Head: Google Fonts Roboto + Font Awesome 6.0 at L16-L17, responsive viewport meta.
  • Embedded CSS — Glassmorphism & Gradients:
    • Body: linear-gradient(135deg, #667eea → #764ba2) at L27
    • Top bar / nav / content-box: rgba(255,255,255,0.95) + backdrop-filter: blur(10px) at L34-L42, L55-L61, L96-L103 — the frosted-glass effect highlighted in README.md#design-features.
    • Meal cards: meals-grid uses grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)) at L117 for adaptive columns; .meal-card:hover lifts translateY(-5px) + shadow at L131-L134 and image scale(1.05) at L143.
    • Detail view: meal-detail is a grid-template-columns: 1fr 1fr at L182-L186 collapsing to 1fr on mobile via @media (max-width: 768px) at L326-L343.
    • Animation: fadeIn keyframes (opacity + translateY) at L346-L353 applied to .meal-card.
  • Top Bar & Nav: TheMealDB Explorer title with gradient text + background-clip: text at L48-L52; seven nav links at L360-L369 (/, /search_meal_by_name_form, /list_meals_by_first_letter_form, /random_meal, /filter_by_main_ingredient_form, /filter_by_category_form, /filter_by_area_form) styled as pill buttons with hover gradient at L82-L87.
  • Embedded JS — Progressive Enhancement at L539-L565:
    form.addEventListener('submit', () => submitBtn.value='🔍 Searching...' ; submitBtn.disabled=true)
    mealCards.forEach(card => card.addEventListener('mouseenter', () => transform='translateY(-8px) scale(1.02)'))
    The first handler gives instant loading feedback (no spinner, just button text swap); the second enhances the CSS hover with a JS scale for smoother interaction. Both are wrapped in DOMContentLoaded.

Trade-off: Embedding everything in HTML_TEMPLATE makes the app copy-paste runnable (no templates/ dir needed) but at 560 lines the string dominates app.py and hampers syntax highlighting — a refactor would split it into templates/base.html.

Deep Dive 2: Core Data Fetcher — The Single API Gateway

fetch_data(endpoint, params=None) — app.py#L570-L585

def fetch_data(endpoint, params=None):
    try:
        response = requests.get(f"{BASE_URL}{endpoint}", params=params)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        status_code = 'N/A'
        if hasattr(e, 'response') and e.response is not None:
            status_code = e.response.status_code
        return {"error": str(e), "status_code": status_code}
    except ValueError:
        content = 'No content returned'
        if hasattr(response, 'text'):
            content = response.text
        return {"error": "Failed to decode JSON from response.", "content": content}
  • URL construction: f"{BASE_URL}{endpoint}" with params dict — requests handles URL-encoding (crucial for chicken_breast → stays underscored, but Chicken Curry → Chicken%20Curry automatically).
  • HTTP errors: raise_for_status() raises HTTPError (subclass of RequestException) — caught in the first except at L575. It tries to extract e.response.status_code (if the server responded, e.g., 404 for unknown endpoint vs N/A for DNS failure).
  • JSON errors: ValueError at L581 covers response.json() failures — returns content for debugging (mirrors show_search_meal_by_name_form error display in the template at L452-L455 which shows Status: when present).
  • Normalized error shape: Always returns {"error": ..., "status_code": ...} or {"error": ...} — the template’s {% if data.error %} at L451 relies on this contract. On success, it returns the raw TheMealDB JSON (e.g., {"meals": [{...}, ...]} or {"meals": null} for no results).

Compare to the notebook’s fetch_api_data() at notebook.ipynb#L20-L52 — the Flask version is a streamlined copy: notebook handles HTTPError separately with pretty json.dumps logging, while the Flask version collapses all RequestException subtypes for brevity.

Deep Dive 3: Form Routes — Instructional Pages

These six routes render forms without calling the API. Each passes a unique boolean flag that the template checks:

RouteHandlerFlagMessage Example
GET /home()none"Discover amazing recipes..."
GET /search_meal_by_name_formsearch_meal_by_name_form()show_search_meal_by_name_form=True"Enter a meal name..."
GET /list_meals_by_first_letter_formlist_meals_by_first_letter_form()show_list_meals_by_first_letter_form=True"Browse meals alphabetically..."
GET /filter_by_main_ingredient_formfilter_by_main_ingredient_form()show_filter_by_main_ingredient_form=True"Find meals that use your favorite ingredient!"
GET /filter_by_category_formfilter_by_category_form()show_filter_by_category_form=True"Explore meals by category..."
GET /filter_by_area_formfilter_by_area_form()show_filter_by_area_form=True"Discover authentic cuisine..."

Example — app.py#L593-L597:

@app.route('/search_meal_by_name_form')
def search_meal_by_name_form():
    return render_template_string(HTML_TEMPLATE, title="🔍 Search Meal by Name", 
                                show_search_meal_by_name_form=True, 
                                message="Enter a meal name to find delicious recipes!")

The template renders the correct form via conditionals like {% if show_search_meal_by_name_form %} at L380-L392 (Search), L394-L406 (Letter), etc. Each form uses method="GET" + action="/search_meal_by_name" with a required text input and maxlength="1" for the letter form at L399.

Deep Dive 4: API Routes — TheMealDB Integration

All six data routes follow the same validate-then-fetch-then-render pattern:

1. search_meal_by_name — L624-L632

@app.route('/search_meal_by_name')
def search_meal_by_name():
    meal_name = request.args.get('name')
    if not meal_name:
        return render_template_string(HTML_TEMPLATE, title="🔍 Search Meal by Name", 
                                    message="Error: Meal name is required.", 
                                    show_search_meal_by_name_form=True)
    data = fetch_data("search.php", params={"s": meal_name})
    return render_template_string(HTML_TEMPLATE, title=f"🔍 Search Results for: {meal_name}", data=data)
  • Query param name → TheMealDB param s. Missing → re-render form with error message (not data.error).
  • On success, title is dynamic (Search Results for: Arrabiata) and data is passed to the template’s meal renderer.

2. list_meals_by_first_letter — L634-L642

if not first_letter or len(first_letter) != 1:
    return render_template_string(..., message="Error: A single first letter is required.", show_list_meals_by_first_letter_form=True)
data = fetch_data("search.php", params={"f": first_letter})
  • Validates len == 1 at L637 — prevents ?letter=ab from hitting the API (TheMealDB would ignore extra chars anyway).

3. random_meal — L644-L647

@app.route('/random_meal')
def random_meal():
    data = fetch_data("random.php")
    return render_template_string(HTML_TEMPLATE, title="🎲 Random Meal Discovery", data=data)
  • No params, always returns {"meals": [{fullDetail}]} with one element — used as the demo for the detailed card path (see Rendering Branching).

4. filter_by_main_ingredient — L649-L659

data = fetch_data("filter.php", params={"i": ingredient})
return render_template_string(..., title=f"🥕 Meals with {ingredient.replace('_', ' ').title()}", data=data)
  • .replace('_', ' ').title() at L658 turns chicken_breast → Chicken Breast for the title — a small UX polish. The API expects underscores.

5. filter_by_category — L661-L671 and 6. filter_by_area — L673-L683

Both mirror the ingredient pattern with params={"c": category} and params={"a": area} per TheMealDB API docs listed in README.md.

Common error handling: All API routes defer HTTP/JSON errors to fetch_data()’s {"error": ...} shape — the template at L451-L456 displays <div class="message error-message"> with status_code if present, so 500 or 403 from TheMealDB surfaces to the user without crashing Flask.

Deep Dive 5: Rendering Logic — Template Branching

The template’s core at app.py#L450-L535 decides among three states via nested Jinja:

1. Error State — L451-L456

{% if data.error %}
    <div class="message error-message">
        <i class="fas fa-exclamation-triangle"></i> {{ data.error }}
        {% if data.status_code %}(Status: {{ data.status_code }}){% endif %}
    </div>

2. Meals Found — L458-L518

The critical fork at L460:

{% if data.meals[0] and data.meals[0].strInstructions %}
    <!-- Full meal details -->
    {% for meal in data.meals %}
    <div class="meal-detail">
        <img src="{{ meal.strMealThumb }}" class="meal-detail-image">
        <div class="meal-detail-info">
            <h3>{{ meal.strMeal }}</h3>
            <span class="meal-category">{{ meal.strCategory }}</span>
            <div class="ingredients-list">
                {% for i in range(1, 21) %}
                    {% set ingredient = meal['strIngredient' + i|string] %}
                    {% set measure = meal['strMeasure' + i|string] %}
                    {% if ingredient and ingredient.strip() %}
                    <div class="ingredient-item">
                        <span>{{ ingredient }}</span><span>{{ measure }}</span>
                    </div>
                    {% endif %}
                {% endfor %}
            </div>
            <div class="instructions">{{ meal.strInstructions }}</div>
            <a href="{{ meal.strYoutube }}" class="video-link">Watch Video</a>
  • Why the check? TheMealDB’s filter.php (ingredient/category/area) returns lightweight meals (idMeal, strMeal, strMealThumb only) without strInstructions — while search.php and random.php return full details. The strInstructions presence at L460 is the discriminator.

  • Detailed path: Two-column grid at L463 with large image (meal-detail-image), category/area pills at L469-L470, strInstructions verbatim at L474, ingredient loop 1..20 at L479-L488 (TheMealDB stores 20 strIngredientN/strMeasureN pairs, many empty — the if ingredient.strip() guards), and strSource + strYoutube links at L491-L501.

  • Grid path: At L505-L518:

    {% else %}
        <div class="meals-grid">
            {% for meal in data.meals %}
            <div class="meal-card">
                <img src="{{ meal.strMealThumb }}" class="meal-image">
                <h3 class="meal-title">{{ meal.strMeal }}</h3>

    Shows lightweight cards (image + title + optional pills) — ideal for browsing dozens of results without overwhelming instructions.

3. No Results — L520-L525

{% elif data.meals is defined and data.meals == null %}
    <div class="no-results">
        <i class="fas fa-search"></i>
        <h3>No meals found</h3>
        <p>Try searching with different keywords...</p>
    </div>
  • TheMealDB returns {"meals": null} (JSON null, Jinja null) for zero matches — distinct from {"error": ...}. The template explicitly checks == null at L520, not just not data.meals, ensuring errors don’t masquerade as “no results.”

Additionally, top-level message handling at L376-L378 and L533-L534 shows instructional or validation errors (e.g., "Error: Meal name is required." from L629) with .error-message styling when the message contains Error:.

Deep Dive 6: Notebook Prototype — Interactive Discovery Lab

notebook.ipynb (457 lines, 4 code cells + 3 demo outputs) is the Flask app’s ancestor:

  • Configuration — Cell 0: BASE_URL = "https://www.themealdb.com/api/json/v1/1/" at notebook.ipynb#L17 — identical to app.py#L6.
  • Base Fetcher — fetch_api_data() at L20-L52: More verbose than Flask’s fetch_data() — separates HTTPError (with pretty json.dumps of details/raw_content) from RequestException and ValueError, with print() debugging. Flask collapses this for brevity.
  • Six Wrappers at L54-L93: search_meal_by_name(meal_name) (s), list_meals_by_first_letter(letter) (f with len==1 check), get_random_meal() (random.php), filter_by_main_ingredient(ingredient_name) (i), filter_by_category(category_name) (c), filter_by_area(area_name) (a) — exact parity with the six Flask API routes.
  • Display Helpers at L96-L204:
    • display_meal_summary_card(meal) at L98-L116 — 300px inline div with image + title (used for filter results).
    • display_detailed_meal_card(meal) at L118-L171 — 700px card with category/area badges, 1..20 ingredient loop (same logic as Flask’s L479), strInstructions with \n→<br>, YouTube/Source buttons, and Font Awesome CDN injection.
    • display_meal_list(data, title) at L173-L204 — flex-wrap wrapper, error/null/empty branching mirroring the Flask template.
  • Live Demos — Cells 2 & 3:
    • search_meal_by_name("Arrabiata") → Spicy Arrabiata Penne card + XyzNonExistentMeal → "No meals found..." at L290-L297
    • get_random_meal() → Cabbage Soup (Shchi) detailed card at L352-L360 (Russian, Vegetarian)
    • filter_by_area("Uruguayan") → Chivito uruguayo at L422-L425

The notebook’s HTML output is what’s ported into Flask’s HTML_TEMPLATE — but Flask’s version is more polished, adding grid, glassmorphism, and responsive breakpoints.

Complete Request Lifecycle — Search Example

User searches “Arrabiata”:

  1. GET /search_meal_by_name_form → search_meal_by_name_form() renders HTML_TEMPLATE with show_search_meal_by_name_form=True and the form at L381-L391.
  2. User enters Arrabiata → submits GET /search_meal_by_name?name=Arrabiata (form method="GET" at L382).
  3. search_meal_by_name() validates meal_name via request.args.get('name') at L626, then calls fetch_data("search.php", {"s": "Arrabiata"}) at L631.
  4. fetch_data at L572-L574 → requests.get("https://www.themealdb.com/api/json/v1/1/search.php?s=Arrabiata") → raise_for_status() → return {"meals": [{"strMeal":"Spicy Arrabiata Penne", "strMealThumb":"...", "strCategory":"Vegetarian", "strArea":"Italian", "strInstructions":"Bring a large pot...", "strIngredient1":"penne rigate", "strMeasure1":"1 pound", ...}]}
  5. Route renders render_template_string(HTML_TEMPLATE, title="🔍 Search Results for: Arrabiata", data=data) at L632.
  6. Template at L460 finds data.meals[0].strInstructions exists → enters detailed branch at L462-L504, loops 1..20 ingredients at L479, shows instructions + YouTube link.
  7. If user searched XyzNonExistentMeal, TheMealDB returns {"meals": null} → template matches L520 → No meals found message.
  8. If offline, fetch_data returns {"error": ..., "status_code": ...} → template shows error-message div at L452.

Installation, Running & Configuration

Verified against README.md:

git clone https://github.com/azario0/mealdb-explorer.git
cd mealdb-explorer
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate
pip install flask requests
python app.py
# → Running on http://127.0.0.1:5001 (debug=True @ L686)
# Open http://localhost:5001
  • Port: 5001 at app.py#L686 — change via app.run(debug=True, port=YOUR_PORT) if needed.
  • No env vars: Unlike sister projects (e.g., Gemini-based apps), this app needs no API key — TheMealDB free tier 1 is open. No .env required.
  • Notebook: Run jupyter notebook notebook.ipynb to test fetch_api_data() and display_meal_* helpers interactively before deploying Flask.

Customization & Extension Ideas

All from README.md#contributing + code inspection:

  • Add Favorites: Store session['favorites'] = [idMeal, ...] in Flask session (import session, add app.secret_key — not currently set). Render a /favorites page reusing the grid.
  • Split Template: Extract HTML_TEMPLATE at L9 into templates/base.html + templates/search.html and switch to render_template — restores editor highlighting and enables {% extends %}.
  • Lookup by ID: Add lookup.php?i={id} endpoint (TheMealDB supports it, not yet implemented) to allow clicking a grid card to see full details — the comment at L128 notes onclick was removed.
  • Debounce Search: Add client-side fetch() + input event to replace the full-page GET with an AJAX JSON endpoint (add @app.route('/api/search') returning jsonify(data)).
  • Theming: Expose BG_COLOR equivalents as CSS variables — the glassmorphism values at L34-L103 are hard-coded.
  • Error Pages: Add @app.errorhandler(404) with the same HTML_TEMPLATE wrapper — currently unhandled routes return Flask’s default 404.

Limitations & Trade-offs

Verified by reading code, not just docs:

  • Monolith: 560 lines of template inside a Python string at L9-L568 hampers maintainability and linting — but makes the repo beginner-friendly (clone → run).
  • GET for Mutations: All forms use method="GET" at L382 etc., leaking queries into URL history — POST would hide them but require CSRF handling.
  • No Caching or Rate-Limit Display: TheMealDB has no rate limit documented, but the app has no Cache-Control or retry — a 429 would surface only as Status: 429 in the error div.
  • jsonify Unused: Imported at L1 but never called — a JSON API variant (/api/*.json) would be trivial to add with it.
  • Ingredient Display Divergence: Notebook’s display_meal_summary_card at notebook.ipynb#L98 omits category/area for grid cards (commented out), while Flask’s grid at app.py#L513-L514 shows them — a subtle parity gap.

Conclusion — Why This Repo Is a Great Flask API Starter

If you want to learn Flask integration with a real REST API without the bloat, this project is a minimal, verified template:

  • Flask done simply: One file, two route families (form vs API), one fetch_data gateway — yet it handles six TheMealDB endpoints, validation, and graceful errors.
  • API done practically: requests.get + raise_for_status() + {"error": ...} normalization shows the healthy pattern for consuming any JSON API.
  • Rendering done pragmatically: The strInstructions fork at L460 hides TheMealDB’s two response shapes (full vs lightweight) behind a single UX.

Clone it, run pip install flask requests && python app.py, try Search Meal → Arrabiata, then Filter by Area → Italian, and finally Random Meal. From there, extract HTML_TEMPLATE into real templates, add session-based favorites, or wrap fetch_data with functools.lru_cache for instant repeat queries — the 12 routes are ready for it.


Links: app.py · notebook.ipynb · README.md · LICENSE · TheMealDB API Docs

Verified against commit main on 2026-09-02. All file paths, line numbers, parameter names (s/f/i/c/a), and rendering branches checked against the cloned repository.

Share
Portrait of Benmalek Zohir

Written by

Benmalek Zohir

Founder, AI Engineer & Full Stack Developer

Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.

The Software Journal Dispatch

One excellent engineering read, every week.

A concise digest of our best new essays on architecture, tooling, databases, and the craft of software. No spam, no noise — unsubscribe anytime.