Skip to content

Commit 71fcd21

Browse files
Sbussisoclaude
andcommitted
email(v1.1): motion notifications with per-camera cooldown + digest
The biggest gap in the v1 email surface — motion was the only notification kind every consumer competitor (Ring, Nest, Wyze) sends emails for, and the only one we deferred at v1 because raw 1:1 motion-to-email would tank Resend sender reputation across all customers the moment a flappy outdoor camera triggered. The user-proposed cooldown-batch design solves this cleanly: first event per camera fires immediately, subsequent in-window events are silenced, a single "X more motion events" digest emits at window expiry. Volume cap: 2 emails per cycle per camera regardless of event count. User decisions (collected pre-implementation): - Default OFF for the new toggle (deliberate inversion of every other email kind — protects sender reputation against unknown per-org volume profiles) - Default cooldown 15 minutes per camera (env-overridable; not exposed in v1.1 UI) Backend ------- - app/api/notifications.py: * Added 2 kinds to _EMAIL_KIND_TO_SETTING: "motion" → email_motion (default False) "motion_digest" → email_motion (default False) Both share one toggle so users opt in once for the whole mechanism (matches the camera_offline+camera_online, mcp_key_create+revoke, member_added+role_changed+removed precedents). * Added "motion_digest" to _NOTIFICATION_KIND_TO_SETTING mapped to motion_notifications — muting motion in the bell-icon panel also hides digest banners. * Added 3 helpers: _motion_cooldown_anchor_key(camera_id) — returns the colon-suffixed Setting key. Mirrors the cloudnode_disk_low_emit_at:{node_id} precedent. _motion_cooldown_minutes(db, org_id) — reads the per-org Setting with int parse + 15-min fallback. _claim_motion_cooldown_or_silence(db, org_id, camera_id) — returns True (caller should email) and writes the anchor on first event after expiry; False (silence) while anchor is fresh. Defensive None-camera-id guard. * Wrapped the email enqueue branch in create_notification so kind=="motion" routes through the cooldown gate while every other kind preserves its existing behavior unchanged. * Added email_motion field to EmailPreferences pydantic. - app/main.py: * New constant MOTION_DIGEST_INTERVAL_SECONDS=60. * New _motion_digest_loop() async background task — opens its own SessionLocal per tick (mirrors _disk_check_loop pattern), finds active anchors via Setting.key.like(...) prefix query, counts MotionEvent rows in each expired window via the existing time-windowed query pattern from app/api/motion.py. For each expired anchor: if extras present AND email still enabled, emits motion_digest via create_notification (which enqueues the digest email); deletes the anchor regardless. Per-anchor try/except so one corrupt row can't poison the tick, outer try/except so the loop survives transient failures. * Wired into lifespan() alongside the other background loops with matching cancellation in the shutdown block. Templates (6 new files in app/templates/emails/) ------------------------------------------------ - motion.subject.txt.j2 / .body.txt.j2 / .body.html.j2 Immediate "first motion" alert. Green severity bar, mentions intensity score, explicit callout that the cooldown is now active and the user won't get a flood. - motion_digest.subject.txt.j2 / .body.txt.j2 / .body.html.j2 Window-close summary. Blue informational callout, displays event count + window times. Settings UI ----------- - frontend/src/pages/SettingsPage.jsx: * Added 7th toggle "Motion detection (with digest)" with description explaining the cooldown+digest behavior. * Updated section header copy: 6 default ON, motion default OFF (called out in the section description). Docs + legal sweep ------------------ - frontend/src/pages/docs/Notifications.jsx, Faq.jsx, PricingPage.jsx, SecurityPage.jsx — all stale "motion email deferred" copy replaced with accurate v1.1 description. - docs/legal/SUB_PROCESSORS.md, DPA.md — Resend section now lists motion alongside the other operator-critical kinds. Memory note ----------- - project_notification_channels.md: added motion + motion_digest to the kind list (now 12 kinds gated by 7 settings, six default ON, motion default OFF). New "Motion email cooldown" section documents the anchor mechanism end-to-end so future sessions don't accidentally remove it. Tests (24 new across 2 new files + 1 extension) ----------------------------------------------- - tests/test_motion_email_cooldown.py (13 tests): * default-OFF behavior (the critical safety call) * kill-switch off skips email AND anchor write * first event sends immediate + writes anchor * subsequent in-window events silenced (inbox + SSE still fire) * post-expiry resumes immediate + overwrites anchor * per-camera independence (anchor on A doesn't suppress B) * inbox-disabled short-circuits everything (no anchor written) * email-disabled skips anchor (so flipping on later starts clean) * malformed anchor recovers, malformed cooldown_minutes falls back * helper unit tests for anchor key format + None-camera defensive - tests/test_motion_digest_loop.py (11 tests): * digest emits when extras present * digest silent when no extras (anchor still deleted) * digest skips open windows (anchor preserved) * email_motion=false at digest time → no email, anchor deleted * inbox-mute at digest time → no notification, anchor deleted * orphan anchor for deleted camera → silent cleanup * cooldown_minutes change mid-window honored at digest time * per-org independence (only enabled orgs digest) * malformed + empty anchor values dropped without crashing * count uses strict > anchor_ts (excludes the immediate event itself) — pins the off-by-one boundary that drives user- facing copy - tests/test_notifications.py extension: default-prefs test now asserts email_motion=False (the lone default-OFF kind). Full suite: 450 passed (was 426, +24 net). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c894c03 commit 71fcd21

18 files changed

Lines changed: 1380 additions & 41 deletions

backend/app/api/notifications.py

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646

4747
_NOTIFICATION_KIND_TO_SETTING: dict[str, tuple[str, bool]] = {
4848
"motion": ("motion_notifications", True),
49+
# motion_digest shares motion's inbox toggle — if you've muted
50+
# motion in the bell-icon panel, you don't want a digest banner
51+
# either. Generated by _motion_digest_loop in app/main.py when
52+
# an email cooldown window expires with extra events accumulated.
53+
"motion_digest": ("motion_notifications", True),
4954
"camera_online": ("camera_transition_notifications", True),
5055
"camera_offline": ("camera_transition_notifications", True),
5156
"node_online": ("node_transition_notifications", True),
@@ -84,9 +89,11 @@
8489
# "offline" events are — recovery is good news that doesn't need
8590
# a midnight email. Keeps the inbox feed and the email feed
8691
# intentionally different shapes.
87-
# - Motion is omitted entirely from this map for v1. Adding it
88-
# before the digest/cooldown work in v1.1 is the difference
89-
# between "useful" and "unsubscribed within an hour."
92+
# - Motion is in the map but defaults FALSE — see the motion +
93+
# motion_digest entries below for the full rationale. v1.1
94+
# ships motion email with per-camera cooldown + digest summary
95+
# so volume is bounded; we still default-off because per-org
96+
# volume variance is too high to opt users in by default.
9097
# - All other defaults are True so an org that hasn't visited the
9198
# settings page still gets the operator-critical alerts.
9299
#
@@ -124,6 +131,21 @@
124131
"member_added": ("email_member_audit", True),
125132
"member_role_changed": ("email_member_audit", True),
126133
"member_removed": ("email_member_audit", True),
134+
# Motion detection — high volume, deferred to v1.1 cooldown design.
135+
# Both kinds (immediate first-event "motion" + cooldown summary
136+
# "motion_digest") share email_motion. DEFAULT FALSE on purpose:
137+
# motion volume is wildly variable per customer (1 indoor doorbell
138+
# vs. 10 outdoor cameras), and opting users in by default risks
139+
# surprise day-one volume on outdoor cams → spam-marks → tanks
140+
# Resend sender reputation for ALL email kinds across ALL customers.
141+
# Customers explicitly opt in via the Settings UI toggle.
142+
# Volume cap mechanism: app/api/notifications.py::_claim_motion_cooldown_or_silence
143+
# silences subsequent events within the cooldown window per camera;
144+
# app/main.py::_motion_digest_loop emits ONE summary at window
145+
# close. Worst case: 2 emails per cycle per camera regardless of
146+
# event volume.
147+
"motion": ("email_motion", False),
148+
"motion_digest": ("email_motion", False),
127149
}
128150
# Note: ``disk_critical`` is intentionally NOT in this map. Disk-full
129151
# is platform infrastructure state (our Fly volume) — irrelevant to
@@ -181,6 +203,95 @@ def email_enabled_for_kind(db: Session, org_id: str, kind: str) -> bool:
181203
return val == "true"
182204

183205

206+
# ── Motion email cooldown ──────────────────────────────────────────
207+
# Per-camera cooldown anchor mechanism. Used by the email branch of
208+
# create_notification (when kind="motion") to silence per-event emails
209+
# that arrive during an active window, AND by ``_motion_digest_loop``
210+
# in app/main.py to find expired anchors and emit the summary.
211+
#
212+
# Anchor data: a Setting row per (org_id, camera_id) where the key is
213+
# ``motion_email_cooldown_start:{camera_id}`` and the value is the ISO
214+
# timestamp of when the immediate email was sent. The colon-suffix
215+
# pattern matches the ``cloudnode_disk_low_emit_at:{node_id}``
216+
# precedent in app/api/nodes.py:72-121.
217+
#
218+
# Lifecycle: written by _claim_motion_cooldown_or_silence on the first
219+
# motion event after expiry, read by both subsequent in-window events
220+
# (which silence) and the digest loop (which emits + deletes), deleted
221+
# by the digest loop after the window closes. Persistent across
222+
# process restarts which is the whole reason for using Setting over an
223+
# in-memory dict — a deploy mid-window must NOT re-spam users.
224+
225+
226+
def _motion_cooldown_anchor_key(camera_id: str) -> str:
227+
"""Setting key for the per-camera motion email cooldown anchor.
228+
229+
Mirrors the ``cloudnode_disk_low_emit_at:{node_id}`` pattern in
230+
app/api/nodes.py — colon-suffix so the digest loop can find all
231+
active anchors with a single ``Setting.key.like("motion_email_cooldown_start:%")``
232+
query, and parse the camera_id back out by splitting on ":".
233+
"""
234+
return f"motion_email_cooldown_start:{camera_id}"
235+
236+
237+
def _motion_cooldown_minutes(db: Session, org_id: str) -> int:
238+
"""Per-org cooldown duration in minutes. Default 15.
239+
240+
Hidden setting for v1.1 — operators set it via direct DB write or
241+
a future env-bootstrap. Min 1 (no point in zero — the immediate
242+
fires anyway). Falls back to 15 on any parse failure so a
243+
corrupt value can't disable emails entirely.
244+
"""
245+
raw = Setting.get(db, org_id, "email_motion_cooldown_minutes", "15")
246+
try:
247+
return max(1, int(raw))
248+
except (ValueError, TypeError):
249+
return 15
250+
251+
252+
def _claim_motion_cooldown_or_silence(
253+
db: Session, org_id: str, camera_id: Optional[str]
254+
) -> bool:
255+
"""Returns True if this event should email immediately (and writes
256+
the anchor as a side effect); False if a cooldown anchor is active
257+
and the event should be silenced.
258+
259+
Concurrency note: read-then-write is non-atomic in SQLite without
260+
an explicit transaction. Two motion events arriving in the same
261+
microsecond could both see "anchor missing" and both enqueue an
262+
immediate email. Worst case is one duplicate first-event email,
263+
which we accept (matches the cloudnode_disk_low precedent and is
264+
far better than silently losing alerts). If/when we go multi-
265+
replica, swap to a dedicated table with INSERT...ON CONFLICT.
266+
"""
267+
if camera_id is None:
268+
# Defensive — motion notifications today always carry a camera_id
269+
# (see ws.py emit), but a future caller without one shouldn't
270+
# silently break. Default to "send immediate" rather than
271+
# "silence" because a missing camera_id would also break the
272+
# digest loop's per-camera grouping.
273+
return True
274+
275+
key = _motion_cooldown_anchor_key(camera_id)
276+
existing = Setting.get(db, org_id, key, "")
277+
cooldown_min = _motion_cooldown_minutes(db, org_id)
278+
now = datetime.now(tz=timezone.utc).replace(tzinfo=None)
279+
280+
if existing:
281+
try:
282+
anchor = datetime.fromisoformat(existing)
283+
if (now - anchor).total_seconds() < cooldown_min * 60:
284+
return False # silenced — within active cooldown window
285+
except ValueError:
286+
# Malformed value (manual edit / corruption). Treat as
287+
# expired and overwrite below — better than refusing to
288+
# email forever.
289+
pass
290+
291+
Setting.set(db, org_id, key, now.isoformat())
292+
return True
293+
294+
184295
def _build_email_content(notif: Notification) -> tuple[str, str, str]:
185296
"""Render a notification into ``(subject, body_text, body_html)``
186297
via the Jinja2 templates in ``app/templates/emails/``.
@@ -436,7 +547,18 @@ def create_notification(
436547
# bell panel too.
437548
try:
438549
if email_enabled_for_kind(session, org_id, kind):
439-
_enqueue_email_for_notification(session, notif, audience)
550+
if kind == "motion":
551+
# v1.1 cooldown gate: only email the FIRST motion
552+
# event per camera per cooldown window. Subsequent
553+
# in-window events still hit the inbox + SSE above
554+
# but skip the email outbox. ``_motion_digest_loop``
555+
# in app/main.py picks up expired anchors and
556+
# emits a single ``motion_digest`` summary.
557+
if _claim_motion_cooldown_or_silence(session, org_id, camera_id):
558+
_enqueue_email_for_notification(session, notif, audience)
559+
# else: silenced — digest will summarise
560+
else:
561+
_enqueue_email_for_notification(session, notif, audience)
440562
except Exception:
441563
logger.exception(
442564
"[Notifications] email enqueue side-channel failed for kind=%s",
@@ -796,6 +918,7 @@ class EmailPreferences(BaseModel):
796918
email_mcp_key_audit: Optional[bool] = None
797919
email_cloudnode_disk_low: Optional[bool] = None
798920
email_member_audit: Optional[bool] = None
921+
email_motion: Optional[bool] = None
799922

800923

801924
def _current_email_prefs(db: Session, org_id: str) -> dict:

backend/app/main.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,16 @@
123123
os.getenv("DISK_CRITICAL_REEMIT_INTERVAL_SECONDS", str(6 * 3600))
124124
)
125125

126+
# Cadence for the motion-email digest sweep. 60s is fast enough that
127+
# a 15-minute cooldown's digest is delivered within ~1 minute of window
128+
# expiry (acceptable lag for "here's a summary of the last 15 min"),
129+
# but slow enough that the LIKE scan over Setting rows is a rounding
130+
# error. See app/api/notifications.py::_claim_motion_cooldown_or_silence
131+
# for the per-camera anchor mechanism this loop drains.
132+
MOTION_DIGEST_INTERVAL_SECONDS = int(
133+
os.getenv("MOTION_DIGEST_INTERVAL_SECONDS", "60")
134+
)
135+
126136

127137
@asynccontextmanager
128138
async def lifespan(app):
@@ -146,6 +156,13 @@ async def lifespan(app):
146156
# _check_and_emit_disk_critical for the rationale. Pure in-
147157
# memory debounce, no DB persistence.
148158
disk_check_task = asyncio.create_task(_disk_check_loop())
159+
# Motion-email digest sweep — drains expired per-camera cooldown
160+
# anchors written by the email-immediate path in
161+
# app/api/notifications.py::_claim_motion_cooldown_or_silence and
162+
# emits a single ``motion_digest`` notification per camera that
163+
# accumulated additional motion events during the cooldown window.
164+
# See _motion_digest_loop below for the full lifecycle.
165+
motion_digest_task = asyncio.create_task(_motion_digest_loop())
149166
print(
150167
f"[App] SourceBox Sentry Command Center started "
151168
f"(log retention: {LOG_RETENTION_DAYS}d, "
@@ -159,6 +176,7 @@ async def lifespan(app):
159176
release_refresh_task.cancel()
160177
email_worker_task.cancel()
161178
disk_check_task.cancel()
179+
motion_digest_task.cancel()
162180
print("[System] Shutdown complete")
163181

164182

@@ -810,6 +828,157 @@ async def _disk_check_loop():
810828
logger.exception("[DiskCheck] tick failed")
811829

812830

831+
async def _motion_digest_loop():
832+
"""Per-camera motion-email digest sweep.
833+
834+
Runs every ``MOTION_DIGEST_INTERVAL_SECONDS`` (60s default). Drains
835+
cooldown anchors written by the immediate-email path (see
836+
``app/api/notifications.py::_claim_motion_cooldown_or_silence``).
837+
For each anchor whose window has expired, counts MotionEvent rows
838+
that landed in the window and — if there were extras AND the org
839+
still has email_motion enabled — emits a single ``motion_digest``
840+
notification (which itself enqueues an email via the standard
841+
create_notification path). The anchor is deleted regardless of
842+
whether a digest was emitted, so the next motion event for that
843+
camera triggers a fresh immediate email + new anchor.
844+
845+
Per-anchor try/except so one corrupt row can't poison the tick;
846+
the outer try/except catches anything that escapes (DB connection
847+
drops, session reset) so the loop survives transient failures and
848+
picks up where it left off on the next tick.
849+
"""
850+
from app.models.models import Camera, MotionEvent, Setting
851+
from app.api.notifications import (
852+
create_notification,
853+
email_enabled_for_kind,
854+
_motion_cooldown_minutes,
855+
)
856+
857+
while True:
858+
await asyncio.sleep(MOTION_DIGEST_INTERVAL_SECONDS)
859+
860+
try:
861+
db = SessionLocal()
862+
try:
863+
now = datetime.now(tz=timezone.utc).replace(tzinfo=None)
864+
anchors = (
865+
db.query(Setting)
866+
.filter(Setting.key.like("motion_email_cooldown_start:%"))
867+
.all()
868+
)
869+
for anchor_row in anchors:
870+
try:
871+
# Parse camera_id back from the colon-suffixed key.
872+
# ``split(":", 1)`` handles the (extremely unlikely)
873+
# case of a colon in the camera_id itself.
874+
if ":" not in anchor_row.key:
875+
db.delete(anchor_row)
876+
db.commit()
877+
continue
878+
camera_id = anchor_row.key.split(":", 1)[1]
879+
880+
if not anchor_row.value:
881+
db.delete(anchor_row)
882+
db.commit()
883+
continue
884+
try:
885+
anchor_ts = datetime.fromisoformat(anchor_row.value)
886+
except ValueError:
887+
# Corrupt timestamp — drop the row so the next
888+
# motion event starts fresh rather than being
889+
# silenced forever by an unparseable anchor.
890+
db.delete(anchor_row)
891+
db.commit()
892+
continue
893+
894+
cooldown_min = _motion_cooldown_minutes(db, anchor_row.org_id)
895+
if (now - anchor_ts).total_seconds() < cooldown_min * 60:
896+
# Window still open — leave the anchor alone.
897+
continue
898+
899+
window_end = anchor_ts + timedelta(minutes=cooldown_min)
900+
# Count events strictly AFTER the anchor (the immediate
901+
# email already covered the anchor-time event itself)
902+
# and up to the window edge. Events past window_end
903+
# belong to a later cycle — but no later cycle exists
904+
# yet because the anchor is still here, so this filter
905+
# is defensive against clock skew + late-arriving events.
906+
extra_count = (
907+
db.query(MotionEvent)
908+
.filter(
909+
MotionEvent.org_id == anchor_row.org_id,
910+
MotionEvent.camera_id == camera_id,
911+
MotionEvent.timestamp > anchor_ts,
912+
MotionEvent.timestamp <= window_end,
913+
)
914+
.count()
915+
)
916+
917+
if extra_count > 0 and email_enabled_for_kind(
918+
db, anchor_row.org_id, "motion"
919+
):
920+
# Re-resolve display name at digest-emit time.
921+
# The camera might have been renamed since the
922+
# immediate email; show the current name.
923+
cam = (
924+
db.query(Camera)
925+
.filter_by(
926+
camera_id=camera_id,
927+
org_id=anchor_row.org_id,
928+
)
929+
.first()
930+
)
931+
display = (
932+
cam.name if cam and cam.name else camera_id
933+
)
934+
create_notification(
935+
org_id=anchor_row.org_id,
936+
kind="motion_digest",
937+
title=(
938+
f"{extra_count} more motion event"
939+
f"{'s' if extra_count != 1 else ''} "
940+
f"on {display}"
941+
),
942+
body=(
943+
f"{extra_count} additional motion event"
944+
f"{'s were' if extra_count != 1 else ' was'} "
945+
f'detected on "{display}" in the '
946+
f"{cooldown_min}-minute window after the "
947+
f"first alert."
948+
),
949+
severity="info",
950+
audience="all",
951+
link=f"/dashboard?camera={camera_id}",
952+
camera_id=camera_id,
953+
meta={
954+
"event_count": extra_count,
955+
"window_start": anchor_ts.isoformat(),
956+
"window_end": window_end.isoformat(),
957+
"cooldown_minutes": cooldown_min,
958+
},
959+
db=db,
960+
)
961+
962+
# Always delete the anchor — window has closed. Next
963+
# motion on this camera starts a fresh cycle.
964+
db.delete(anchor_row)
965+
db.commit()
966+
except Exception:
967+
logger.exception(
968+
"[MotionDigest] anchor processing failed key=%s org=%s",
969+
anchor_row.key,
970+
anchor_row.org_id,
971+
)
972+
try:
973+
db.rollback()
974+
except Exception:
975+
pass
976+
finally:
977+
db.close()
978+
except Exception:
979+
logger.exception("[MotionDigest] tick failed")
980+
981+
813982
async def _offline_sweep_loop():
814983
"""Background task — periodically flip stale 'online' rows to 'offline'.
815984

0 commit comments

Comments
 (0)