coderuday21 commited on
Commit
5383f21
·
1 Parent(s): 9d3c0c6

Rework email notifications with explicit send actions.

Browse files

Add test and report email endpoints, surface SMTP errors to the UI, and add a Send Test/Send Report button so users can verify email delivery before or after running detection.

Made-with: Cursor

Files changed (5) hide show
  1. app/main.py +59 -6
  2. app/notifier.py +77 -34
  3. static/css/style.css +14 -0
  4. static/js/app.js +59 -2
  5. templates/index.html +7 -3
app/main.py CHANGED
@@ -26,7 +26,7 @@ from .auth import (
26
  )
27
  from .database import Base, engine, get_db, DATA_DIR
28
  from .models import User, DetectionRun
29
- from .notifier import send_notification
30
 
31
  import logging
32
  logger = logging.getLogger(__name__)
@@ -105,6 +105,17 @@ class UserResponse(BaseModel):
105
  full_name: str
106
 
107
 
 
 
 
 
 
 
 
 
 
 
 
108
  # --- Auth routes ---
109
  def _auth_response(token: str, user: User):
110
  """JSON response with auth cookie so browser sends token on every request (e.g. POST /api/detect)."""
@@ -324,8 +335,9 @@ async def detect(
324
 
325
  # Send email notification if requested
326
  notification_sent = False
 
327
  if notify_email and notify_email.strip():
328
- notification_sent = send_notification(
329
  recipient=notify_email.strip(),
330
  title=title,
331
  method=method,
@@ -356,10 +368,24 @@ async def detect(
356
  "beforeThumbUrl": f"/api/overlay/{relative_before_thumb}",
357
  "afterThumbUrl": f"/api/overlay/{relative_after_thumb}",
358
  "notificationSent": notification_sent,
 
359
  "createdAt": _isoformat_ist(run.created_at),
360
  }
361
 
362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  @app.get("/api/overlay/{path:path}")
364
  def serve_overlay(path: str):
365
  # Restrict to overlays directory
@@ -415,10 +441,7 @@ def get_run(
415
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
416
  if not run:
417
  raise HTTPException(status_code=404, detail="Run not found")
418
- try:
419
- regions = json.loads(run.regions_json) if run.regions_json else []
420
- except (json.JSONDecodeError, TypeError):
421
- regions = []
422
  return {
423
  "id": run.id,
424
  "title": run.title,
@@ -440,6 +463,36 @@ def get_run(
440
  }
441
 
442
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  # --- Delete history run ---
444
  @app.delete("/api/history/{run_id}")
445
  def delete_run(
 
26
  )
27
  from .database import Base, engine, get_db, DATA_DIR
28
  from .models import User, DetectionRun
29
+ from .notifier import send_notification, send_test_email
30
 
31
  import logging
32
  logger = logging.getLogger(__name__)
 
105
  full_name: str
106
 
107
 
108
+ class EmailRequest(BaseModel):
109
+ email: str
110
+
111
+
112
+ def _load_regions_json(raw_regions: Optional[str]):
113
+ try:
114
+ return json.loads(raw_regions) if raw_regions else []
115
+ except (json.JSONDecodeError, TypeError):
116
+ return []
117
+
118
+
119
  # --- Auth routes ---
120
  def _auth_response(token: str, user: User):
121
  """JSON response with auth cookie so browser sends token on every request (e.g. POST /api/detect)."""
 
335
 
336
  # Send email notification if requested
337
  notification_sent = False
338
+ notification_error = None
339
  if notify_email and notify_email.strip():
340
+ notification_sent, notification_error = send_notification(
341
  recipient=notify_email.strip(),
342
  title=title,
343
  method=method,
 
368
  "beforeThumbUrl": f"/api/overlay/{relative_before_thumb}",
369
  "afterThumbUrl": f"/api/overlay/{relative_after_thumb}",
370
  "notificationSent": notification_sent,
371
+ "notificationError": notification_error,
372
  "createdAt": _isoformat_ist(run.created_at),
373
  }
374
 
375
 
376
+ @app.post("/api/notify/test")
377
+ def notify_test(
378
+ data: EmailRequest,
379
+ user: Optional[User] = Depends(get_current_user),
380
+ ):
381
+ if not user:
382
+ raise HTTPException(status_code=401, detail="Login required")
383
+ sent, error = send_test_email(data.email.strip())
384
+ if not sent:
385
+ raise HTTPException(status_code=400, detail=error or "Failed to send test email")
386
+ return {"ok": True, "message": f"Test email sent to {data.email.strip()}."}
387
+
388
+
389
  @app.get("/api/overlay/{path:path}")
390
  def serve_overlay(path: str):
391
  # Restrict to overlays directory
 
441
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
442
  if not run:
443
  raise HTTPException(status_code=404, detail="Run not found")
444
+ regions = _load_regions_json(run.regions_json)
 
 
 
445
  return {
446
  "id": run.id,
447
  "title": run.title,
 
463
  }
464
 
465
 
466
+ @app.post("/api/history/{run_id}/notify")
467
+ def notify_run(
468
+ run_id: int,
469
+ data: EmailRequest,
470
+ user: Optional[User] = Depends(get_current_user),
471
+ db: Session = Depends(get_db),
472
+ ):
473
+ if not user:
474
+ raise HTTPException(status_code=401, detail="Login required")
475
+ run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
476
+ if not run:
477
+ raise HTTPException(status_code=404, detail="Run not found")
478
+
479
+ regions = _load_regions_json(run.regions_json)
480
+ sent, error = send_notification(
481
+ recipient=data.email.strip(),
482
+ title=run.title,
483
+ method=run.method,
484
+ zone=run.zone or "",
485
+ village=run.village or "",
486
+ change_pct=float(run.change_percentage),
487
+ changed_px=int(run.changed_pixels),
488
+ total_px=int(run.total_pixels),
489
+ regions=regions,
490
+ )
491
+ if not sent:
492
+ raise HTTPException(status_code=400, detail=error or "Failed to send report email")
493
+ return {"ok": True, "message": f"Report email sent to {data.email.strip()}."}
494
+
495
+
496
  # --- Delete history run ---
497
  @app.delete("/api/history/{run_id}")
498
  def delete_run(
app/notifier.py CHANGED
@@ -1,7 +1,8 @@
1
  """
2
  Email notification module.
3
- Sends HTML-formatted detection reports via SMTP STARTTLS (port 587).
4
- Credentials are read from environment variables never hardcoded.
 
5
  """
6
  import logging
7
  import os
@@ -15,14 +16,24 @@ from pathlib import Path
15
 
16
  logger = logging.getLogger(__name__)
17
 
18
- SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
19
- SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
20
- SMTP_USER = os.environ.get("SMTP_USER", "vedangofficeserver@gmail.com")
21
- SMTP_PASS = os.environ.get("SMTP_PASS", "")
22
 
23
  TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "ChangeDetection.html"
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def _load_template() -> str:
27
  """Read the HTML email template from disk."""
28
  if not TEMPLATE_PATH.exists():
@@ -92,6 +103,48 @@ def build_email_body(
92
  return html
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def send_notification(
96
  recipient: str,
97
  title: str,
@@ -102,36 +155,26 @@ def send_notification(
102
  changed_px: int,
103
  total_px: int,
104
  regions: list,
105
- ) -> bool:
106
- """
107
- Send detection report email to the recipient.
108
- Returns True on success, False on failure (never raises).
109
- """
110
- if not SMTP_PASS or not SMTP_USER:
111
- logger.warning("SMTP_USER or SMTP_PASS not set — skipping email notification")
112
- return False
113
-
114
  html_body = build_email_body(
115
  title, method, zone, village, change_pct, changed_px, total_px, regions
116
  )
 
 
117
 
118
- msg = MIMEMultipart("alternative")
119
- msg["Subject"] = f"Change Detection Report — {title or 'Untitled run'}"
120
- msg["From"] = SMTP_USER
121
- msg["To"] = recipient
122
- msg.attach(MIMEText(html_body, "html", "utf-8"))
123
 
124
- try:
125
- context = ssl.create_default_context()
126
- with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as server:
127
- server.ehlo()
128
- server.starttls(context=context)
129
- server.ehlo()
130
- server.login(SMTP_USER, SMTP_PASS)
131
- server.sendmail(SMTP_USER, recipient, msg.as_string())
132
- logger.info("Notification email sent to %s", recipient)
133
- return True
134
- except Exception as e:
135
- logger.error("Failed to send notification email to %s: %s: %s",
136
- recipient, type(e).__name__, e)
137
- return False
 
1
  """
2
  Email notification module.
3
+ Sends HTML-formatted detection reports via Gmail SMTP.
4
+ Credentials are read from environment variables and loaded at send time so a
5
+ server restart or secret update is picked up reliably.
6
  """
7
  import logging
8
  import os
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
19
+ EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
 
 
 
20
 
21
  TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "ChangeDetection.html"
22
 
23
 
24
+ def _smtp_settings():
25
+ return {
26
+ "host": os.environ.get("SMTP_HOST", "smtp.gmail.com"),
27
+ "port": int(os.environ.get("SMTP_PORT", "587")),
28
+ "user": os.environ.get("SMTP_USER", "vedangofficeserver@gmail.com"),
29
+ "password": os.environ.get("SMTP_PASS", ""),
30
+ }
31
+
32
+
33
+ def _valid_email(email: str) -> bool:
34
+ return bool(email and EMAIL_RE.match(email.strip()))
35
+
36
+
37
  def _load_template() -> str:
38
  """Read the HTML email template from disk."""
39
  if not TEMPLATE_PATH.exists():
 
103
  return html
104
 
105
 
106
+ def _send_html_email(recipient: str, subject: str, html_body: str):
107
+ settings = _smtp_settings()
108
+ smtp_user = settings["user"]
109
+ smtp_pass = settings["password"]
110
+ smtp_host = settings["host"]
111
+ smtp_port = settings["port"]
112
+
113
+ if not _valid_email(recipient):
114
+ return False, "Enter a valid recipient email address."
115
+ if not smtp_user or not smtp_pass:
116
+ logger.warning("SMTP_USER or SMTP_PASS not set — skipping email notification")
117
+ return False, "SMTP credentials are not configured on the server."
118
+
119
+ msg = MIMEMultipart("alternative")
120
+ msg["Subject"] = subject
121
+ msg["From"] = smtp_user
122
+ msg["To"] = recipient
123
+ msg.attach(MIMEText(html_body, "html", "utf-8"))
124
+
125
+ try:
126
+ context = ssl.create_default_context()
127
+ with smtplib.SMTP(smtp_host, smtp_port, timeout=20) as server:
128
+ server.ehlo()
129
+ server.starttls(context=context)
130
+ server.ehlo()
131
+ server.login(smtp_user, smtp_pass)
132
+ server.sendmail(smtp_user, recipient, msg.as_string())
133
+ logger.info("Notification email sent to %s", recipient)
134
+ return True, None
135
+ except smtplib.SMTPAuthenticationError:
136
+ logger.exception("SMTP authentication failed")
137
+ return False, "SMTP authentication failed. Check the Gmail app password."
138
+ except Exception as exc:
139
+ logger.error(
140
+ "Failed to send notification email to %s: %s: %s",
141
+ recipient,
142
+ type(exc).__name__,
143
+ exc,
144
+ )
145
+ return False, f"{type(exc).__name__}: {exc}"
146
+
147
+
148
  def send_notification(
149
  recipient: str,
150
  title: str,
 
155
  changed_px: int,
156
  total_px: int,
157
  regions: list,
158
+ ):
159
+ """Send a detection report email and return (success, error_message)."""
 
 
 
 
 
 
 
160
  html_body = build_email_body(
161
  title, method, zone, village, change_pct, changed_px, total_px, regions
162
  )
163
+ subject = f"Change Detection Report — {title or 'Untitled run'}"
164
+ return _send_html_email(recipient, subject, html_body)
165
 
 
 
 
 
 
166
 
167
+ def send_test_email(recipient: str):
168
+ """Send a small test email so SMTP configuration can be verified quickly."""
169
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
170
+ html_body = f"""
171
+ <html>
172
+ <body style="font-family: Arial, sans-serif; color: #222;">
173
+ <h2>AI Change Detection Email Test</h2>
174
+ <p>This is a test email from the AI Change Detection application.</p>
175
+ <p>If you received this, the SMTP configuration is working.</p>
176
+ <p><strong>Timestamp:</strong> {now}</p>
177
+ </body>
178
+ </html>
179
+ """
180
+ return _send_html_email(recipient, "AI Change Detection — Test Email", html_body)
static/css/style.css CHANGED
@@ -465,9 +465,23 @@ input:focus, select:focus, textarea:focus {
465
  .notify-email-group input {
466
  width: 100%;
467
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  @media (max-width: 640px) {
469
  .notify-row { flex-direction: column; align-items: stretch; }
470
  .notify-email-group { min-width: 100%; }
 
471
  }
472
 
473
  /* ---- Upload zones ---- */
 
465
  .notify-email-group input {
466
  width: 100%;
467
  }
468
+ .notify-email-actions {
469
+ display: flex;
470
+ gap: 0.6rem;
471
+ align-items: stretch;
472
+ }
473
+ .notify-email-actions .btn {
474
+ flex-shrink: 0;
475
+ }
476
+ .notify-help {
477
+ margin-top: 0.35rem;
478
+ font-size: 0.78rem;
479
+ color: var(--text-dim);
480
+ }
481
  @media (max-width: 640px) {
482
  .notify-row { flex-direction: column; align-items: stretch; }
483
  .notify-email-group { min-width: 100%; }
484
+ .notify-email-actions { flex-direction: column; }
485
  }
486
 
487
  /* ---- Upload zones ---- */
static/js/app.js CHANGED
@@ -30,6 +30,10 @@ function showSuccess(id, msg) {
30
  setTimeout(() => el.classList.add('hidden'), 4000);
31
  }
32
 
 
 
 
 
33
  async function api(method, path, options = {}) {
34
  const headers = { ...options.headers };
35
  const token = getToken();
@@ -288,9 +292,59 @@ const DELHI_ZONES = {
288
  group.classList.add('hidden');
289
  document.getElementById('notify-email').value = '';
290
  }
 
291
  });
292
  })();
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  // ---- Run detection ----
295
  document.getElementById('form-detect')?.addEventListener('submit', async (e) => {
296
  e.preventDefault();
@@ -323,7 +377,7 @@ document.getElementById('form-detect')?.addEventListener('submit', async (e) =>
323
  const notifyInput = document.getElementById('notify-email');
324
  if (notifyCb?.checked) {
325
  const email = (notifyInput?.value || '').trim();
326
- if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
327
  showError('dashboard-error', 'Please enter a valid email address for notification.');
328
  btn.disabled = false;
329
  loading.classList.add('hidden');
@@ -348,7 +402,7 @@ document.getElementById('form-detect')?.addEventListener('submit', async (e) =>
348
  if (notifyCbDone?.checked) {
349
  notifyMsg = data.notificationSent
350
  ? ' Notification email sent.'
351
- : ' Email notification failed check SMTP credentials.';
352
  }
353
  showSuccess('dashboard-success', 'Detection complete!' + notifyMsg);
354
  loadHistory();
@@ -383,6 +437,8 @@ let _regionList = []; // matching data objects
383
  const REGIONS_PER_PAGE = 10;
384
  let _regionPage = 0;
385
 
 
 
386
  function showResult(data) {
387
  const modal = document.getElementById('result-modal');
388
  const statsEl = document.getElementById('result-stats');
@@ -464,6 +520,7 @@ function showResult(data) {
464
 
465
  _regionPage = 0;
466
  renderRegionPage();
 
467
  openResultModal();
468
  }
469
 
 
30
  setTimeout(() => el.classList.add('hidden'), 4000);
31
  }
32
 
33
+ function isValidEmail(email) {
34
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((email || '').trim());
35
+ }
36
+
37
  async function api(method, path, options = {}) {
38
  const headers = { ...options.headers };
39
  const token = getToken();
 
292
  group.classList.add('hidden');
293
  document.getElementById('notify-email').value = '';
294
  }
295
+ updateNotifyActionState();
296
  });
297
  })();
298
 
299
+ function updateNotifyActionState() {
300
+ const cb = document.getElementById('detect-notify');
301
+ const btn = document.getElementById('notify-send-btn');
302
+ const help = document.getElementById('notify-help');
303
+ if (!btn || !cb) return;
304
+ btn.disabled = !cb.checked;
305
+ if (currentResultData?.id) {
306
+ btn.textContent = 'Send Report';
307
+ if (help) help.textContent = 'Send the currently open result report to this email address, or run a new detection to auto-send.';
308
+ } else {
309
+ btn.textContent = 'Send Test';
310
+ if (help) help.textContent = 'Use Send Test to verify email delivery, or run detection to send the report automatically.';
311
+ }
312
+ }
313
+
314
+ document.getElementById('notify-send-btn')?.addEventListener('click', async () => {
315
+ hideError('dashboard-error');
316
+ const cb = document.getElementById('detect-notify');
317
+ const input = document.getElementById('notify-email');
318
+ const btn = document.getElementById('notify-send-btn');
319
+ const email = (input?.value || '').trim();
320
+
321
+ if (!cb?.checked) {
322
+ showError('dashboard-error', 'Enable "Notify via Email" first.');
323
+ return;
324
+ }
325
+ if (!isValidEmail(email)) {
326
+ showError('dashboard-error', 'Please enter a valid email address.');
327
+ return;
328
+ }
329
+
330
+ const originalText = btn.textContent;
331
+ btn.disabled = true;
332
+ btn.textContent = 'Sending...';
333
+ try {
334
+ const path = currentResultData?.id
335
+ ? `/api/history/${currentResultData.id}/notify`
336
+ : '/api/notify/test';
337
+ const data = await api('POST', path, { body: JSON.stringify({ email }) });
338
+ showSuccess('dashboard-success', data?.message || 'Email sent successfully.');
339
+ } catch (err) {
340
+ showError('dashboard-error', err.message || 'Failed to send email.');
341
+ } finally {
342
+ btn.disabled = false;
343
+ btn.textContent = originalText;
344
+ updateNotifyActionState();
345
+ }
346
+ });
347
+
348
  // ---- Run detection ----
349
  document.getElementById('form-detect')?.addEventListener('submit', async (e) => {
350
  e.preventDefault();
 
377
  const notifyInput = document.getElementById('notify-email');
378
  if (notifyCb?.checked) {
379
  const email = (notifyInput?.value || '').trim();
380
+ if (!isValidEmail(email)) {
381
  showError('dashboard-error', 'Please enter a valid email address for notification.');
382
  btn.disabled = false;
383
  loading.classList.add('hidden');
 
402
  if (notifyCbDone?.checked) {
403
  notifyMsg = data.notificationSent
404
  ? ' Notification email sent.'
405
+ : ` Email notification failed${data.notificationError ? `: ${data.notificationError}` : '.'}`;
406
  }
407
  showSuccess('dashboard-success', 'Detection complete!' + notifyMsg);
408
  loadHistory();
 
437
  const REGIONS_PER_PAGE = 10;
438
  let _regionPage = 0;
439
 
440
+ updateNotifyActionState();
441
+
442
  function showResult(data) {
443
  const modal = document.getElementById('result-modal');
444
  const statsEl = document.getElementById('result-stats');
 
520
 
521
  _regionPage = 0;
522
  renderRegionPage();
523
+ updateNotifyActionState();
524
  openResultModal();
525
  }
526
 
templates/index.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>AI Change Detection</title>
7
- <link rel="stylesheet" href="/static/css/style.css?v=17" />
8
  </head>
9
  <body>
10
  <div class="app">
@@ -228,7 +228,11 @@
228
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
229
  Recipient Email
230
  </label>
231
- <input type="email" id="notify-email" placeholder="recipient@example.com" />
 
 
 
 
232
  </div>
233
  </div>
234
  <div class="run-btn-wrap">
@@ -346,6 +350,6 @@
346
  </div>
347
  </div>
348
 
349
- <script src="/static/js/app.js?v=23"></script>
350
  </body>
351
  </html>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>AI Change Detection</title>
7
+ <link rel="stylesheet" href="/static/css/style.css?v=18" />
8
  </head>
9
  <body>
10
  <div class="app">
 
228
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
229
  Recipient Email
230
  </label>
231
+ <div class="notify-email-actions">
232
+ <input type="email" id="notify-email" placeholder="recipient@example.com" />
233
+ <button type="button" class="btn btn-secondary" id="notify-send-btn">Send Test</button>
234
+ </div>
235
+ <div class="notify-help" id="notify-help">Use Send Test to verify email delivery, or run detection to send the report automatically.</div>
236
  </div>
237
  </div>
238
  <div class="run-btn-wrap">
 
350
  </div>
351
  </div>
352
 
353
+ <script src="/static/js/app.js?v=24"></script>
354
  </body>
355
  </html>