Commit 71fcd21
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
File tree
- backend
- app
- api
- templates/emails
- tests
- docs/legal
- frontend/src/pages
- docs
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
49 | 54 | | |
50 | 55 | | |
51 | 56 | | |
| |||
84 | 89 | | |
85 | 90 | | |
86 | 91 | | |
87 | | - | |
88 | | - | |
89 | | - | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
90 | 97 | | |
91 | 98 | | |
92 | 99 | | |
| |||
124 | 131 | | |
125 | 132 | | |
126 | 133 | | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
127 | 149 | | |
128 | 150 | | |
129 | 151 | | |
| |||
181 | 203 | | |
182 | 204 | | |
183 | 205 | | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
184 | 295 | | |
185 | 296 | | |
186 | 297 | | |
| |||
436 | 547 | | |
437 | 548 | | |
438 | 549 | | |
439 | | - | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
440 | 562 | | |
441 | 563 | | |
442 | 564 | | |
| |||
796 | 918 | | |
797 | 919 | | |
798 | 920 | | |
| 921 | + | |
799 | 922 | | |
800 | 923 | | |
801 | 924 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
126 | 136 | | |
127 | 137 | | |
128 | 138 | | |
| |||
146 | 156 | | |
147 | 157 | | |
148 | 158 | | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
149 | 166 | | |
150 | 167 | | |
151 | 168 | | |
| |||
159 | 176 | | |
160 | 177 | | |
161 | 178 | | |
| 179 | + | |
162 | 180 | | |
163 | 181 | | |
164 | 182 | | |
| |||
810 | 828 | | |
811 | 829 | | |
812 | 830 | | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
| 899 | + | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
| 915 | + | |
| 916 | + | |
| 917 | + | |
| 918 | + | |
| 919 | + | |
| 920 | + | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
| 976 | + | |
| 977 | + | |
| 978 | + | |
| 979 | + | |
| 980 | + | |
| 981 | + | |
813 | 982 | | |
814 | 983 | | |
815 | 984 | | |
| |||
0 commit comments