You share a screen recording and wait. Did they watch it? You could check the analytics dashboard, but that requires you to go look. What you really want is a tap on the shoulder: "Hey, someone just watched your video."
That's view notifications. We added them to SendRec so you get an email when someone watches your recording. But "email me when someone watches" sounds simple until you think about the edge cases. What if your video gets 50 views in an hour? What if you only care about the first view? What if you want a summary instead of a firehose?
Four modes, not one
We settled on four notification modes:
- Off — no notifications (the default)
- Every view — email on each view, instantly
- First view only — email when the first person watches, then silence
- Daily digest — one email per day at 9 AM UTC summarizing all views
"Every view" is useful for a targeted recording — you sent a video to one person and want to know the moment they watch it. "First view" works for team announcements — you care that someone on the team saw it, but you don't need 15 separate emails. "Digest" is for people who publish a lot of recordings and want a morning summary without inbox noise.
We considered adding "weekly digest" but left it out. Four modes already cover the spectrum from silence to real-time. YAGNI.
Two tiers of preferences
The first question was where to store the preference. Per user? Per video?
Both. We built a two-tier system:
-
Account default — stored in a
notification_preferencestable, applies to all your videos -
Per-video override — a nullable column on the
videostable, overrides the account default for that specific video
CREATE TABLE notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id),
view_notification TEXT NOT NULL DEFAULT 'off'
CHECK (view_notification IN ('off', 'every', 'first', 'digest'))
);
ALTER TABLE videos ADD COLUMN view_notification TEXT DEFAULT NULL
CHECK (view_notification IN ('off', 'every', 'first', 'digest'));
The videos.view_notification column is nullable on purpose. NULL means "use the account default." A non-null value means "override it for this video." This lets you set a global default ("digest for everything") and then flip individual videos to "every" when you're waiting on a specific person to watch.
Resolving which mode applies uses a simple cascade:
mode := "off"
if videoViewNotification != nil {
mode = *videoViewNotification
} else {
var accountMode string
err := h.db.QueryRow(ctx,
`SELECT view_notification FROM notification_preferences WHERE user_id = $1`,
ownerID,
).Scan(&accountMode)
if err == nil {
mode = accountMode
}
}
Video override wins. If there's no override, check the account default. If there's no account preference either, default to "off." No notification surprises for new users.
Triggering notifications on view
When someone opens a watch page, we record the view and decide whether to notify. Both happen in a background goroutine so the viewer's page load isn't blocked:
go func() {
ip := clientIP(r)
hash := viewerHash(ip, r.UserAgent())
h.db.Exec(ctx,
`INSERT INTO video_views (video_id, viewer_hash) VALUES ($1, $2)`,
videoID, hash,
)
h.resolveAndNotify(videoID, ownerID, ownerEmail, ownerName,
title, shareToken, viewerUserID, viewNotification)
}()
The resolveAndNotify function resolves the preference cascade, then handles the mode:
- "off" or "digest": return immediately. Digest is handled by a separate worker (more on that below).
- "every": send the email now.
- "first": count existing views first. Only send if this is view number one.
if mode == "first" {
var viewCount int64
h.db.QueryRow(ctx,
`SELECT COUNT(*) FROM video_views WHERE video_id = $1`,
videoID,
).Scan(&viewCount)
if viewCount != 1 {
return
}
}
There's a small race window here. If two people open the video at the exact same moment, both goroutines might see viewCount == 1 and both send a notification. We acknowledged it and moved on — at our current scale, this is a non-issue. If it ever matters, an advisory lock or a first_view_notified flag column would fix it.
Skipping self-views
One thing we got right early: don't notify the owner when they watch their own video. You'd be surprised how annoying this is if you forget it.
We identify the viewer by checking the refresh token cookie on the watch page request:
func (h *Handler) viewerUserIDFromRequest(r *http.Request) string {
cookie, err := r.Cookie("refresh_token")
if err != nil {
return ""
}
claims, err := auth.ValidateToken(h.hmacSecret, cookie.Value)
if err != nil || claims.TokenType != "refresh" {
return ""
}
return claims.UserID
}
If the viewer's user ID matches the video owner's user ID, we skip the notification entirely. Anonymous viewers (no cookie) always trigger notifications since they can't be the owner.
The daily digest worker
Instant notifications (every and first) fire inline when a view happens. Digests are different — they batch 24 hours of views into a single email per user, sent at 9 AM UTC.
The digest worker is a background goroutine that sleeps until the next 9 AM:
const digestHourUTC = 9
func durationUntilNextRun(now time.Time) time.Duration {
next := time.Date(now.Year(), now.Month(), now.Day(),
digestHourUTC, 0, 0, 0, time.UTC)
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next.Sub(now)
}
When it wakes, it runs one query that does all the work — find every video set to digest mode that got views in the last 24 hours, grouped by user:
SELECT v.id, v.title, v.share_token, v.user_id, u.email, u.name,
COUNT(vv.id) as view_count
FROM video_views vv
JOIN videos v ON v.id = vv.video_id
JOIN users u ON u.id = v.user_id
LEFT JOIN notification_preferences np ON np.user_id = v.user_id
WHERE vv.created_at >= NOW() - INTERVAL '24 hours'
AND COALESCE(v.view_notification, np.view_notification, 'off') = 'digest'
AND v.status != 'deleted'
GROUP BY v.id, v.title, v.share_token, v.user_id, u.email, u.name
ORDER BY v.user_id, view_count DESC
The COALESCE does the same preference cascade as the Go code: check the video override first, fall back to the account default, default to "off." But it does it in SQL, which means we don't need to load every video and preference row into Go memory — the database filters to only digest-mode videos with recent views.
The results are grouped by user, and each user gets a single email listing their videos sorted by view count. If you have 5 videos that got views yesterday, you get one email with all 5 — not five separate emails.
The frontend: two places to set preferences
Users set their account default in Settings via a dropdown:
View notifications: [Off | Every view | First view only | Daily digest]
Per-video overrides live in the Library, on each video card. A small dropdown lets you set the mode for that specific video, with an "Account default" option that clears the override:
<select
value={video.viewNotification ?? ""}
onChange={(e) => changeNotification(video, e.target.value)}
>
<option value="">Account default</option>
<option value="off">Off</option>
<option value="every">Every view</option>
<option value="first">First view only</option>
<option value="digest">Daily digest</option>
</select>
Empty string means "inherit the account default." Selecting a specific mode saves it as a per-video override. Switching back to "Account default" sends null to the API, clearing the override column.
Both save optimistically — the UI updates immediately, and if the API call fails, it reverts to the previous value.
What we didn't build
Webhook notifications. An API that POSTs to a URL on each view would let teams pipe events to Slack or their own systems. Useful eventually, but email covers the core use case today.
In-app notifications. A notification bell in the UI with unread counts. This requires WebSockets or polling, a notification queue, read/unread state tracking — a lot of infrastructure for a feature that only matters when the user is already in the app. Email reaches them wherever they are.
Per-viewer identification in notifications. The email says "someone watched your video," not "Alice watched your video." We identify unique viewers by hashing IP + user agent, which preserves privacy. Showing viewer names would require authenticated viewing, which is a team workspaces feature we haven't built yet.
Try it
SendRec is open source (AGPL-3.0) and self-hostable. View notifications are live at app.sendrec.eu — go to Settings, pick a notification mode, share a video, and see who's watching. The notification code is in notification.go and the digest worker in digest_worker.go.
Top comments (0)