<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Alex Neamtu</title>
    <description>The latest articles on Forem by Alex Neamtu (@alexneamtu).</description>
    <link>https://forem.com/alexneamtu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F353763%2Fad2f8461-24ae-4014-8af9-f848d9cf4c5b.jpeg</url>
      <title>Forem: Alex Neamtu</title>
      <link>https://forem.com/alexneamtu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/alexneamtu"/>
    <language>en</language>
    <item>
      <title>GDPR-Compliant Screen Recording for Teams: What You Actually Need</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Tue, 31 Mar 2026 12:19:48 +0000</pubDate>
      <link>https://forem.com/alexneamtu/gdpr-compliant-screen-recording-for-teams-what-you-actually-need-p37</link>
      <guid>https://forem.com/alexneamtu/gdpr-compliant-screen-recording-for-teams-what-you-actually-need-p37</guid>
      <description>&lt;p&gt;Your team records screen videos every day — product demos, bug reports, training walkthroughs, client updates. Each one potentially contains personal data: email addresses visible in a dashboard, names in a CRM, analytics with user IPs, or even a Slack notification popping up mid-recording.&lt;/p&gt;

&lt;p&gt;Under GDPR, that makes your screen recording tool a data processor. And most teams don't think about this until legal asks the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GDPR actually requires from your video tools
&lt;/h2&gt;

&lt;p&gt;GDPR doesn't ban screen recording. It requires you to control where personal data goes, who processes it, and how long it's kept. For a screen recording tool, that means:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Data residency
&lt;/h3&gt;

&lt;p&gt;Where are your videos stored? If your tool uploads recordings to US servers, that data transfer needs a legal basis under GDPR. Since the Schrems II ruling invalidated the Privacy Shield framework, relying on Standard Contractual Clauses alone has become legally shaky — especially for video content that may contain sensitive personal data.&lt;/p&gt;

&lt;p&gt;The simplest approach: keep videos on EU infrastructure. No transfer, no legal gymnastics.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Sub-processors
&lt;/h3&gt;

&lt;p&gt;Your screen recording tool likely uses other services: cloud storage (AWS, GCP), CDN for delivery, AI for transcription, analytics for tracking. Each one is a sub-processor under GDPR. You need to know who they are and have appropriate agreements in place.&lt;/p&gt;

&lt;p&gt;Many popular tools have long sub-processor lists that include US-based companies. Each one is a potential compliance risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Data Processing Agreement (DPA)
&lt;/h3&gt;

&lt;p&gt;You need a DPA with your screen recording provider. This should cover what data is processed, the purpose, retention periods, and deletion procedures. Most enterprise tools offer these. Many free tiers don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Retention and deletion
&lt;/h3&gt;

&lt;p&gt;GDPR requires data minimization — don't keep personal data longer than necessary. Your screen recording tool should let you set retention periods and automatically delete old videos. Bonus points if it lets you delete individual videos on request (right to erasure).&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Access controls
&lt;/h3&gt;

&lt;p&gt;Who can see the recordings? If a video contains customer data, it shouldn't be accessible to everyone in the company. Role-based access, password protection on shared links, and link expiry are practical controls that support GDPR compliance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What most screen recording tools get wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  US-hosted by default
&lt;/h3&gt;

&lt;p&gt;Loom, Zight, and most mainstream tools host everything on AWS US regions. Your video data crosses the Atlantic before your viewer even clicks play. Some offer EU hosting on enterprise plans, but that's typically $20+/user/month with annual commitments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Invisible sub-processors
&lt;/h3&gt;

&lt;p&gt;That "AI transcription" feature? It might be sending your audio to OpenAI's API, which processes data on US infrastructure. That CDN making your videos load fast? Cloudflare or AWS CloudFront, both US companies. These details are buried in privacy policies that nobody reads.&lt;/p&gt;

&lt;h3&gt;
  
  
  No retention controls
&lt;/h3&gt;

&lt;p&gt;Most free and mid-tier plans keep your videos forever — or until you manually delete them. There's no way to set automatic expiry. When an employee leaves and their account has 200 recordings containing customer data, you have a compliance problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tracking on watch pages
&lt;/h3&gt;

&lt;p&gt;When someone views your shared video, many tools load third-party analytics (Google Analytics, Mixpanel, Intercom) on the watch page. Your viewer didn't consent to being tracked by these services. Under GDPR, that's your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;p&gt;Before choosing a screen recording tool, ask these questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data location:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where are videos stored? (country and provider)&lt;/li&gt;
&lt;li&gt;Where is audio processed for transcription?&lt;/li&gt;
&lt;li&gt;Are any CDNs or proxies in the delivery path?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Legal:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is a DPA available? (even on free tier?)&lt;/li&gt;
&lt;li&gt;What sub-processors are listed?&lt;/li&gt;
&lt;li&gt;Can you get EU-only hosting without an enterprise contract?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Technical controls:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can you set video retention periods?&lt;/li&gt;
&lt;li&gt;Can you password-protect shared links?&lt;/li&gt;
&lt;li&gt;Can you set link expiry dates?&lt;/li&gt;
&lt;li&gt;Can you delete videos on demand?&lt;/li&gt;
&lt;li&gt;What tracking loads on watch pages?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Access:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Role-based access for team members?&lt;/li&gt;
&lt;li&gt;Can you restrict who sees which recordings?&lt;/li&gt;
&lt;li&gt;SSO integration for centralized access management?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Self-hosting: the compliance shortcut
&lt;/h2&gt;

&lt;p&gt;If you can run your own infrastructure, self-hosting eliminates most GDPR complexity. There are no sub-processors because you are the processor. Data residency is wherever you deploy. Retention is whatever you configure.&lt;/p&gt;

&lt;p&gt;The trade-off is operational: you're responsible for backups, updates, and uptime. But for teams that already run Docker containers in production, adding a screen recording tool is straightforward.&lt;/p&gt;

&lt;p&gt;Tools like &lt;a href="https://sendrec.eu" rel="noopener noreferrer"&gt;SendRec&lt;/a&gt; (which I build) run as a single Docker Compose stack — the app, PostgreSQL, and S3-compatible storage. Deploy it on a Hetzner server in Germany or Finland and your compliance story becomes very simple: all data stays on infrastructure you control, in the EU, with no third parties.&lt;/p&gt;

&lt;h2&gt;
  
  
  The managed middle ground
&lt;/h2&gt;

&lt;p&gt;Not every team wants to self-host. If you prefer a managed service, look for these specifics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;EU-only infrastructure&lt;/strong&gt; with no US fallback — not just "data is stored in EU" but "data never touches US servers at any point"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No third-party AI processing&lt;/strong&gt; — transcription should happen on the provider's own infrastructure, not via external APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie-free analytics&lt;/strong&gt; on watch pages — tools like Umami or Plausible track views without setting cookies or loading third-party scripts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparent sub-processor list&lt;/strong&gt; that you can actually verify&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What about consent for recording?
&lt;/h2&gt;

&lt;p&gt;GDPR compliance for screen recording isn't just about where data is stored. If your recording captures other people's data — a customer's name in a CRM, a colleague's message in Slack — you need a legal basis for processing that data.&lt;/p&gt;

&lt;p&gt;For internal team recordings, legitimate interest usually applies. For recordings shared externally (client demos, support videos), consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Telling viewers the video may contain personal data&lt;/li&gt;
&lt;li&gt;Using password protection or email gates to control access&lt;/li&gt;
&lt;li&gt;Setting expiry dates so recordings don't persist indefinitely&lt;/li&gt;
&lt;li&gt;Avoiding recording screens that show sensitive customer data when possible&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;GDPR-compliant screen recording isn't complicated, but it requires intentional tool choices. The key decisions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep data in the EU&lt;/strong&gt; — avoid tools that default to US hosting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know your sub-processors&lt;/strong&gt; — fewer is better&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set retention policies&lt;/strong&gt; — don't keep videos forever&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control access&lt;/strong&gt; — passwords, expiry, roles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimize tracking&lt;/strong&gt; — no third-party scripts on watch pages&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The simplest path is self-hosting an open source tool on EU infrastructure. The next best option is a managed service with transparent EU-only hosting and minimal sub-processors.&lt;/p&gt;

&lt;p&gt;If you're evaluating tools, we wrote a &lt;a href="https://sendrec.eu/blog/open-source-loom-alternatives-2026" rel="noopener noreferrer"&gt;detailed comparison of open source Loom alternatives&lt;/a&gt; that covers data residency, pricing, and features across the main options.&lt;/p&gt;

</description>
      <category>gdpr</category>
      <category>privacy</category>
      <category>opensource</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Open Source Loom Alternatives in 2026: A Practical Comparison</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Sun, 22 Mar 2026 08:32:25 +0000</pubDate>
      <link>https://forem.com/alexneamtu/open-source-loom-alternatives-in-2026-a-practical-comparison-1lal</link>
      <guid>https://forem.com/alexneamtu/open-source-loom-alternatives-in-2026-a-practical-comparison-1lal</guid>
      <description>&lt;p&gt;Loom changed how teams communicate asynchronously. But as Atlassian pushes prices higher and more organizations ask where their video data actually lives, the search for alternatives has grown.&lt;/p&gt;

&lt;p&gt;This post compares the open source options available today. I built one of them (SendRec), so I'll be upfront about that bias — but I'll try to be honest about where each tool shines and where it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why open source matters for screen recording
&lt;/h2&gt;

&lt;p&gt;Screen recordings often contain sensitive information: product demos with unreleased features, internal discussions, customer data visible on screen, credentials accidentally shown. Where that data lives — and who controls it — matters more than most teams realize.&lt;/p&gt;

&lt;p&gt;Open source screen recording tools give you three things proprietary tools can't:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit the code.&lt;/strong&gt; You can verify there's no telemetry, no data exfiltration, no hidden upload to third-party services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-host.&lt;/strong&gt; Your videos stay on infrastructure you control. No third-party cloud provider in the data path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No vendor lock-in.&lt;/strong&gt; If the project dies or changes direction, you have the source code. Export your data and move on.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The contenders
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Loom (proprietary baseline)
&lt;/h3&gt;

&lt;p&gt;Loom isn't open source, but it's the tool everyone compares against, so it's worth including as a reference point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does well:&lt;/strong&gt; Polish. Loom's desktop app and Chrome extension are smooth. The editing tools (trim, stitch, filler word removal) work reliably. AI-generated summaries and chapters are available on the Business tier. The brand recognition means recipients know what a Loom link is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-offs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; $15–20/user/month. AI features locked to the $20 Business tier. Enterprise pricing is opaque.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free tier:&lt;/strong&gt; 25 videos total (not per month), 5-minute max, 720p only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; US-hosted on AWS. No option for EU data residency. No self-hosting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock-in:&lt;/strong&gt; Closed source. If Atlassian changes pricing or shuts it down, your video library is at risk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Loom is the right choice if your team is US-based, budget isn't a concern, and data residency doesn't matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cap
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://cap.so" rel="noopener noreferrer"&gt;Cap&lt;/a&gt; is a newer open source screen recorder with a growing community (17,000+ GitHub stars). It takes a desktop-app-first approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does well:&lt;/strong&gt; Beautiful desktop app for macOS and Windows. Local-first recording with optional cloud upload. The Studio editing mode offers frame-level control. Lifetime license option ($58) is attractive for individual users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-offs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Desktop app required.&lt;/strong&gt; No browser-based recording. This is a dealbreaker for some teams — you can't send someone a link and say "record a response."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Linux support.&lt;/strong&gt; Desktop app is macOS and Windows only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commercial use requires paid license.&lt;/strong&gt; The AGPL license is open source, but Cap's commercial license prohibits free commercial use. If your company uses it, you need the paid plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limited team features.&lt;/strong&gt; No workspaces, no role-based access, no SSO, no SCIM. It's designed for individual creators, not teams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;US-based.&lt;/strong&gt; Cloud storage is US-hosted. Self-hosting is possible but the documentation is more focused on the cloud offering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No comments or reactions on videos.&lt;/strong&gt; Collaboration happens outside the tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cap is the right choice if you want a polished desktop recording app for personal use and don't need team features or browser-based recording.&lt;/p&gt;

&lt;h3&gt;
  
  
  Zight (formerly CloudApp)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://zight.com" rel="noopener noreferrer"&gt;Zight&lt;/a&gt; rebranded from CloudApp in 2023. It's a screenshot and screen recording tool aimed at support and sales teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does well:&lt;/strong&gt; Quick screenshots and GIF creation alongside video recording. Good integrations with support tools. Annotation tools for marking up screenshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-offs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not open source.&lt;/strong&gt; Closed source, US-hosted on AWS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free tier expires old content.&lt;/strong&gt; Only your last 50 uploads are accessible — older ones disappear. This is a nasty surprise if you're not expecting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; $9–11/user/month. Less expensive than Loom but still adds up for teams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limited video features.&lt;/strong&gt; No comments, no reactions, no email gate, no embeddable player, no custom thumbnails. It's primarily a screenshot tool that also does video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No self-hosting.&lt;/strong&gt; No option to run on your own infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zight is the right choice if screenshots are your primary use case and video is secondary.&lt;/p&gt;

&lt;h3&gt;
  
  
  SendRec
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://sendrec.eu" rel="noopener noreferrer"&gt;SendRec&lt;/a&gt; is what I've been building — an open source async video platform focused on EU data residency and team use cases. Full disclosure: I'm the developer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does well:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Browser-based recording.&lt;/strong&gt; Screen, camera, or both. No desktop app required, works on any OS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full team features.&lt;/strong&gt; Workspaces with roles (owner, admin, member, viewer), SSO (OIDC + SAML), SCIM provisioning, per-workspace branding and billing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich video features.&lt;/strong&gt; Timestamped comments, reactions, password-protected links, email gate, embeddable player, analytics with per-viewer engagement data, custom CTAs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI transcription.&lt;/strong&gt; 99 languages via whisper.cpp, AI summaries, chapters, filler word removal, trim by transcript. Bring your own model — no data sent to external AI services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosting.&lt;/strong&gt; Single Docker Compose command. Go binary + PostgreSQL + S3-compatible storage. Runs on a $10/month VPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EU data residency.&lt;/strong&gt; Managed instance runs on Hetzner in Germany. No US cloud providers in the data path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The trade-offs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No desktop app.&lt;/strong&gt; Browser-only recording. Works well for most use cases, but you can't record native app windows that block screen capture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller community.&lt;/strong&gt; ~40 GitHub stars vs Cap's 17,000+. The project is younger and less proven.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solo developer.&lt;/strong&gt; I'm building this as one person. Response times for issues are reasonable but not instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free tier has limits.&lt;/strong&gt; 25 videos/month, 5-minute max duration on the managed instance. Self-hosted has no limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SendRec is the right choice if your team needs EU data residency, self-hosting, or team features like SSO and workspaces alongside async video.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Loom&lt;/th&gt;
&lt;th&gt;Cap&lt;/th&gt;
&lt;th&gt;Zight&lt;/th&gt;
&lt;th&gt;SendRec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (AGPL)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (AGPL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hostable&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser recording&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desktop app&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux support&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team workspaces&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSO / SAML&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Yes (Pro)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SCIM provisioning&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comments&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI transcription&lt;/td&gt;
&lt;td&gt;$20/user&lt;/td&gt;
&lt;td&gt;Paid&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (free, local)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-viewer analytics&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password protection&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email gate&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom branding&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (Pro)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU data residency&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free commercial use&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Pricing comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Loom&lt;/th&gt;
&lt;th&gt;Cap&lt;/th&gt;
&lt;th&gt;Zight&lt;/th&gt;
&lt;th&gt;SendRec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;25 videos total, 5 min, 720p&lt;/td&gt;
&lt;td&gt;Local only, no commercial use&lt;/td&gt;
&lt;td&gt;Last 50 uploads, 5 min&lt;/td&gt;
&lt;td&gt;25 videos/month, 5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid&lt;/td&gt;
&lt;td&gt;$15–20/user/month&lt;/td&gt;
&lt;td&gt;$12/month or $58 lifetime&lt;/td&gt;
&lt;td&gt;$9–11/user/month&lt;/td&gt;
&lt;td&gt;€8–12/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Free (non-commercial)&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Free, unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which one should you pick?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Loom if:&lt;/strong&gt; Budget isn't a concern, you want maximum polish, your team is US-based, and data residency doesn't matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Cap if:&lt;/strong&gt; You want a beautiful desktop recording app for personal use and don't need team features or browser recording.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Zight if:&lt;/strong&gt; Screenshots are your primary workflow and video is a secondary feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose SendRec if:&lt;/strong&gt; You need EU data residency, want to self-host, or need team features (SSO, workspaces, SCIM) with an open source tool you can audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try them
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.loom.com" rel="noopener noreferrer"&gt;Loom&lt;/a&gt; — sign up for free tier&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cap.so" rel="noopener noreferrer"&gt;Cap&lt;/a&gt; — download the desktop app&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://zight.com" rel="noopener noreferrer"&gt;Zight&lt;/a&gt; — sign up for free tier&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://app.sendrec.eu" rel="noopener noreferrer"&gt;SendRec&lt;/a&gt; — sign up or &lt;a href="https://github.com/sendrec/sendrec" rel="noopener noreferrer"&gt;self-host from GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're evaluating async video tools for your team and have questions about any of these, feel free to reach out at &lt;a href="mailto:hello@sendrec.eu"&gt;hello@sendrec.eu&lt;/a&gt;. Happy to help even if you don't end up choosing SendRec.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>selfhosted</category>
      <category>loom</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How We Built Data Retention for SendRec</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Thu, 05 Mar 2026 09:54:50 +0000</pubDate>
      <link>https://forem.com/alexneamtu/how-we-built-data-retention-for-sendrec-1i76</link>
      <guid>https://forem.com/alexneamtu/how-we-built-data-retention-for-sendrec-1i76</guid>
      <description>&lt;p&gt;Video recordings accumulate. A team of ten recording three videos a day produces over 800 recordings a year. Some of those are important. Most are not. Without automatic cleanup, storage grows indefinitely and you end up violating your own data retention policies.&lt;/p&gt;

&lt;p&gt;SendRec now has built-in data retention. Configure a retention period at the user or workspace level, pin the videos you want to keep forever, and everything else gets automatically deleted after a 7-day warning email.&lt;/p&gt;

&lt;p&gt;Here's how we built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why data retention matters
&lt;/h2&gt;

&lt;p&gt;Three reasons drove this feature:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GDPR data minimization.&lt;/strong&gt; Article 5(1)(e) requires that personal data is kept only as long as necessary. Video recordings often contain faces, voices, and screen content with personal data. A configurable retention period lets organizations enforce this automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage costs.&lt;/strong&gt; For self-hosted deployments, S3 storage is a real line item. Auto-deleting old recordings keeps costs predictable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise compliance.&lt;/strong&gt; ISO 27001 and SOC 2 audits ask for documented data retention policies with enforcement mechanisms. A dropdown in settings and an automated worker is a concrete answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;Three columns across two existing tables handle the configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Migration 000051&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;retention_days&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;retention_days&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;videos&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;pinned&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;videos&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;retention_warned_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;retention_days = 0&lt;/code&gt; means disabled. Valid values are 0, 30, 60, 90, 180, and 365. The &lt;code&gt;pinned&lt;/code&gt; flag lets users exempt individual videos from auto-deletion. &lt;code&gt;retention_warned_at&lt;/code&gt; tracks whether the 7-day warning email has been sent.&lt;/p&gt;

&lt;p&gt;The interesting design decision is the resolution order. Workspace videos use the organization's &lt;code&gt;retention_days&lt;/code&gt;. Personal videos use the user's &lt;code&gt;retention_days&lt;/code&gt;. This means a workspace admin sets the policy once and it applies to every video in the workspace, while personal accounts control their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-pass worker
&lt;/h2&gt;

&lt;p&gt;The retention worker runs on a daily ticker and does two things: warn, then delete. Separating these into two passes with a 7-day gap means users always get a chance to pin important videos before they disappear.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;StartRetentionWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DBTX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt; &lt;span class="n"&gt;RetentionSender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"retention-worker: started"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;processRetentionWarnings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;processRetentionDeletions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;ticker&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTicker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hour&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"retention-worker: shutting down"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;C&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;processRetentionWarnings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;processRetentionDeletions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both passes run on startup and then every 24 hours. The &lt;code&gt;nil&lt;/code&gt; sender check means if email isn't configured, the worker silently skips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass 1: Warn
&lt;/h2&gt;

&lt;p&gt;The warning query is the most interesting part. It needs to resolve the correct retention period from either the organization or the user, depending on which one the video belongs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;`SELECT v.id, v.title, v.share_token, u.email,
            COALESCE(o.retention_days, u.retention_days) AS retention_days
     FROM videos v
     JOIN users u ON u.id = v.user_id
     LEFT JOIN organizations o ON o.id = v.organization_id
     WHERE v.status = 'ready'
       AND v.pinned = false
       AND v.retention_warned_at IS NULL
       AND COALESCE(o.retention_days, u.retention_days) &amp;gt; 0
       AND v.created_at &amp;lt; now() - make_interval(days =&amp;gt; COALESCE(o.retention_days, u.retention_days) - 7)
     LIMIT 100`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;COALESCE(o.retention_days, u.retention_days)&lt;/code&gt; does the resolution: if the video belongs to a workspace (&lt;code&gt;organization_id&lt;/code&gt; is not null), the &lt;code&gt;LEFT JOIN&lt;/code&gt; returns the org's setting. For personal videos, &lt;code&gt;o.retention_days&lt;/code&gt; is null and we fall through to the user's setting.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;- 7&lt;/code&gt; in the age check is the grace period. A 90-day retention policy triggers the warning at day 83, giving users 7 days to pin anything they want to keep.&lt;/p&gt;

&lt;p&gt;Results are grouped by email address so each user gets a single email listing all their expiring videos, not one email per video:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;grouped&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt;&lt;span class="n"&gt;videoEntry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shareToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userEmail&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;shareToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;userEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;grouped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;userEmail&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grouped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;userEmail&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;videoEntry&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shareToken&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shareToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After sending the email, we mark each video with &lt;code&gt;retention_warned_at = now()&lt;/code&gt; so the same video doesn't trigger another warning on the next tick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass 2: Delete
&lt;/h2&gt;

&lt;p&gt;The deletion pass is simpler. It looks for videos that were warned at least 7 days ago and are still not pinned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;`SELECT id FROM videos
     WHERE retention_warned_at IS NOT NULL
       AND retention_warned_at &amp;lt; now() - interval '7 days'
       AND status = 'ready'
       AND pinned = false
     LIMIT 100`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a user pinned a video after receiving the warning, the &lt;code&gt;pinned = false&lt;/code&gt; filter excludes it. The deletion itself is a soft delete -- we set &lt;code&gt;status = 'deleted'&lt;/code&gt; and remove the video from any playlists. The existing cleanup worker handles purging the actual S3 files later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"DELETE FROM playlist_videos WHERE video_id = ANY($1)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;videoIDs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UPDATE videos SET status = 'deleted' WHERE id = ANY($1)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;videoIDs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both passes process 100 videos at a time. With a daily tick this handles up to 100 warnings and 100 deletions per day, which is more than sufficient for typical usage. Larger deployments will process the backlog over multiple days without spiking database load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pin toggle
&lt;/h2&gt;

&lt;p&gt;Pinning is a one-click toggle on the VideoDetail page. The handler flips the boolean and returns the new state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;TogglePin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;videoID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URLParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;orgVideoFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;videoID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"AND status != 'deleted'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pinned&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s"&gt;"UPDATE videos SET pinned = NOT pinned, updated_at = now() WHERE "&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;" RETURNING pinned"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;httputil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"video not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;httputil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"pinned"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UPDATE ... SET pinned = NOT pinned ... RETURNING pinned&lt;/code&gt; is atomic -- no race condition between reading the current value and writing the new one. The frontend shows a pin icon that toggles between filled and outlined, plus a "Pinned" badge when active. Pinned videos also show a pin indicator on library cards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The warning email
&lt;/h2&gt;

&lt;p&gt;Warning emails go through Listmonk via our existing transactional email pipeline. The email lists each expiring video with its title and watch link, plus the deletion date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SendRetentionWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;videos&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;RetentionVideoSummary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expiryDate&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetentionWarningTemplateID&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"retention warning template ID not set, skipping"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"recipient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ensureSubscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;txRequest&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;SubscriberEmail&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TemplateID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetentionWarningTemplateID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"videos"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"expiryDate"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;expiryDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"html"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sendTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth noting. First, retention warnings bypass the email allowlist. These are critical notifications -- users need to know their videos are about to be deleted, even in development environments with restricted email sending. Second, if the template ID is zero (not configured), the function returns nil without error. This lets self-hosted instances run without Listmonk configured; videos will still be deleted on schedule, just without the warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Two passes are simpler than one.&lt;/strong&gt; We initially considered a single query that calculates the deletion date and groups videos by urgency. The two-pass approach is much cleaner: pass 1 sends warnings, pass 2 deletes warned videos. Each query is simple and testable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;COALESCE&lt;/code&gt; handles the org/user resolution in SQL.&lt;/strong&gt; We considered resolving the retention policy in Go code by querying the org and user separately. Doing it in the query with &lt;code&gt;COALESCE&lt;/code&gt; and a &lt;code&gt;LEFT JOIN&lt;/code&gt; means one database round trip instead of two, and the filter logic stays in the same place as the data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Soft delete cascades naturally.&lt;/strong&gt; Setting &lt;code&gt;status = 'deleted'&lt;/code&gt; means the video immediately disappears from the API (all list queries filter on status). The existing S3 cleanup worker picks up deleted videos later. No new cleanup code needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Data retention is live at &lt;a href="https://app.sendrec.eu" rel="noopener noreferrer"&gt;app.sendrec.eu&lt;/a&gt;. Go to Settings, set a retention period, and pin any videos you want to keep forever.&lt;/p&gt;

&lt;p&gt;Self-hosting? Pull the latest image and the migration runs automatically. Set &lt;code&gt;DEFAULT_RETENTION_DAYS&lt;/code&gt; to configure the default for new accounts, and &lt;code&gt;LISTMONK_RETENTION_WARNING_TEMPLATE_ID&lt;/code&gt; if you want warning emails.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/sendrec/sendrec" rel="noopener noreferrer"&gt;github.com/sendrec/sendrec&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>postgres</category>
      <category>gdpr</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How We Added Jira and GitHub Integrations to SendRec</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Tue, 03 Mar 2026 11:30:02 +0000</pubDate>
      <link>https://forem.com/alexneamtu/how-we-added-jira-and-github-integrations-to-sendrec-342i</link>
      <guid>https://forem.com/alexneamtu/how-we-added-jira-and-github-integrations-to-sendrec-342i</guid>
      <description>&lt;p&gt;You record a bug. You watch it back. You open Jira, create a ticket, paste the video link, copy the transcript, and fill in the title. That's four context switches for something that should take one click.&lt;/p&gt;

&lt;p&gt;SendRec now has built-in Jira and GitHub integrations. From any video, click "Create Issue" and a ticket appears in your project tracker with the video link and a transcript excerpt already attached.&lt;/p&gt;

&lt;p&gt;Here's how we built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The IssueCreator interface
&lt;/h2&gt;

&lt;p&gt;We wanted to support multiple providers without the handler knowing which one it's talking to. A simple interface does the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;IssueCreator&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;CreateIssue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;CreateIssueRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CreateIssueResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ValidateConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;CreateIssueRequest&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Title&lt;/span&gt;       &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;VideoURL&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CreateIssue&lt;/code&gt; files the ticket. &lt;code&gt;ValidateConfig&lt;/code&gt; checks credentials before you save — so you find out immediately if your token is wrong, not when you try to file your first bug.&lt;/p&gt;

&lt;p&gt;Adding a new provider means implementing two methods. The handler, the settings UI, and the config storage don't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub: Bearer token and markdown
&lt;/h2&gt;

&lt;p&gt;GitHub Issues have a straightforward API. POST to &lt;code&gt;/repos/{owner}/{repo}/issues&lt;/code&gt; with a Bearer token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;GitHubClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;CreateIssue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;CreateIssueRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CreateIssueResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;formatGitHubBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;bodyJSON&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s/repos/%s/%s/issues"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;httpReq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequestWithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bodyJSON&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;httpReq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer "&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;httpReq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;httpReq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue body is markdown with the video link prominent and the transcript in a collapsible &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; block so it doesn't overwhelm the ticket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;formatGitHubBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;CreateIssueRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"**Video:** %s&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VideoURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;details&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;summary&amp;gt;Transcript&amp;lt;/summary&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Description&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/details&amp;gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ValidateConfig&lt;/code&gt; hits two endpoints: &lt;code&gt;GET /user&lt;/code&gt; to verify the token works, then &lt;code&gt;GET /repos/{owner}/{repo}&lt;/code&gt; to confirm the repo exists and the token has access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jira: Basic auth and Atlassian Document Format
&lt;/h2&gt;

&lt;p&gt;Jira Cloud uses Basic auth (email + API token, base64-encoded) and requires the Atlassian Document Format (ADF) instead of plain text or markdown. ADF is a JSON tree that describes rich content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;buildADFDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;CreateIssueRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;adfParagraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adfInlineCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VideoURL&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;adfParagraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adfText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Transcript:"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="n"&gt;adfCodeBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"version"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"doc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"content"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The video URL renders as an inline card in Jira — Atlassian automatically fetches the page metadata and shows a rich preview. The transcript goes in a code block to preserve formatting.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ValidateConfig&lt;/code&gt; calls &lt;code&gt;GET /rest/api/3/myself&lt;/code&gt; — the simplest authenticated endpoint Jira offers. If it returns 200, your credentials work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encrypting API tokens at rest
&lt;/h2&gt;

&lt;p&gt;Integration configs live in a &lt;code&gt;user_integrations&lt;/code&gt; table as JSONB. Storing API tokens in plaintext would be reckless, even in a self-hosted database. We encrypt token fields with AES-256-GCM before writing them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plaintext&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;aes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewCipher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;gcm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewGCM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gcm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NonceSize&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rand&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;sealed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gcm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Seal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StdEncoding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sealed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The encryption key is derived from the existing &lt;code&gt;JWT_SECRET&lt;/code&gt; environment variable via SHA-256 — no new secrets to configure. Each encryption uses a random nonce, so the same token produces different ciphertexts each time.&lt;/p&gt;

&lt;p&gt;When listing integrations, we decrypt the token, then mask it (show first 4 characters, replace the rest with asterisks). The full token never leaves the server after initial save.&lt;/p&gt;

&lt;p&gt;One subtlety: when a user edits their config (say, changing the repo name) without re-entering the token, the frontend sends the masked value back. The backend detects this, loads the existing encrypted token from the database, and preserves it. No accidental token loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  The settings UI
&lt;/h2&gt;

&lt;p&gt;Each provider gets a collapsible card in Settings. Connected providers show a green "Connected" badge. Expand the card to see the config fields, pre-filled from saved data (with tokens masked).&lt;/p&gt;

&lt;p&gt;Three buttons per provider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Save&lt;/strong&gt; — validates all required fields, encrypts tokens, upserts the config&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test Connection&lt;/strong&gt; — calls &lt;code&gt;ValidateConfig&lt;/code&gt; against the live API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disconnect&lt;/strong&gt; — deletes the config entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Creating issues from videos
&lt;/h2&gt;

&lt;p&gt;On the VideoDetail page, a "Create Issue" button appears if you have at least one integration configured. If you have both GitHub and Jira connected, it becomes a dropdown so you can pick which tracker to file in.&lt;/p&gt;

&lt;p&gt;Clicking it sends the video's title, share URL, and a transcript excerpt (first 500 characters) to the provider. The response includes the issue URL, which opens in a new tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keep the provider interface minimal.&lt;/strong&gt; Two methods — &lt;code&gt;CreateIssue&lt;/code&gt; and &lt;code&gt;ValidateConfig&lt;/code&gt; — are enough. We were tempted to add &lt;code&gt;ListProjects&lt;/code&gt;, &lt;code&gt;GetIssueTypes&lt;/code&gt;, and other discovery methods, but that would couple the UI to each provider's data model. A flat config with required fields (token, owner, repo for GitHub; base URL, email, API token, project key for Jira) is simpler and works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encrypt at the boundary.&lt;/strong&gt; Token encryption happens in the handler, not in the provider clients. The GitHub and Jira clients receive plaintext tokens and don't know encryption exists. This keeps the providers testable with simple string arguments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test against real API shapes.&lt;/strong&gt; Both providers have test suites using &lt;code&gt;httptest.Server&lt;/code&gt; that return realistic response payloads. This caught a bug early — Jira's create-issue response uses &lt;code&gt;key&lt;/code&gt; (like &lt;code&gt;PROJ-123&lt;/code&gt;), not &lt;code&gt;id&lt;/code&gt;, for the issue identifier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The integrations are live at &lt;a href="https://app.sendrec.eu" rel="noopener noreferrer"&gt;app.sendrec.eu&lt;/a&gt;. Go to Settings, expand GitHub or Jira, enter your credentials, and hit Test Connection. Then open any video and click Create Issue.&lt;/p&gt;

&lt;p&gt;Self-hosting? Pull the latest image and the migration runs automatically. No new environment variables needed — encryption uses your existing &lt;code&gt;JWT_SECRET&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/sendrec/sendrec" rel="noopener noreferrer"&gt;github.com/sendrec/sendrec&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>SendRec Now Supports Team Workspaces</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Sun, 01 Mar 2026 10:55:53 +0000</pubDate>
      <link>https://forem.com/alexneamtu/sendrec-now-supports-team-workspaces-5527</link>
      <guid>https://forem.com/alexneamtu/sendrec-now-supports-team-workspaces-5527</guid>
      <description>&lt;p&gt;SendRec has always been a single-user tool. You record, you share, you see who watched. That works for solo use, but teams need shared libraries, permissions, and a way to manage who can do what.&lt;/p&gt;

&lt;p&gt;v1.70.0 added team workspaces, and subsequent releases have expanded them with a viewer role, video transfer between scopes, and workspace-level SSO. Here's how it all works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shared video libraries.&lt;/strong&gt; Create a workspace, invite your team, and everyone's recordings and uploads live in one place. Videos belong to either your personal account or a workspace — never both. Switch between them with the workspace switcher in the nav bar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Role-based permissions.&lt;/strong&gt; Four roles: owner, admin, member, and viewer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;Admin&lt;/th&gt;
&lt;th&gt;Member&lt;/th&gt;
&lt;th&gt;Viewer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;View all workspace videos&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Download videos&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;View analytics&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comment on videos&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create/record videos&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload videos&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit/delete own videos&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit/delete others' videos&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manage folders and tags&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manage branding&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invite/remove members&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change member roles&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transfer videos&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete workspace&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manage billing&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configure SSO&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Viewers are free — they don't count toward your member limit. Add stakeholders, clients, or executives who need to watch and comment without taking up a paid seat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video transfer.&lt;/strong&gt; Move videos between your personal account and any workspace you belong to. Transfer preserves comments and view counts. Folder assignments and tags are cleared on transfer since they're scope-specific. Videos must be in "ready" status to transfer — processing videos are blocked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email invites.&lt;/strong&gt; Invite teammates by email. They get a link, create an account (or log in if they already have one), and land in the workspace. Pending invites are visible in workspace settings and can be revoked before acceptance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workspace branding and billing.&lt;/strong&gt; Each workspace can have its own logo, colors, and footer — separate from personal branding. Billing works per-workspace: upgrade the workspace to Pro or Business directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workspace SSO.&lt;/strong&gt; Business plan workspaces can configure OIDC-based SSO. Connect your identity provider, and members can log in with their corporate credentials. Optionally enforce SSO so non-SSO logins are blocked for workspace members.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free tier limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;1 workspace per user (as owner)&lt;/li&gt;
&lt;li&gt;3 members per workspace (viewers don't count)&lt;/li&gt;
&lt;li&gt;Pro and Business remove both limits&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  For self-hosters
&lt;/h2&gt;

&lt;p&gt;Update your Docker image to &lt;code&gt;latest&lt;/code&gt; and restart. The database migration runs automatically on startup — no manual steps. Workspaces are available immediately with no additional configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull sendrec
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; sendrec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How we built it
&lt;/h2&gt;

&lt;p&gt;The implementation touches every layer of the stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database.&lt;/strong&gt; Three core tables: &lt;code&gt;organizations&lt;/code&gt;, &lt;code&gt;organization_members&lt;/code&gt;, and &lt;code&gt;organization_invites&lt;/code&gt;. Videos, folders, tags, analytics, and branding all gained an &lt;code&gt;organization_id&lt;/code&gt; foreign key. SSO configuration lives in &lt;code&gt;organization_sso_configs&lt;/code&gt; with AES-256-GCM encrypted client secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend.&lt;/strong&gt; A new &lt;code&gt;internal/organization/&lt;/code&gt; package handles CRUD, member management, invites, and middleware. The &lt;code&gt;Middleware&lt;/code&gt; verifies membership and injects the user's role into the request context. &lt;code&gt;RequireWriter&lt;/code&gt; blocks viewer-role users from all write endpoints — videos, folders, tags, and playlists. Every video handler uses a shared &lt;code&gt;orgVideoFilter&lt;/code&gt; helper that builds the correct WHERE clause based on role.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Frontend.&lt;/strong&gt; The workspace switcher persists your selection in localStorage. The API client reads it and sends an &lt;code&gt;X-Organization-Id&lt;/code&gt; header on every request. Workspace settings has sections for general info, members, invites, billing, SSO, and a danger zone. The viewer role conditionally hides all write actions — record, upload, edit, delete, pin, trim, organize — while keeping read actions visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests.&lt;/strong&gt; 724 frontend unit tests, 874+ backend tests across 18 packages, and 18 Playwright E2E tests covering workspace CRUD, viewer restrictions, and video transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  API
&lt;/h2&gt;

&lt;p&gt;All workspace endpoints are documented in the &lt;a href="https://app.sendrec.eu/api/docs" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; under the Organizations tag. Key endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /api/organizations&lt;/code&gt; — create a workspace&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /api/organizations/{orgId}/invites&lt;/code&gt; — invite a member&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /api/invites/accept&lt;/code&gt; — accept an invitation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PATCH /api/organizations/{orgId}/members/{userId}&lt;/code&gt; — change a member's role&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /api/videos/{id}/transfer&lt;/code&gt; — move a video between scopes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set the &lt;code&gt;X-Organization-Id&lt;/code&gt; header on any request to scope it to a workspace.&lt;/p&gt;




&lt;p&gt;SendRec is open source and self-hostable. Try it at &lt;a href="https://app.sendrec.eu" rel="noopener noreferrer"&gt;app.sendrec.eu&lt;/a&gt; or run it on your own infrastructure with &lt;a href="https://github.com/sendrec/sendrec" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>webdev</category>
      <category>go</category>
      <category>react</category>
    </item>
    <item>
      <title>How We Built a Welcome Email That Actually Gets Sent</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Thu, 26 Feb 2026 07:16:22 +0000</pubDate>
      <link>https://forem.com/alexneamtu/how-we-built-a-welcome-email-that-actually-gets-sent-5a4o</link>
      <guid>https://forem.com/alexneamtu/how-we-built-a-welcome-email-that-actually-gets-sent-5a4o</guid>
      <description>&lt;p&gt;Most SaaS products send a welcome email the moment you sign up. The problem: if the user hasn't confirmed their email yet, you're sending a "start using the product" message to someone who can't log in. They have to find the confirmation email first, click the link, then come back. The welcome email gets buried.&lt;/p&gt;

&lt;p&gt;We wanted the welcome email to arrive at exactly the right moment — after the user confirms their email address and can actually use the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The confirmation flow
&lt;/h2&gt;

&lt;p&gt;SendRec requires email verification before login. Here's what happens when someone registers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User fills out the registration form&lt;/li&gt;
&lt;li&gt;Backend creates the account with &lt;code&gt;email_verified = false&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Confirmation email is sent with a 24-hour token&lt;/li&gt;
&lt;li&gt;User clicks the link&lt;/li&gt;
&lt;li&gt;Backend sets &lt;code&gt;email_verified = true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;User can now log in&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is where the welcome email belongs. The user just proved they own the email address, and their next action is to log in and start recording. The welcome email should be waiting in their inbox with a direct link.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;

&lt;p&gt;We use &lt;a href="https://listmonk.app" rel="noopener noreferrer"&gt;Listmonk&lt;/a&gt; for transactional email — it's open source, self-hosted, and speaks a simple HTTP API. Our Go backend already had an email client with methods for password resets, comment notifications, view notifications, and confirmation emails. Adding a welcome email followed the same pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  The email client
&lt;/h3&gt;

&lt;p&gt;The email package wraps Listmonk's transactional API. Each email type is a method that takes the recipient details and template-specific data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SendWelcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dashboardURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseURL&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"email not configured, welcome email skipped"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"recipient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WelcomeTemplateID&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"welcome template ID not set, skipping welcome email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"recipient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ensureSubscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;txRequest&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;SubscriberEmail&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TemplateID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WelcomeTemplateID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;         &lt;span class="n"&gt;toName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"dashboardURL"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dashboardURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"html"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sendTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two guard clauses handle graceful degradation. If Listmonk isn't configured (&lt;code&gt;BaseURL&lt;/code&gt; is empty), the method logs a warning and returns nil — self-hosters who don't run Listmonk won't see errors. If the template ID isn't set, same thing. The application never crashes because email is down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bypassing the allowlist
&lt;/h3&gt;

&lt;p&gt;We have an email allowlist feature for staging environments. When set, only emails matching specific domains or addresses get sent. This prevents test runs from emailing real users.&lt;/p&gt;

&lt;p&gt;But confirmation and welcome emails must always be sent — they're part of the core authentication flow. A user on staging who can't confirm their email can't test anything. So both methods bypass the allowlist check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Welcome emails bypass the allowlist — they are part of the core&lt;/span&gt;
&lt;span class="c"&gt;// onboarding flow and must always be sent after email confirmation.&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ensureSubscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare this with the view notification method, which checks the allowlist first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Triggering after confirmation
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;ConfirmEmail&lt;/code&gt; handler already updates &lt;code&gt;email_verified&lt;/code&gt; in the database. We added the welcome email call right after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s"&gt;"UPDATE users SET email_verified = true, updated_at = now() WHERE id = $1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;httputil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"failed to verify email"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emailSender&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;userEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userName&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s"&gt;"SELECT email, name FROM users WHERE id = $1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;userEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"confirm-email: failed to load user for welcome email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emailSender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SendWelcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;userEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"confirm-email: failed to send welcome email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few design decisions here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The welcome email is best-effort.&lt;/strong&gt; If the database query fails or Listmonk is down, we log the error but still return a success response. The user confirmed their email — that worked. The welcome email is a nice-to-have, not a gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We query for the user's email and name.&lt;/strong&gt; The &lt;code&gt;ConfirmEmail&lt;/code&gt; handler only has the &lt;code&gt;userID&lt;/code&gt; from the confirmation token lookup. We need the email and name to personalize the welcome message, so we make one additional query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;emailSender&lt;/code&gt; nil check&lt;/strong&gt; handles the case where the email system isn't configured at all. This is common in development and testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The interface
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;EmailSender&lt;/code&gt; interface in the auth package defines what the auth handler needs from the email system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;EmailSender&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;SendPasswordReset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resetLink&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="n"&gt;SendConfirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confirmLink&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="n"&gt;SendWelcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dashboardURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the auth package decoupled from the email implementation. In tests, we use a mock that records what was called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;mockEmailSender&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;lastEmail&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;lastName&lt;/span&gt;         &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;lastDashboardURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;welcomeCalled&lt;/span&gt;    &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;sendErr&lt;/span&gt;          &lt;span class="kt"&gt;error&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mockEmailSender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SendWelcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dashboardURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;welcomeCalled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;toEmail&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;toName&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastDashboardURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dashboardURL&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sendErr&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Listmonk template
&lt;/h3&gt;

&lt;p&gt;Listmonk templates are managed in its admin UI, not in code. The template uses Go's text/template syntax with Listmonk's &lt;code&gt;.Tx.Data&lt;/code&gt; namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Hi {{ .Tx.Data.name }},&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Welcome to SendRec. Your account is live and ready to use.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Everything you record stays on EU infrastructure — no US cloud
in the data path, no third-party processors to vet.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Getting started is fast: click "New Recording" from your dashboard,
choose screen, camera, or both, and hit record. When you stop,
your video gets a shareable link instantly.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Your free tier includes 25 videos per month with AI transcription,
timestamped comments, and viewer analytics on every video.
No credit card needed.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ .Tx.Data.dashboardURL }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Record Your First Video&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template ID is passed to the application via an environment variable: &lt;code&gt;LISTMONK_WELCOME_TEMPLATE_ID&lt;/code&gt;. This means self-hosters can create their own template with different copy, point the env var at it, and everything works.&lt;/p&gt;

&lt;h2&gt;
  
  
  A routing mistake
&lt;/h2&gt;

&lt;p&gt;The first version linked the CTA button to &lt;code&gt;${baseURL}/dashboard&lt;/code&gt;. When we tested it, the link went to a 404. Our SPA doesn't have a &lt;code&gt;/dashboard&lt;/code&gt; route — the root path &lt;code&gt;/&lt;/code&gt; loads the Record page, and authenticated users land there after login.&lt;/p&gt;

&lt;p&gt;The fix was simple: link to the base URL instead. But it's a good reminder to actually click the links in your emails before shipping them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;Three tests for the email client method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestSendWelcome_Success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c"&gt;// verifies template ID, email, data&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestSendWelcome_SkipsWhenTemplateIDZero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// no HTTP call when unconfigured&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestSendWelcome_BypassesAllowlist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;// sent even when allowlist blocks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the existing &lt;code&gt;TestConfirmEmail_Success&lt;/code&gt; was updated to verify the welcome email is sent after confirmation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;emailSender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;welcomeCalled&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expected welcome email to be sent after confirmation"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;emailSender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastEmail&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"alice@example.com"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expected welcome email to alice@example.com, got %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;emailSender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The user experience is now: register, receive confirmation email, click the link, receive welcome email with a direct "Record Your First Video" button. Two emails, in the right order, each arriving when they're useful.&lt;/p&gt;

&lt;p&gt;The welcome email is the first step in a planned onboarding sequence. Next up: a "share your first video" nudge on day 2 and a Pro upgrade prompt on day 7. All powered by Listmonk's transactional API, all triggered from application events rather than time-based campaigns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sendrec.eu" rel="noopener noreferrer"&gt;SendRec&lt;/a&gt; is open source (AGPL-3.0) and self-hostable. Register at &lt;a href="https://app.sendrec.eu" rel="noopener noreferrer"&gt;app.sendrec.eu&lt;/a&gt; to see the welcome email in action, or browse the &lt;a href="https://github.com/sendrec/sendrec" rel="noopener noreferrer"&gt;source code&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>email</category>
      <category>listmonk</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How We Added Silence Removal to SendRec</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Wed, 25 Feb 2026 12:38:12 +0000</pubDate>
      <link>https://forem.com/alexneamtu/how-we-added-silence-removal-to-sendrec-22di</link>
      <guid>https://forem.com/alexneamtu/how-we-added-silence-removal-to-sendrec-22di</guid>
      <description>&lt;p&gt;Anyone who has watched a screen recording knows the feeling: the presenter pauses to think, navigates a menu slowly, or just trails off between thoughts. Those dead seconds add up fast. In v1.65.4, SendRec can detect and remove them automatically.&lt;/p&gt;

&lt;p&gt;We already had filler word removal — detecting "um", "uh", and similar disfluencies from transcript data, presenting them as a checklist, and cutting them out. Silence removal follows the same pattern, but operates purely on audio energy rather than transcription.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection: ffmpeg's silencedetect filter
&lt;/h2&gt;

&lt;p&gt;ffmpeg ships a &lt;code&gt;silencedetect&lt;/code&gt; audio filter that scans an audio stream and emits timestamps whenever audio drops below a configurable noise floor for at least a minimum duration.&lt;/p&gt;

&lt;p&gt;The command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mp4 &lt;span class="nt"&gt;-vn&lt;/span&gt; &lt;span class="nt"&gt;-af&lt;/span&gt; &lt;span class="nv"&gt;silencedetect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;noise&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-30dB&lt;/span&gt;:d&lt;span class="o"&gt;=&lt;/span&gt;1.0 &lt;span class="nt"&gt;-f&lt;/span&gt; null -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-vn&lt;/code&gt; flag tells ffmpeg to skip video decoding entirely. Silence detection only needs the audio stream, and skipping video makes the scan significantly faster on large files.&lt;/p&gt;

&lt;p&gt;ffmpeg writes silence events to stderr:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[silencedetect @ 0x...] silence_start: 3.504
[silencedetect @ 0x...] silence_end: 5.200 | silence_duration: 1.696
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We parse this with a pair of regexes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;startRe&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;regexp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`silence_start:\s*([\d.]+)`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;endRe&lt;/span&gt;   &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;regexp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`silence_end:\s*([\d.]+)`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walk the stderr lines, match each pattern, pair starts with ends. If audio is silent at the very end of a recording, ffmpeg emits a &lt;code&gt;silence_start&lt;/code&gt; with no corresponding &lt;code&gt;silence_end&lt;/code&gt; — we discard those unpaired starts.&lt;/p&gt;

&lt;p&gt;The result is a list of &lt;code&gt;{start, end}&lt;/code&gt; pairs returned via a new endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/videos/{id}/detect-silence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request body accepts two optional parameters: &lt;code&gt;noiseDB&lt;/code&gt; (default &lt;code&gt;-30&lt;/code&gt;) and &lt;code&gt;minDuration&lt;/code&gt; (default &lt;code&gt;1.0&lt;/code&gt; seconds).&lt;/p&gt;

&lt;h2&gt;
  
  
  The presigned URL optimization
&lt;/h2&gt;

&lt;p&gt;The initial implementation downloaded the full video from S3 to a temp file on disk, then ran ffmpeg against the local path. This worked, but the wait before detection started scaled linearly with file size. A large recording meant waiting for the entire download before ffmpeg processed a single audio frame.&lt;/p&gt;

&lt;p&gt;The fix: generate a presigned S3 URL and pass it directly to ffmpeg.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;presignedURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GenerateDownloadURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fileKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ffmpeg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"-i"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;presignedURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"-vn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"-af"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"silencedetect=noise=%ddB:d=%.2f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;noiseDB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minDuration&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;"-f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ffmpeg handles HTTPS inputs natively. It starts streaming and processing audio immediately — no temp file, no download wait. Combined with &lt;code&gt;-vn&lt;/code&gt; skipping the video track, detection on a long recording completes in seconds rather than minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Duration clamping
&lt;/h2&gt;

&lt;p&gt;ffmpeg reports timestamps as floats. Our database stores video duration as an integer (seconds). This mismatch caused a subtle bug: a video stored as 120 seconds could have ffmpeg report silence ending at 120.041 seconds, which the segment removal worker would reject with "segment end exceeds video duration."&lt;/p&gt;

&lt;p&gt;The fix is a clamping step before returning results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drop any segment whose start is at or beyond the stored duration&lt;/li&gt;
&lt;li&gt;Cap any segment end at the stored duration&lt;/li&gt;
&lt;li&gt;Drop any segment shorter than 0.1 seconds after clamping&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps detection results consistent with what the removal worker expects, regardless of float-to-integer rounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frontend
&lt;/h2&gt;

&lt;p&gt;The UI mirrors the existing filler word removal modal. Click "Remove Silence" on a video, and a modal shows each detected pause as a checkbox entry with timestamp range and duration, all selected by default. Confirm, and the segments are handed off to the same &lt;code&gt;removeSegmentsFromVideo&lt;/code&gt; worker that handles filler word removal. No new cut logic was needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;p&gt;The presigned URL approach is worth considering any time you run a command-line tool against a cloud-stored file. Passing a URL directly to ffmpeg eliminates the download-to-disk step, and ffmpeg's streaming behavior means processing starts almost immediately. Combined with &lt;code&gt;-vn&lt;/code&gt; for audio-only analysis, the latency improvement on large files is substantial.&lt;/p&gt;

&lt;p&gt;The float/integer duration mismatch only showed up with real recordings. ffmpeg is precise; databases round. A clamping step at the boundary prevents confusing errors downstream.&lt;/p&gt;

&lt;p&gt;SendRec is open source. The full implementation is on &lt;a href="https://github.com/sendrec/sendrec" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>ffmpeg</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How We Made Our E2E Tests 12x Faster</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Tue, 24 Feb 2026 20:54:42 +0000</pubDate>
      <link>https://forem.com/alexneamtu/how-we-made-our-e2e-tests-12x-faster-51pm</link>
      <guid>https://forem.com/alexneamtu/how-we-made-our-e2e-tests-12x-faster-51pm</guid>
      <description>&lt;p&gt;Our Playwright end-to-end test suite has 15 tests across 5 spec files. They run sequentially because some tests have ordering dependencies — upload creates a video that later tests verify. The suite was taking around 90 seconds per run. Most of that time was spent doing the same thing: logging in through the UI.&lt;/p&gt;

&lt;p&gt;We got it down to 7 seconds. Here's how.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottleneck
&lt;/h2&gt;

&lt;p&gt;Eight of the fifteen tests need authentication. Each one called &lt;code&gt;loginViaUI()&lt;/code&gt; before doing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loginViaUI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TEST_USER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TEST_USER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sign in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Navigate to the login page. Wait for it to load. Fill the email. Fill the password. Click the button. Wait for the redirect. That's 2-3 seconds per test, times eight — roughly 20 seconds of typing into login forms.&lt;/p&gt;

&lt;p&gt;The remaining time came from Docker health checks with conservative timings and a CI wait loop that polled every 3 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried first: &lt;code&gt;storageState&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Playwright has a built-in solution for this: &lt;code&gt;storageState&lt;/code&gt;. The idea is to log in once in a global setup, save the browser's cookies and localStorage to a JSON file, and load that file for every test. Tests start authenticated without touching the login page.&lt;/p&gt;

&lt;p&gt;We implemented it exactly as the docs describe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// global-setup.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/login`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TEST_USER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TEST_USER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sign in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;context&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;storageState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.auth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// playwright.config.ts&lt;/span&gt;
&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chromium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;storageState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./e2e/.auth/user.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It didn't work. Tests landed on the login page as if no authentication existed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;storageState&lt;/code&gt; failed
&lt;/h3&gt;

&lt;p&gt;Our auth system stores the access token in a module-level variable — not in localStorage or sessionStorage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The refresh token is an HTTP-only cookie. On page load, a &lt;code&gt;ProtectedRoute&lt;/code&gt; component checks for the access token in memory. If it's not there, it calls &lt;code&gt;tryRefreshToken()&lt;/code&gt;, which POSTs to &lt;code&gt;/api/auth/refresh&lt;/code&gt; with the cookie. If that succeeds, the access token is set in memory and the user is authenticated.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;storageState&lt;/code&gt; file captured the refresh token cookie correctly. The problem was refresh token rotation. When a test navigates to a protected page, the browser sends the refresh token cookie. The server validates it, issues a new access token, and rotates the refresh token — the old one is invalidated and a new one is returned.&lt;/p&gt;

&lt;p&gt;Each Playwright test gets a fresh browser context initialized from the same &lt;code&gt;storageState&lt;/code&gt; file. The first test uses the refresh token, which gets rotated. The second test loads the same (now-invalid) token from the file. The server rejects it. The test lands on the login page.&lt;/p&gt;

&lt;p&gt;This is a fundamental incompatibility between &lt;code&gt;storageState&lt;/code&gt; and refresh token rotation. The &lt;code&gt;storageState&lt;/code&gt; approach assumes tokens remain valid across contexts. Token rotation assumes each token is used exactly once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually worked: API login
&lt;/h2&gt;

&lt;p&gt;Instead of avoiding the login entirely, we made it fast. The &lt;code&gt;page.request&lt;/code&gt; API in Playwright lets you make HTTP calls that share cookies with the browser context. A single POST to the login endpoint sets the refresh token cookie — no page navigation, no DOM interaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loginViaAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/auth/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TEST_USER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TEST_USER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Login API failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Login API failed: exceeded retries (429)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retry loop handles rate limiting. Our auth endpoints are rate-limited to 0.5 requests per second with a burst of 5. With 8+ login calls happening across a fast test suite, the last few can hit the limit. A 2-second wait and retry handles it cleanly.&lt;/p&gt;

&lt;p&gt;Each test's &lt;code&gt;beforeEach&lt;/code&gt; calls &lt;code&gt;loginViaAPI&lt;/code&gt; instead of &lt;code&gt;loginViaUI&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Upload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beforeEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loginViaAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// tests...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The auth spec still uses &lt;code&gt;loginViaUI&lt;/code&gt; because it's testing the actual login UI flow — form rendering, error messages, redirects. That's intentional; those tests need to exercise the real login page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection pooling
&lt;/h2&gt;

&lt;p&gt;The test helpers used per-call database connections:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every query opened a new TCP connection, performed the TLS handshake (if applicable), authenticated, executed the query, and closed the connection. For global setup and teardown — which each run a TRUNCATE — that's two full connection lifecycles.&lt;/p&gt;

&lt;p&gt;Replacing &lt;code&gt;pg.Client&lt;/code&gt; with &lt;code&gt;pg.Pool&lt;/code&gt; keeps connections alive across calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;closePool&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pool is closed in &lt;code&gt;globalTeardown&lt;/code&gt; after the final table truncation. This saves around 100-200ms per database call — small individually, but it eliminates unnecessary overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Faster health checks
&lt;/h2&gt;

&lt;p&gt;The Docker Compose e2e stack had a conservative health check configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wget"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--spider"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-q"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8080/api/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
  &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Go binary starts in under a second. A 15-second start period is 14 seconds of waiting. We reduced it to 5 seconds and the check interval from 5 to 2 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;
  &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CI workflow had a similar problem. After Docker Compose reports healthy, a shell loop polled the health endpoint as a safety net:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 60&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if &lt;/span&gt;curl &lt;span class="nt"&gt;-sf&lt;/span&gt; http://localhost:8080/api/health &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"App is healthy!"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;3  &lt;span class="c"&gt;# was 3, now 1&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop also dumped garage-init logs and tested S3 connectivity on every successful health check — debugging artifacts from the initial setup that we removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Time saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API login (8 tests)&lt;/td&gt;
&lt;td&gt;~20s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health check timing&lt;/td&gt;
&lt;td&gt;~15s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI wait loop&lt;/td&gt;
&lt;td&gt;~20s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection pooling&lt;/td&gt;
&lt;td&gt;~1s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The suite now runs in about 7 seconds locally, down from 90+. In CI, the total e2e job time drops by roughly a minute including stack startup.&lt;/p&gt;

&lt;p&gt;The key insight: the obvious Playwright solution (&lt;code&gt;storageState&lt;/code&gt;) didn't work because of how our auth system manages tokens. The actual fix was simpler — skip the browser, call the API directly, handle rate limits. Sometimes the right optimization isn't eliminating the work, it's doing it more efficiently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sendrec.eu" rel="noopener noreferrer"&gt;SendRec&lt;/a&gt; is open source (AGPL-3.0) and self-hostable. Check the &lt;a href="https://github.com/sendrec/sendrec/tree/main/web/e2e" rel="noopener noreferrer"&gt;e2e test suite&lt;/a&gt;, pull the image from &lt;a href="https://hub.docker.com/r/alexneamtu/sendrec" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt;, or browse the &lt;a href="https://github.com/sendrec/sendrec" rel="noopener noreferrer"&gt;source code&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>testing</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What's Next for SendRec</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Tue, 24 Feb 2026 20:22:35 +0000</pubDate>
      <link>https://forem.com/alexneamtu/whats-next-for-sendrec-cap</link>
      <guid>https://forem.com/alexneamtu/whats-next-for-sendrec-cap</guid>
      <description>&lt;p&gt;We shipped 19 releases in February — playlists, engagement heatmaps, system audio, custom player controls, and more. Here's what we built, what we learned, and where SendRec is heading.&lt;/p&gt;

&lt;p&gt;SendRec started as a simple idea: an open-source, EU-hosted alternative to Loom. Record your screen, share a link, see who watched. Four weeks into February 2026, we've shipped 19 releases (v1.48.0 through v1.64.0) and the product looks very different from where it started.&lt;/p&gt;

&lt;p&gt;Here's a recap of what shipped, and where we're going next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped in February
&lt;/h2&gt;

&lt;p&gt;The month started with billing (Creem integration for Pro subscriptions) and ended with faster end-to-end tests and a refined deployment pipeline. In between:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video playlists.&lt;/strong&gt; Share an ordered set of videos as a single link. Password protection, email gates, auto-advance, watched badges. Playlists get their own embed page with a sidebar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom player controls.&lt;/strong&gt; We replaced the native &lt;code&gt;&amp;lt;video controls&amp;gt;&lt;/code&gt; with a custom UI — seek bar with chapter segments and comment markers, speed controls, picture-in-picture, volume slider, fullscreen. Auto-hide after 3 seconds, touch support for mobile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Viewer engagement heatmap.&lt;/strong&gt; The analytics page now shows a 50-segment bar that visualizes where viewers pay attention and where they drop off. Each segment covers 2% of the video timeline. Bright means high engagement, faint means drop-off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;System audio recording.&lt;/strong&gt; Chrome's &lt;code&gt;systemAudio&lt;/code&gt; and &lt;code&gt;suppressLocalAudioPlayback&lt;/code&gt; constraints let us capture tab and system audio during screen recording, with an Audio On/Off toggle in the recorder UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MP4 recording.&lt;/strong&gt; Chrome 130+ and Safari record MP4 natively. No more server-side transcoding for most recordings. Firefox falls back to WebM with a VP8/VP9 chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS compatibility.&lt;/strong&gt; Custom player controls break on iOS, so we detect it and fall back to native controls. ffmpeg flags were tuned for iOS playback compatibility (&lt;code&gt;-profile:v high -level:v 5.1 -r 60&lt;/code&gt;). Videos that aren't iOS-compatible get re-encoded automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Playlist embeds.&lt;/strong&gt; &lt;code&gt;/embed/playlist/{shareToken}&lt;/code&gt; renders a sidebar with video list and auto-advancing player — designed for embedding in documentation, wikis, or internal tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker Hub publishing.&lt;/strong&gt; Every tagged release now pushes to Docker Hub (&lt;code&gt;alexneamtu/sendrec&lt;/code&gt;) and GHCR automatically. Plus an Unraid community app template for one-click installation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured logging.&lt;/strong&gt; Migrated ~130 log calls from &lt;code&gt;log.Printf&lt;/code&gt; to Go's &lt;code&gt;log/slog&lt;/code&gt; with structured key-value fields. Custom HTTP middleware that skips health check noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Playwright e2e tests.&lt;/strong&gt; 15 end-to-end tests covering auth, upload, library, settings, and watch pages. Run against a full Docker Compose stack with ephemeral volumes. Then we made them &lt;a href="https://sendrec.eu/blog/how-we-made-our-e2e-tests-12x-faster" rel="noopener noreferrer"&gt;12x faster&lt;/a&gt; — from 90 seconds to 7 seconds — by replacing UI-based login with API calls, adding connection pooling, and tuning health check timings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAPI documentation.&lt;/strong&gt; All 95 API endpoints documented with Scalar UI at &lt;code&gt;/api/docs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And a handful of smaller things: configurable AI timeout for self-hosters running Ollama on slower hardware, webhook idempotency for billing events, comparison pages on the landing site, data-driven free tier limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Browser APIs are surprisingly accommodating.&lt;/strong&gt; The &lt;code&gt;getDisplayMedia&lt;/code&gt; API silently ignores unknown constraints instead of throwing errors. This meant we could add Chrome-specific system audio constraints and ship them to all browsers without feature detection. Firefox and Safari just skip what they don't understand. More APIs should work this way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;50 segments is enough.&lt;/strong&gt; When planning the engagement heatmap, we considered 100 or even per-second tracking. 50 segments (2% each) gives useful resolution — you can clearly see intro drop-off, mid-video engagement dips, and whether viewers reach your call-to-action. More segments would mean more data with no real improvement in actionable insight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-side compositing was the right call.&lt;/strong&gt; Canvas-based webcam overlay compositing breaks when the tab goes to the background (browser throttling kills &lt;code&gt;requestAnimationFrame&lt;/code&gt;). Recording two separate streams and compositing with ffmpeg on the server is more work upfront but works reliably regardless of what the user does with their browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS is its own platform.&lt;/strong&gt; Every video feature needs an iOS code path. Custom player controls, specific ffmpeg encoding flags, WebM-to-MP4 normalization, Safari-specific warnings. The investment is worth it — mobile viewing is a significant share of watch page traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The obvious Playwright solution didn't work.&lt;/strong&gt; Playwright's &lt;code&gt;storageState&lt;/code&gt; is the standard way to share authentication across tests. It didn't work for us because our app stores access tokens in memory (not localStorage) and uses refresh token rotation. Each test context loaded the same saved token, the first test rotated it, and every subsequent test got an invalid token. The fix was simpler: skip the browser, POST to the login API directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we're heading
&lt;/h2&gt;

&lt;p&gt;With the core product solid — recording, sharing, analytics, transcription, AI summaries, billing — the focus shifts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution.&lt;/strong&gt; The product does what it needs to do. Now more people need to find it. LinkedIn (founder-led content about EU data sovereignty and building in public), Product Hunt (saving for a coordinated launch), and getting listed on european-alternatives.eu and awesome-selfhosted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Team features.&lt;/strong&gt; SendRec is currently single-user. The next major product milestone is team workspaces — shared video libraries, roles and permissions, team-level branding. This is what turns SendRec from a personal tool into something a company deploys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-hosting improvements.&lt;/strong&gt; Every release should make self-hosting easier. The Unraid template was a start. Better documentation, one-command deploys, and reducing the number of environment variables needed to get started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;As of today: 465 unit tests, 15 e2e tests, 95 documented API endpoints, 45 blog posts, 64 releases. The codebase is Go + React + TypeScript + PostgreSQL + S3, licensed AGPL-3.0, and runs on a single Hetzner CX33 server in Helsinki.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sendrec.eu" rel="noopener noreferrer"&gt;SendRec&lt;/a&gt; is open source and self-hostable. Try it at &lt;a href="https://app.sendrec.eu" rel="noopener noreferrer"&gt;app.sendrec.eu&lt;/a&gt;, pull the image from &lt;a href="https://hub.docker.com/r/alexneamtu/sendrec" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt;, or check the &lt;a href="https://github.com/sendrec/sendrec" rel="noopener noreferrer"&gt;source code on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>go</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How We Added System Audio to Screen Recording</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Tue, 24 Feb 2026 20:20:47 +0000</pubDate>
      <link>https://forem.com/alexneamtu/how-we-added-system-audio-to-screen-recording-54kg</link>
      <guid>https://forem.com/alexneamtu/how-we-added-system-audio-to-screen-recording-54kg</guid>
      <description>&lt;p&gt;We already had screen recording with webcam overlay, drawing tools, and pause/resume. But when someone recorded a product walkthrough with background music or a tutorial with UI sounds, the recording was silent. The browser captured the screen but not the audio coming from it.&lt;/p&gt;

&lt;p&gt;The fix was surprisingly small — two constraints on one API call. But understanding &lt;em&gt;why&lt;/em&gt; those constraints exist and how they interact with different browsers took more work than writing the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;getDisplayMedia()&lt;/code&gt; API lets web apps capture a user's screen. You can request audio alongside video:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDisplayMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works — sort of. The &lt;code&gt;audio: true&lt;/code&gt; flag tells the browser you'd like audio, but the browser decides what that means. In Chrome, the share picker shows an "Also share tab audio" checkbox. If the user shares a tab, they get tab audio. If they share an entire screen or window, they might get nothing.&lt;/p&gt;

&lt;p&gt;The real issue is that Chrome doesn't offer system-level audio capture by default. Without an explicit signal, it limits you to tab audio at best.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraints
&lt;/h2&gt;

&lt;p&gt;Chrome 105+ supports two constraints that change the behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDisplayMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;systemAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;suppressLocalAudioPlayback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;systemAudio: "include"&lt;/code&gt;&lt;/strong&gt; tells Chrome to offer system audio capture in the share picker, not just tab audio. When a user shares their entire screen, Chrome can now capture audio from any application — not just browser tabs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;suppressLocalAudioPlayback: true&lt;/code&gt;&lt;/strong&gt; solves the echo problem. Without it, if you're recording a tab that's playing audio, the user hears the audio locally &lt;em&gt;and&lt;/em&gt; it gets recorded. During playback, you'd hear the audio twice — once from the recording and once from the echo picked up by the microphone. Suppressing local playback mutes the captured source's audio output so it only exists in the recording.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;p&gt;The full change was three parts: a state variable, modified constraints, and a toggle button.&lt;/p&gt;

&lt;p&gt;The state defaults to &lt;code&gt;true&lt;/code&gt; because most people recording their screen want audio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;systemAudioEnabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSystemAudioEnabled&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;getDisplayMedia&lt;/code&gt; call builds its options based on that state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;displayMediaOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DisplayMediaStreamOptions&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;systemAudioEnabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;systemAudioEnabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;displayMediaOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;systemAudio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;displayMediaOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suppressLocalAudioPlayback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;screenStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDisplayMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;displayMediaOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;amp; Record&amp;lt;string, unknown&amp;gt;&lt;/code&gt; type intersection is a pragmatic choice. TypeScript's built-in &lt;code&gt;DisplayMediaStreamOptions&lt;/code&gt; doesn't include &lt;code&gt;systemAudio&lt;/code&gt; or &lt;code&gt;suppressLocalAudioPlayback&lt;/code&gt; — they're Chrome-specific extensions that haven't landed in the standard DOM types yet. Instead of creating a custom type declaration file for two properties, we use a type intersection that allows setting arbitrary keys.&lt;/p&gt;

&lt;p&gt;When audio is toggled off, we pass &lt;code&gt;audio: false&lt;/code&gt; and skip the extra constraints entirely. The browser won't request any audio, and the recording is video-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser compatibility
&lt;/h2&gt;

&lt;p&gt;Here's what happens on each browser:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;&lt;code&gt;systemAudio&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;suppressLocalAudioPlayback&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome 105+&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;td&gt;System + tab audio captured, local playback suppressed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge 105+&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;td&gt;Same as Chrome (Chromium-based)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;Ignored&lt;/td&gt;
&lt;td&gt;Ignored&lt;/td&gt;
&lt;td&gt;Tab audio may work on some configs, no system audio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari&lt;/td&gt;
&lt;td&gt;Ignored&lt;/td&gt;
&lt;td&gt;Ignored&lt;/td&gt;
&lt;td&gt;No audio capture from &lt;code&gt;getDisplayMedia&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key insight: &lt;strong&gt;browsers silently ignore unknown constraints in &lt;code&gt;getDisplayMedia()&lt;/code&gt;&lt;/strong&gt;. They don't throw errors or reject the promise. Firefox and Safari simply skip &lt;code&gt;systemAudio&lt;/code&gt; and &lt;code&gt;suppressLocalAudioPlayback&lt;/code&gt; and proceed as if they weren't there. This means we can safely pass these constraints on all browsers without feature detection.&lt;/p&gt;

&lt;p&gt;No &lt;code&gt;try/catch&lt;/code&gt;, no &lt;code&gt;if (typeof systemAudio !== 'undefined')&lt;/code&gt;, no user-agent sniffing. The API was designed this way — unknown constraints are a no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  No backend changes
&lt;/h2&gt;

&lt;p&gt;Our server-side video pipeline already handled audio correctly. The ffmpeg command for compositing screen + webcam recordings uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; screen.mp4 &lt;span class="nt"&gt;-i&lt;/span&gt; webcam.webm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-map&lt;/span&gt; &lt;span class="s2"&gt;"0:a?"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;?&lt;/code&gt; suffix on &lt;code&gt;0:a?&lt;/code&gt; means "include the audio track from input 0 if it exists, otherwise skip it." When audio is present, it gets included. When it's not, ffmpeg doesn't fail — it just produces a video-only output. This pattern was already in place from the webcam compositing work, so system audio flows through the pipeline with zero changes.&lt;/p&gt;

&lt;p&gt;The transcoding step similarly handles audio transparently. MP4 outputs get AAC audio. WebM outputs copy the original codec. If there's no audio track, both paths produce video-only files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toggle
&lt;/h2&gt;

&lt;p&gt;We added an "Audio On/Off" button to the recorder's idle UI, sitting between the Camera toggle and the Start Recording button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Camera Off] [Audio On] [Start Recording]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It follows the exact same styling pattern as the Camera toggle — accent background when on, transparent with border when off. The toggle only appears in the idle state and disappears during recording, countdown, and paused states, which is the natural behavior since the idle UI section is already conditionally rendered.&lt;/p&gt;

&lt;p&gt;When audio is on, the button reads "Audio On" with the accent background. When off, "Audio Off" with a transparent background and border. The aria-label switches between "Disable system audio" and "Enable system audio" for accessibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about microphone audio?
&lt;/h2&gt;

&lt;p&gt;System audio from &lt;code&gt;getDisplayMedia&lt;/code&gt; and microphone audio from &lt;code&gt;getUserMedia&lt;/code&gt; are separate streams. We deliberately don't mix them. If someone wants narration over their screen recording, they use the webcam overlay — it records separately with its own audio track and gets composited server-side.&lt;/p&gt;

&lt;p&gt;Mixing two audio streams in the browser would require a Web Audio API &lt;code&gt;AudioContext&lt;/code&gt; with a &lt;code&gt;MediaStreamAudioDestinationNode&lt;/code&gt; to combine sources. That's significant complexity for a marginal benefit, especially when server-side compositing already handles multiple tracks cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  macOS caveat
&lt;/h2&gt;

&lt;p&gt;On macOS, Chrome can capture tab audio but not system-wide audio from other applications without additional OS-level permissions. The &lt;code&gt;systemAudio: "include"&lt;/code&gt; constraint is a hint to the browser — it tells the share picker to offer audio options. If the OS doesn't support system audio capture, the browser simply doesn't offer it. The recording still succeeds; it just won't have audio from non-browser sources.&lt;/p&gt;

&lt;p&gt;On Windows and Linux, system audio capture generally works without additional setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sendrec.eu" rel="noopener noreferrer"&gt;SendRec&lt;/a&gt; is open source (AGPL-3.0) and self-hostable. Record a screen with system audio at &lt;a href="https://app.sendrec.eu" rel="noopener noreferrer"&gt;app.sendrec.eu&lt;/a&gt;. Pull the image from &lt;a href="https://hub.docker.com/r/alexneamtu/sendrec" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt;, check the &lt;a href="https://github.com/sendrec/sendrec/blob/main/SELF-HOSTING.md" rel="noopener noreferrer"&gt;Self-Hosting Guide&lt;/a&gt;, or browse the &lt;a href="https://github.com/sendrec/sendrec" rel="noopener noreferrer"&gt;source code&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>react</category>
    </item>
    <item>
      <title>How We Built a Viewer Engagement Heatmap</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Tue, 24 Feb 2026 19:59:06 +0000</pubDate>
      <link>https://forem.com/alexneamtu/how-we-built-a-viewer-engagement-heatmap-342b</link>
      <guid>https://forem.com/alexneamtu/how-we-built-a-viewer-engagement-heatmap-342b</guid>
      <description>&lt;p&gt;We already had view counts and a completion funnel showing how many viewers reached 25%, 50%, 75%, and 100%. That tells you whether people finish your video, but not &lt;em&gt;where&lt;/em&gt; they lose interest. A five-minute product walkthrough might have great completion rates while everyone skips the two-minute intro.&lt;/p&gt;

&lt;p&gt;The engagement heatmap fixes that. It splits the video timeline into 50 segments and shows how many viewers watched each one. Bright segments mean high engagement. Faint segments mean drop-off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;Each video gets up to 50 rows in a new &lt;code&gt;segment_engagement&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;segment_engagement&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;video_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;segment_index&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;segment_index&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;segment_index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;watch_count&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;video_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;segment_index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The composite primary key means no separate ID column and no index to maintain — the primary key &lt;em&gt;is&lt;/em&gt; the lookup path. Each segment covers roughly 2% of the video duration.&lt;/p&gt;

&lt;p&gt;We considered adding a &lt;code&gt;day&lt;/code&gt; column for time-series breakdowns but decided against it. The heatmap answers "which parts of my video work?" — that question doesn't change much day to day. Keeping it aggregate means at most 50 rows per video, and queries stay simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  Client-side tracking
&lt;/h2&gt;

&lt;p&gt;The tracking runs as a self-contained IIFE on both watch and embed pages. The core logic hooks into the player's &lt;code&gt;timeupdate&lt;/code&gt; event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeupdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;SEGMENTS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;SEGMENTS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reported&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastSeg&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastSeg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;lastSeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;reported&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;lastSeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things happen here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client-side dedup.&lt;/strong&gt; The &lt;code&gt;reported&lt;/code&gt; object tracks which segments this viewer has already counted. Each segment fires at most once per page load. If someone rewinds and watches segment 12 again, it doesn't inflate the count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seek detection.&lt;/strong&gt; If the current segment jumps by more than 1 from the last segment, the viewer seeked. We skip that segment — it wasn't watched through natural playback, so counting it would distort the heatmap. The next naturally-played segment will be counted normally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch collection.&lt;/strong&gt; Segments accumulate in the &lt;code&gt;pending&lt;/code&gt; array instead of firing a request per segment. A flush function sends the batch every 5 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/watch/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;shareToken&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/segments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/watch/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;shareToken&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/segments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;
        &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flush runs on an interval, but also fires on &lt;code&gt;pause&lt;/code&gt;, &lt;code&gt;ended&lt;/code&gt;, &lt;code&gt;visibilitychange&lt;/code&gt;, and &lt;code&gt;beforeunload&lt;/code&gt;. The &lt;code&gt;sendBeacon&lt;/code&gt; API is important here — regular &lt;code&gt;fetch&lt;/code&gt; requests get canceled when the page unloads, but &lt;code&gt;sendBeacon&lt;/code&gt; is designed for exactly this use case. It queues the request for delivery even after the page closes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server-side upsert
&lt;/h2&gt;

&lt;p&gt;The endpoint validates the segments, looks up the video, then fires a goroutine to upsert the counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Segments&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;`INSERT INTO segment_engagement (video_id, segment_index, watch_count)
             VALUES ($1, $2, 1)
             ON CONFLICT (video_id, segment_index)
             DO UPDATE SET watch_count = segment_engagement.watch_count + 1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;videoID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler returns &lt;code&gt;204 No Content&lt;/code&gt; immediately. The upsert runs in the background so tracking never slows down video playback. The &lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt; pattern means the first view creates the row, and every subsequent view increments the counter — no read-before-write, no race conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalization in the analytics API
&lt;/h2&gt;

&lt;p&gt;Raw watch counts aren't useful for visualization. Segment 0 might have 200 views while segment 49 has 15. The analytics endpoint normalizes counts to a 0.0–1.0 intensity scale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;maxCount&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sd&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WatchCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;maxCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WatchCount&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;maxCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Intensity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WatchCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The highest segment always gets intensity 1.0. Everything else is relative. This means the heatmap always shows useful contrast regardless of whether the video has 5 views or 5,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visualization
&lt;/h2&gt;

&lt;p&gt;The frontend renders 50 equal-width &lt;code&gt;div&lt;/code&gt; elements, each using the brand accent color with opacity mapped to intensity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heatmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segment&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intensity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intensity&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
            &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%-&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;watchCount&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; views`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;var(--color-accent)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intensity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No chart library, no canvas, no SVG. Just 50 divs in a flex container with varying opacity. The minimum opacity of 0.08 keeps empty segments visible so you can see the full timeline shape. Hovering any segment shows the exact percentage range and view count.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it tells you
&lt;/h2&gt;

&lt;p&gt;The heatmap answers questions that view counts and completion rates can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where do viewers drop off?&lt;/strong&gt; A steep drop after the intro means your hook isn't working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What gets rewatched?&lt;/strong&gt; Segments with counts higher than the start segment mean people sought back to rewatch them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the ending matter?&lt;/strong&gt; If the last few segments are faint, viewers stop before your call to action.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Combined with the existing completion funnel and per-viewer tracking, you can now see both the &lt;em&gt;what&lt;/em&gt; (which parts get watched) and the &lt;em&gt;who&lt;/em&gt; (which viewers watched how much).&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sendrec.eu" rel="noopener noreferrer"&gt;SendRec&lt;/a&gt; is open source (AGPL-3.0) and self-hostable. Share a video, check the analytics page, and see where your viewers pay attention. Pull the image from &lt;a href="https://hub.docker.com/r/alexneamtu/sendrec" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt;, check the &lt;a href="https://github.com/sendrec/sendrec/blob/main/SELF-HOSTING.md" rel="noopener noreferrer"&gt;Self-Hosting Guide&lt;/a&gt;, or try it at &lt;a href="https://app.sendrec.eu" rel="noopener noreferrer"&gt;app.sendrec.eu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>typescript</category>
      <category>analytics</category>
      <category>webdev</category>
    </item>
    <item>
      <title>SendRec Is Now on Docker Hub and Unraid</title>
      <dc:creator>Alex Neamtu</dc:creator>
      <pubDate>Tue, 24 Feb 2026 18:33:42 +0000</pubDate>
      <link>https://forem.com/alexneamtu/sendrec-is-now-on-docker-hub-and-unraid-24ap</link>
      <guid>https://forem.com/alexneamtu/sendrec-is-now-on-docker-hub-and-unraid-24ap</guid>
      <description>&lt;p&gt;A user &lt;a href="https://github.com/sendrec/sendrec/issues/70" rel="noopener noreferrer"&gt;opened an issue&lt;/a&gt; asking for an Unraid community app template. They wanted to install SendRec on their Unraid server, but without a template in Community Applications, setup meant manually configuring a Docker container with a dozen environment variables. We fixed that — and while we were at it, we published the Docker image to Docker Hub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Hub
&lt;/h2&gt;

&lt;p&gt;Until now, SendRec's Docker image was only built on the server during deployment. There was no public image to pull. If you wanted to self-host, you had to clone the repo and build it yourself.&lt;/p&gt;

&lt;p&gt;We added a GitHub Actions workflow that builds and pushes the image on every version tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;build-args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;VERSION=${{ github.ref_name }}&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;alexneamtu/sendrec:latest&lt;/span&gt;
      &lt;span class="s"&gt;alexneamtu/sendrec:${{ steps.version.outputs.version }}&lt;/span&gt;
      &lt;span class="s"&gt;ghcr.io/sendrec/sendrec:latest&lt;/span&gt;
      &lt;span class="s"&gt;ghcr.io/sendrec/sendrec:${{ steps.version.outputs.version }}&lt;/span&gt;
    &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha&lt;/span&gt;
    &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every release now publishes to both &lt;a href="https://hub.docker.com/r/alexneamtu/sendrec" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt; and GitHub Container Registry. The build uses Docker Buildx with GitHub Actions cache, so subsequent builds only rebuild changed layers.&lt;/p&gt;

&lt;p&gt;Self-hosting is now one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull alexneamtu/sendrec:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You still need PostgreSQL and S3-compatible storage — the &lt;a href="https://github.com/sendrec/sendrec/blob/main/SELF-HOSTING.md" rel="noopener noreferrer"&gt;Self-Hosting Guide&lt;/a&gt; covers the full setup with Docker Compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Unraid template
&lt;/h2&gt;

&lt;p&gt;Unraid Community Applications uses XML templates to describe how a container should be configured. Each &lt;code&gt;&amp;lt;Config&amp;gt;&lt;/code&gt; element becomes a form field in the Unraid UI — ports, volumes, environment variables — so users can fill in values and click Apply without touching the command line.&lt;/p&gt;

&lt;p&gt;The template for SendRec lives at &lt;a href="https://github.com/sendrec/unraid-templates" rel="noopener noreferrer"&gt;sendrec/unraid-templates&lt;/a&gt;. Here's what the configuration looks like:&lt;/p&gt;

&lt;h3&gt;
  
  
  Required settings
&lt;/h3&gt;

&lt;p&gt;These appear in the basic install view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"Database URL"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"DATABASE_URL"&lt;/span&gt;
        &lt;span class="na"&gt;Default=&lt;/span&gt;&lt;span class="s"&gt;"postgres://sendrec:secret@192.168.1.X:5432/sendrec?sslmode=disable"&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Variable"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"always"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"JWT Secret"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"JWT_SECRET"&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Variable"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"always"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"Base URL"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"BASE_URL"&lt;/span&gt;
        &lt;span class="na"&gt;Default=&lt;/span&gt;&lt;span class="s"&gt;"http://192.168.1.X:8080"&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Variable"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"always"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sensitive fields like &lt;code&gt;JWT_SECRET&lt;/code&gt; and &lt;code&gt;S3_SECRET_KEY&lt;/code&gt; use &lt;code&gt;Mask="true"&lt;/code&gt; to hide values in the UI. The &lt;code&gt;Required="true"&lt;/code&gt; attribute prevents installation if the field is empty.&lt;/p&gt;

&lt;h3&gt;
  
  
  S3 storage
&lt;/h3&gt;

&lt;p&gt;SendRec stores videos in S3-compatible object storage. The template includes fields for endpoint, bucket, credentials, and region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"S3 Endpoint"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"S3_ENDPOINT"&lt;/span&gt;
        &lt;span class="na"&gt;Default=&lt;/span&gt;&lt;span class="s"&gt;"http://192.168.1.X:3900"&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Variable"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"always"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"S3 Bucket"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"S3_BUCKET"&lt;/span&gt;
        &lt;span class="na"&gt;Default=&lt;/span&gt;&lt;span class="s"&gt;"sendrec"&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Variable"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"always"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For S3-compatible providers like Garage, MinIO, or Backblaze B2, the checksum settings are pre-configured in the advanced view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"S3 Checksum Calculation"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"AWS_REQUEST_CHECKSUM_CALCULATION"&lt;/span&gt;
        &lt;span class="na"&gt;Default=&lt;/span&gt;&lt;span class="s"&gt;"when_required"&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Variable"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"advanced"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Usage limits
&lt;/h3&gt;

&lt;p&gt;Self-hosters typically want no limits. The template defaults all limits to &lt;code&gt;0&lt;/code&gt; (unlimited):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"Max Videos per Month"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"MAX_VIDEOS_PER_MONTH"&lt;/span&gt;
        &lt;span class="na"&gt;Default=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Variable"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"always"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"Max Video Duration (seconds)"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"MAX_VIDEO_DURATION_SECONDS"&lt;/span&gt;
        &lt;span class="na"&gt;Default=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Variable"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"always"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Optional features
&lt;/h3&gt;

&lt;p&gt;Transcription and AI summaries are configurable through the template. The whisper model gets mounted as a read-only volume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"Whisper Model"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"/models"&lt;/span&gt; &lt;span class="na"&gt;Default=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="na"&gt;Mode=&lt;/span&gt;&lt;span class="s"&gt;"ro"&lt;/span&gt;
        &lt;span class="na"&gt;Description=&lt;/span&gt;&lt;span class="s"&gt;"Path to directory containing the whisper model file (ggml-small.bin, ~466 MB)."&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Path"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"always"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;Config&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"AI Enabled"&lt;/span&gt; &lt;span class="na"&gt;Target=&lt;/span&gt;&lt;span class="s"&gt;"AI_ENABLED"&lt;/span&gt;
        &lt;span class="na"&gt;Default=&lt;/span&gt;&lt;span class="s"&gt;"true|false"&lt;/span&gt;
        &lt;span class="na"&gt;Type=&lt;/span&gt;&lt;span class="s"&gt;"Variable"&lt;/span&gt; &lt;span class="na"&gt;Display=&lt;/span&gt;&lt;span class="s"&gt;"always"&lt;/span&gt; &lt;span class="na"&gt;Required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="na"&gt;Mask=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Default="true|false"&lt;/code&gt; syntax creates a dropdown selector in the Unraid UI instead of a free-text field.&lt;/p&gt;

&lt;h3&gt;
  
  
  Display modes
&lt;/h3&gt;

&lt;p&gt;Unraid templates support two display modes: &lt;code&gt;always&lt;/code&gt; for settings most users need, and &lt;code&gt;advanced&lt;/code&gt; for settings they typically don't. We put the core settings (database, S3, limits) in the basic view and less common options (checksum settings, AI model name, frame ancestors) in the advanced view. This keeps the install dialog manageable while still exposing every configuration option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites warning
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;Requires&amp;gt;&lt;/code&gt; element shows a warning in the Unraid install dialog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Requires&amp;gt;&lt;/span&gt;PostgreSQL 16+ and S3-compatible storage (Garage, MinIO, AWS S3, etc.)&lt;span class="nt"&gt;&amp;lt;/Requires&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells users they need to install PostgreSQL and an S3 provider before installing SendRec — both are available as separate containers in the Unraid CA store.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The template repo is ready. The remaining steps to get into Unraid Community Applications are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a support thread on the &lt;a href="https://forums.unraid.net/" rel="noopener noreferrer"&gt;Unraid forums&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Submit via the &lt;a href="https://docs.unraid.net/unraid-os/using-unraid-to/run-docker-containers/community-applications/#publishing-workflow" rel="noopener noreferrer"&gt;Community Applications form&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Wait for moderation review (typically 48 hours)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the meantime, Unraid users can install SendRec manually by adding the template repository URL in the Docker tab, or by pulling the image directly from Docker Hub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sendrec.eu" rel="noopener noreferrer"&gt;SendRec&lt;/a&gt; is open source (AGPL-3.0) and self-hostable. Pull the image from &lt;a href="https://hub.docker.com/r/alexneamtu/sendrec" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt;, check the &lt;a href="https://github.com/sendrec/sendrec/blob/main/SELF-HOSTING.md" rel="noopener noreferrer"&gt;Self-Hosting Guide&lt;/a&gt;, or try it at &lt;a href="https://app.sendrec.eu" rel="noopener noreferrer"&gt;app.sendrec.eu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosted</category>
      <category>opensource</category>
      <category>unraid</category>
    </item>
  </channel>
</rss>
