diff --git a/InterlinedList/Services/InterlinedApiClient.Blog.cs b/InterlinedList/Services/InterlinedApiClient.Blog.cs
new file mode 100644
index 0000000..e910611
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.Blog.cs
@@ -0,0 +1,68 @@
+using System.Net.Http;
+
+namespace InterlinedList.Services;
+
+///
+/// Blog newsletter subscription.
+///
+///
+///
+/// There is no public blog-read endpoint. Verified live 2026-09-16:
+/// /api/blog, /api/blog/posts and /api/blog/list all
+/// return 404, and the only blog routes in the spec besides these
+/// subscription ones are /api/admin/blog* — admin-only and
+/// cookie-authed. So reading the blog is a browser handoff (#121); there is
+/// nothing to fetch, and scraping the website's HTML is not a substitute for
+/// an API.
+///
+///
+/// Unsubscribe is token-based, not email-based. Both
+/// GET and POST /api/blog/unsubscribe take a ?token=
+/// query parameter and nothing else — the token comes from the email footer
+/// (the POST form is the RFC-8058 one-click target). A client that only
+/// knows the user's address therefore cannot unsubscribe them, so that
+/// is a handoff too rather than a button that can't work.
+///
+///
+public sealed partial class InterlinedApiClient
+{
+ ///
+ /// Subscribe an address to the blog newsletter. Double opt-in — a
+ /// confirmation email follows, and the subscription isn't live until its
+ /// link is clicked.
+ ///
+ ///
+ /// Request shape is {email}. The server validates it:
+ /// an invalid address returns
+ /// 400 {"error":"A valid email is required","code":"bad_request"}
+ /// (verified live with not-an-email — no message was sent to anyone).
+ /// The success envelope is not verified: confirming it would mean
+ /// mailing a real address, so nothing is parsed from it.
+ ///
+ public Task SubscribeToBlogAsync(string email, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, "api/blog/subscribe", new { email }, ct);
+
+ ///
+ /// Complete a double opt-in confirmation from an emailed token.
+ ///
+ ///
+ /// Included for completeness; in practice the confirmation link is clicked
+ /// in the browser from the email, which is where it lands anyway.
+ ///
+ public Task ConfirmBlogSubscriptionAsync(string token, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Get, $"api/blog/subscribe/confirm?token={Uri.EscapeDataString(token)}", null, ct);
+
+ ///
+ /// Unsubscribe using a token from an email footer.
+ ///
+ ///
+ /// Only usable if the caller actually has the token — see the type-level
+ /// remarks. Do not surface this as "unsubscribe me"; the app has no way to
+ /// obtain the token.
+ ///
+ public Task UnsubscribeFromBlogAsync(string token, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/blog/unsubscribe?token={Uri.EscapeDataString(token)}", new { }, ct);
+
+ /// The blog's web address, for the browser handoff.
+ public static string BlogUrl => $"{ApiConfig.BaseUrl}blog";
+}
diff --git a/InterlinedList/ViewModels/BlogPanelViewModel.cs b/InterlinedList/ViewModels/BlogPanelViewModel.cs
new file mode 100644
index 0000000..7ad7e36
--- /dev/null
+++ b/InterlinedList/ViewModels/BlogPanelViewModel.cs
@@ -0,0 +1,94 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// Blog: read it in the browser, and subscribe to the newsletter.
+///
+///
+/// Reading is a handoff because there is no public blog-read endpoint (#121) —
+/// /api/blog, /api/blog/posts and /api/blog/list all 404,
+/// and authoring lives under admin-only cookie-authed routes. Unsubscribe is
+/// also a handoff, because the endpoint needs a token that only the email
+/// footer carries.
+///
+public partial class BlogPanelViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
+ private string email = "";
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
+ private bool isBusy;
+
+ [ObservableProperty]
+ private string? statusMessage;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ public BlogPanelViewModel(SessionService session)
+ {
+ _session = session;
+ // Pre-fill with the account address — the overwhelmingly likely choice.
+ email = session.CurrentUser?.Email ?? "";
+ }
+
+ private bool CanSubscribe() => !IsBusy && LooksLikeEmail(Email);
+
+ // Cheap client-side shape check only. The server is authoritative and
+ // returns 400 "A valid email is required"; this just avoids an obviously
+ // pointless round trip.
+ private static bool LooksLikeEmail(string value)
+ {
+ var trimmed = value.Trim();
+ var at = trimmed.IndexOf('@');
+ return at > 0
+ && at < trimmed.Length - 1
+ && trimmed.IndexOf('.', at) > at + 1
+ && !trimmed.Contains(' ');
+ }
+
+ [RelayCommand(CanExecute = nameof(CanSubscribe))]
+ private async Task SubscribeAsync()
+ {
+ IsBusy = true;
+ StatusMessage = null;
+ ErrorMessage = null;
+ try
+ {
+ await _session.Api.SubscribeToBlogAsync(Email.Trim());
+ // Double opt-in: say so, or the user will assume they're done.
+ StatusMessage = $"Check {Email.Trim()} for a confirmation link — "
+ + "the subscription isn't active until you click it.";
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ /// Open the blog in the OS browser — there is no read API to render.
+ [RelayCommand]
+ private void OpenBlog() => OpenInBrowser(InterlinedApiClient.BlogUrl);
+
+ private static void OpenInBrowser(string url)
+ {
+ // UseShellExecute is required for .NET Core+ to hand a URL to the OS
+ // default browser — same pattern as the OAuth handoff.
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = url,
+ UseShellExecute = true,
+ });
+ }
+}
diff --git a/InterlinedList/Views/BlogPanel.xaml b/InterlinedList/Views/BlogPanel.xaml
new file mode 100644
index 0000000..0941fd7
--- /dev/null
+++ b/InterlinedList/Views/BlogPanel.xaml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/BlogPanel.xaml.cs b/InterlinedList/Views/BlogPanel.xaml.cs
new file mode 100644
index 0000000..43ce4d1
--- /dev/null
+++ b/InterlinedList/Views/BlogPanel.xaml.cs
@@ -0,0 +1,21 @@
+using System.Windows.Controls;
+using InterlinedList.Services;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+///
+/// Blog reading (browser handoff) and newsletter subscription.
+///
+///
+/// Self-contained so it can be hosted with a one-line add — SettingsView
+/// is being reworked across several open PRs.
+///
+public partial class BlogPanel : UserControl
+{
+ public BlogPanel()
+ {
+ InitializeComponent();
+ DataContext = new BlogPanelViewModel(AppServices.Session);
+ }
+}