diff --git a/CLAUDE.md b/CLAUDE.md index 501ca39..2d6eabc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,12 +99,15 @@ Without this, sessions and CSRF tokens are broken across the 4 Gunicorn workers. | `app.py` | Flask factory | Registers all 11 blueprints; `CSRFProtect(app)`; `hhmm` template filter; logs WARNING if `SECRET_KEY` not set | | `wsgi.py` | Gunicorn entry point | Only place that calls `create_app()` — do not add a second call elsewhere | | `config.py` | DB config, DDL, settings | Calls `load_dotenv()` at top — **must be before `DB_CONFIG` dict**; contains safe re-runnable migrations | -| `models.py` | All DB queries | ~1,430 lines; no ORM; every function opens/closes its own connection; `import datetime` at top | +| `models.py` | All DB queries | ~1,650 lines; no ORM; every function opens/closes its own connection; `import datetime` at top | | `utils/crypto.py` | Fernet encryption | Must match desktop exactly | | `utils/decorators.py` | `@login_required`, `@admin_required` | Simple session checks | +| `utils/email.py` | SMTP email helper | `send_email(to, subject, body_text)` reads smtp settings from `app_settings`; used by bid reminders and password reset | | `static/css/style.css` | Full design system | Light theme, DM Sans + DM Mono, CSS variables in `:root` | -| `static/js/app.js` | Global JS utilities | `openModal()`, `closeModal()`, `copyToClipboard()`, session timeout warning, `getCsrfToken()`, CSRF auto-inject IIFE | +| `static/js/app.js` | Global JS utilities | `openModal()`, `closeModal()`, `copyToClipboard()`, `timeAgo()`, mobile sidebar toggle, session timeout warning, `getCsrfToken()`, CSRF auto-inject IIFE | | `templates/login.html` | Standalone login page | Does NOT extend `base.html`; has its own `` and no `app.js`; CSRF token must be a direct hidden input | +| `templates/forgot_password.html` | Standalone forgot-password page | Same constraints as `login.html` — standalone, direct CSRF hidden input, no `app.js` | +| `templates/reset_password.html` | Standalone reset-password page | Same constraints as `login.html` — standalone, direct CSRF hidden input, no `app.js` | --- @@ -333,9 +336,9 @@ mysql -u webchecker_user -p webchecker -e "SELECT key_name, value FROM app_setti - [ ] **Paginate bid list** — `get_all_bids()` fetches all rows with no limit; add `LIMIT`/`OFFSET` to the model query and a "load more" button in the split-pane list ### Functionality -- [ ] **Missed-shift alerting** — Query or report that flags shifts where zero `shift_checks` records exist for a given date, surfaced on the admin dashboard or via email -- [ ] **Bid deadline email reminders** — Use the existing `email.smtp_*` settings to send a daily digest of bids with `due_date` within the next 7 days -- [ ] **Server-side health checks** — Replace the Google favicon proxy in the user dashboard with a `/dashboard/health/` route that makes a server-side `HEAD` request (with short timeout) for a real reachability signal -- [ ] **Password reset via email** — Time-limited token flow so users can self-service instead of requiring an admin edit; needs a `password_reset_tokens` table and SMTP integration -- [ ] **"Copy password" button in Credentials modal** — Wire `copyToClipboard()` (already in `app.js`) to the password field in the user dashboard credentials modal -- [ ] **Shift calendar view** — Weekly grid (Mon–Sun columns, shifts as rows) on the admin shifts page to make schedule gaps and overlaps visible at a glance +- [x] **Missed-shift alerting** — `get_missed_shifts_today()` in `models.py` queries shift+user pairs scheduled today with 0 checks; surfaced as a warning card on the admin dashboard +- [x] **Bid deadline email reminders** — `get_bids_due_soon()` + `get_admin_emails()` in `models.py`; `send_reminders` route in `bid_tracker.py`; "📧 Remind" button in bid tracker toolbar (admin only); uses `utils/email.py` +- [x] **Server-side health checks** — `/dashboard/health/` in `user_dashboard.py` does a server-side HEAD request; user dashboard JS updated to call this instead of the Google favicon proxy +- [x] **Password reset via email** — `password_reset_tokens` table (migration-safe DDL in `config.py`); model functions in `models.py`; `/forgot-password` and `/reset-password/` routes in `auth.py`; standalone templates `forgot_password.html` and `reset_password.html`; "Forgot password?" link on login page; uses `utils/email.py` +- [x] **"Copy password" button in Credentials modal** — Already implemented in the original code via `data-copy` attribute and delegated click handler in `user/dashboard.html` +- [x] **Shift calendar view** — Weekly grid tab added to admin shifts page; Mon–Sun columns, active shifts as rows; server-side rendered with Jinja2 using existing `day_map` data diff --git a/config.py b/config.py index 1d174c3..5e4d8f9 100644 --- a/config.py +++ b/config.py @@ -311,6 +311,18 @@ def initialize_database(): ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token VARCHAR(64) NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_prt_token (token), + INDEX idx_prt_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + """ CREATE TABLE IF NOT EXISTS app_settings ( key_name VARCHAR(100) NOT NULL PRIMARY KEY, value TEXT NULL, diff --git a/models.py b/models.py index 44d556d..3865bf1 100644 --- a/models.py +++ b/models.py @@ -1480,3 +1480,191 @@ def purge_app_log(older_than_days: int = 30): finally: if conn: conn.close() + + +# ─── Missed Shifts ──────────────────────────────────────────────────────────── + +def get_missed_shifts_today() -> list: + """Return shift+user pairs scheduled today where the user has checked 0 sites.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT s.name AS shift_name, + u.id AS user_id, u.username, + COALESCE(u.full_name, u.username) AS full_name, + COUNT(DISTINCT sw.website_id) AS total_sites, + COUNT(DISTINCT sc.website_id) AS checked_sites + FROM shifts s + JOIN shift_users su ON su.shift_id = s.id + JOIN users u ON u.id = su.user_id AND u.is_active = 1 + JOIN shift_websites sw ON sw.shift_id = s.id + LEFT JOIN shift_checks sc + ON sc.user_id = su.user_id + AND sc.website_id = sw.website_id + AND DATE(sc.checked_at) = CURDATE() + WHERE s.is_active = 1 + AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0 + GROUP BY s.id, u.id + HAVING total_sites > 0 AND checked_sites = 0 + ORDER BY s.name, u.username + """ + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +# ─── Bid Reminders ──────────────────────────────────────────────────────────── + +def get_bids_due_soon(days: int = 7) -> list: + """Return open/monitoring bids with due_date within the next `days` days.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT b.*, u.username AS added_by_username, u.full_name AS added_by_full_name" + " FROM bid_tracker b" + " LEFT JOIN users u ON u.id = b.added_by" + " WHERE b.status IN ('open','monitoring')" + " AND b.due_date IS NOT NULL" + " AND b.due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL %s DAY)" + " ORDER BY b.due_date ASC", + (days,), + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def get_admin_emails() -> list: + """Return email addresses of all active admin users that have one set.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT email FROM users" + " WHERE role='admin' AND is_active=1 AND email IS NOT NULL AND email != ''" + ) + rows = cur.fetchall() + cur.close() + return [r["email"] for r in rows] + finally: + if conn: + conn.close() + + +# ─── Server-side Health Check ───────────────────────────────────────────────── + +def get_website_url(website_id: int): + """Return the URL of an active website by ID, or None if not found.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT url FROM websites WHERE id=%s AND is_active=1", (website_id,)) + row = cur.fetchone() + cur.close() + return row[0] if row else None + finally: + if conn: + conn.close() + + +# ─── Password Reset ─────────────────────────────────────────────────────────── + +def get_user_by_email(email: str): + """Return the active user matching the given email (case-insensitive), or None.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT * FROM users WHERE LOWER(email)=%s AND is_active=1 LIMIT 1", + (email.lower(),), + ) + row = cur.fetchone() + cur.close() + return row + finally: + if conn: + conn.close() + + +def create_password_reset_token(user_id: int) -> str: + """Generate a secure 1-hour reset token; invalidates any prior token for the user.""" + import secrets + token = secrets.token_urlsafe(48) + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("DELETE FROM password_reset_tokens WHERE user_id=%s", (user_id,)) + cur.execute( + "INSERT INTO password_reset_tokens (user_id, token, expires_at)" + " VALUES (%s, %s, DATE_ADD(NOW(), INTERVAL 1 HOUR))", + (user_id, token), + ) + conn.commit() + cur.close() + return token + finally: + if conn: + conn.close() + + +def get_password_reset_user(token: str): + """Return the user row for a valid, unexpired reset token, or None.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT u.* FROM password_reset_tokens t" + " JOIN users u ON u.id = t.user_id" + " WHERE t.token=%s AND t.expires_at > NOW()", + (token,), + ) + row = cur.fetchone() + cur.close() + return row + finally: + if conn: + conn.close() + + +def consume_password_reset_token(token: str, new_password: str) -> bool: + """Validate token, update the user's password, and delete the token. Returns True on success.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT user_id FROM password_reset_tokens WHERE token=%s AND expires_at > NOW()", + (token,), + ) + row = cur.fetchone() + if not row: + cur.close() + return False + user_id = row["user_id"] + new_hash = _hash_password(new_password) + cur = conn.cursor() + cur.execute("UPDATE users SET password=%s WHERE id=%s", (new_hash, user_id)) + cur.execute("DELETE FROM password_reset_tokens WHERE token=%s", (token,)) + conn.commit() + cur.close() + return True + finally: + if conn: + conn.close() diff --git a/routes/admin_dashboard.py b/routes/admin_dashboard.py index 2112e34..1c273e9 100644 --- a/routes/admin_dashboard.py +++ b/routes/admin_dashboard.py @@ -4,7 +4,7 @@ routes/admin_dashboard.py — Admin dashboard: today's completion stats. import logging from flask import Blueprint, render_template -from models import get_admin_dashboard_stats +from models import get_admin_dashboard_stats, get_missed_shifts_today from utils.decorators import admin_required logger = logging.getLogger("routes.admin_dashboard") @@ -19,4 +19,9 @@ def dashboard(): except Exception as e: logger.error(f"Dashboard stats error: {e}") stats = {"user_stats": [], "total_sites": 0, "total_users": 0, "active_today": 0} - return render_template("admin/dashboard.html", stats=stats) + try: + missed = get_missed_shifts_today() + except Exception as e: + logger.error(f"Missed shifts error: {e}") + missed = [] + return render_template("admin/dashboard.html", stats=stats, missed=missed) diff --git a/routes/auth.py b/routes/auth.py index ff9ece7..bd6bedb 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -4,8 +4,13 @@ routes/auth.py — Authentication routes: login, logout, change-password. import logging from flask import Blueprint, render_template, request, session, redirect, url_for, flash -from models import authenticate, check_login_allowed, change_password, log_action +from models import ( + authenticate, check_login_allowed, change_password, log_action, + get_user_by_email, create_password_reset_token, + get_password_reset_user, consume_password_reset_token, +) from utils.decorators import login_required +from utils.email import send_email logger = logging.getLogger("routes.auth") auth_bp = Blueprint("auth", __name__) @@ -95,3 +100,61 @@ def ping(): """Keep-alive endpoint for the session timeout warning in app.js.""" session.modified = True return "", 204 + + +@auth_bp.route("/forgot-password", methods=["GET", "POST"]) +def forgot_password(): + if "user" in session: + return redirect(url_for("index")) + if request.method == "GET": + return render_template("forgot_password.html") + + email = request.form.get("email", "").strip().lower() + if email: + user = get_user_by_email(email) + if user and user.get("email"): + try: + token = create_password_reset_token(user["id"]) + reset_url = url_for("auth.reset_password", token=token, _external=True) + body = ( + f"Hi {user.get('full_name') or user['username']},\n\n" + f"A password reset was requested for your Website Checker account.\n" + f"Click the link below to set a new password (valid for 1 hour):\n\n" + f"{reset_url}\n\n" + f"If you did not request this, you can safely ignore this email.\n" + ) + send_email(user["email"], "Website Checker — Password Reset", body) + except Exception as e: + logger.error(f"Password reset email error for '{email}': {e}") + + # Always show the same message to prevent user enumeration + flash("If that email is registered, a reset link has been sent.", "info") + return render_template("forgot_password.html") + + +@auth_bp.route("/reset-password/", methods=["GET", "POST"]) +def reset_password(token): + user = get_password_reset_user(token) + if not user: + flash("This reset link is invalid or has expired.", "danger") + return redirect(url_for("auth.forgot_password")) + + if request.method == "GET": + return render_template("reset_password.html", token=token) + + password = request.form.get("password", "") + confirm = request.form.get("confirm_password", "") + + if len(password) < 8: + flash("Password must be at least 8 characters.", "danger") + return render_template("reset_password.html", token=token) + if password != confirm: + flash("Passwords do not match.", "danger") + return render_template("reset_password.html", token=token) + + if consume_password_reset_token(token, password): + logger.info(f"Password reset successfully for user id={user['id']}.") + flash("Password reset successfully. Please log in.", "success") + return redirect(url_for("auth.login")) + flash("Reset link expired or already used. Please request a new one.", "danger") + return redirect(url_for("auth.forgot_password")) diff --git a/routes/bid_tracker.py b/routes/bid_tracker.py index 08c348c..007f2a5 100644 --- a/routes/bid_tracker.py +++ b/routes/bid_tracker.py @@ -8,9 +8,10 @@ from flask import (Blueprint, render_template, request, redirect, url_for, from models import ( get_all_bids, get_bid, create_bid, update_bid, delete_bid, get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES, - log_action, + log_action, get_bids_due_soon, get_admin_emails, ) -from utils.decorators import login_required +from utils.decorators import login_required, admin_required +from utils.email import send_email logger = logging.getLogger("routes.bid_tracker") bid_tracker_bp = Blueprint("bid_tracker", __name__, url_prefix="/bids") @@ -237,3 +238,41 @@ def delete_update_json(update_id): except Exception as e: logger.error(f"delete_update_json error: {e}") return jsonify({"error": str(e)}), 500 + + +@bid_tracker_bp.route("/remind", methods=["POST"]) +@admin_required +def send_reminders(): + """Send an email digest of bids due within 7 days to all admin users with emails.""" + user = session["user"] + bids = get_bids_due_soon(days=7) + recipients = get_admin_emails() + + if not bids: + flash("No active bids due within 7 days.", "info") + return redirect(url_for("bid_tracker.bids_list")) + + if not recipients: + flash("No admin email addresses configured. Add them via Admin → Users.", "warning") + return redirect(url_for("bid_tracker.bids_list")) + + lines = [f"Bid Deadline Reminder — {len(bids)} bid(s) due within 7 days:\n"] + for b in bids: + due = str(b["due_date"])[:10] if b.get("due_date") else "N/A" + line = f" • {b['title']} — Due: {due} — Status: {b['status']}" + if b.get("solicitation_number"): + line += f" — Sol#: {b['solicitation_number']}" + lines.append(line) + lines.append("\nLog in to Website Checker for full details.") + body = "\n".join(lines) + + try: + send_email(recipients, "Website Checker — Upcoming Bid Deadlines", body) + log_action(user["id"], "BID_REMIND", "bid_tracker", None, + f"Bid reminder email sent to {len(recipients)} admin(s), {len(bids)} bid(s) listed.") + flash(f"Reminder digest sent to {len(recipients)} recipient(s).", "success") + except Exception as e: + logger.error(f"send_reminders error: {e}") + flash(f"Failed to send email: {e}", "danger") + + return redirect(url_for("bid_tracker.bids_list")) diff --git a/routes/user_dashboard.py b/routes/user_dashboard.py index dd28816..f806d7b 100644 --- a/routes/user_dashboard.py +++ b/routes/user_dashboard.py @@ -3,10 +3,13 @@ routes/user_dashboard.py — Regular user: shift check dashboard. """ import logging +import time +import requests as _http_req from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify from models import ( get_today_checks, mark_website_checked, unmark_website_checked, update_check_note, get_user_active_shifts, get_website_credentials, + get_website_url, ) from utils.decorators import login_required @@ -91,3 +94,23 @@ def view_credentials(website_id): except Exception as e: logger.error(f"view_credentials error: {e}") return jsonify({"error": str(e)}), 500 + + +@user_dashboard_bp.route("/health/") +@login_required +def health_check(website_id): + """Server-side HEAD probe for a website's reachability.""" + url = get_website_url(website_id) + if not url: + return jsonify({"status": "not_found"}), 404 + try: + t0 = time.time() + resp = _http_req.head(url, timeout=6, allow_redirects=True, + headers={"User-Agent": "WebsiteChecker/1.0 (health-check)"}) + ms = int((time.time() - t0) * 1000) + ok = resp.status_code < 400 + return jsonify({"status": "ok" if ok else "error", "ms": ms}) + except _http_req.exceptions.Timeout: + return jsonify({"status": "timeout", "ms": 6000}) + except Exception: + return jsonify({"status": "unreachable", "ms": None}) diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 387b7e0..0ee6b79 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -22,6 +22,28 @@ + +{% if missed %} +
+
+ ⚠ Missed Shifts Today + Users with 0 sites checked so far +
+ + + + {% for m in missed %} + + + + + + {% endfor %} + +
ShiftUserSites Checked
{{ m.shift_name }}{{ m.full_name }}{{ m.checked_sites }} / {{ m.total_sites }}
+
+{% endif %} +
diff --git a/templates/admin/shifts.html b/templates/admin/shifts.html index fa7f010..df52d43 100644 --- a/templates/admin/shifts.html +++ b/templates/admin/shifts.html @@ -3,9 +3,17 @@ {% block content %} +
+ + +
@@ -34,6 +42,49 @@
+
+ + +
+
+
Weekly Schedule
+
+ + + + + {% for label, _ in day_map %} + + {% endfor %} + + + + {% for s in shifts if s.is_active %} + + + {% for _, day_val in day_map %} + + {% endfor %} + + {% else %} + + {% endfor %} + +
Shift{{ label }}
+ {{ s.name }} +
{{ s.start_time|hhmm }}–{{ s.end_time|hhmm }}
+
+ {% if day_val in s.days_of_week %} + + {% else %} + + {% endif %} +
No active shifts.
+
+
+
+ +
+ {% if session['user']['role'] == 'admin' %} +
+ +
+ {% endif %}
diff --git a/templates/forgot_password.html b/templates/forgot_password.html new file mode 100644 index 0000000..53711b2 --- /dev/null +++ b/templates/forgot_password.html @@ -0,0 +1,41 @@ + + + + + + Forgot Password — Website Checker + + + + + + diff --git a/templates/login.html b/templates/login.html index 9fcae15..8730fee 100644 --- a/templates/login.html +++ b/templates/login.html @@ -34,6 +34,10 @@ +

+ Forgot password? +

+
\ No newline at end of file diff --git a/templates/reset_password.html b/templates/reset_password.html new file mode 100644 index 0000000..a0605cc --- /dev/null +++ b/templates/reset_password.html @@ -0,0 +1,46 @@ + + + + + + Set New Password — Website Checker + + + + + + diff --git a/templates/user/dashboard.html b/templates/user/dashboard.html index dcf2e03..0e2b718 100644 --- a/templates/user/dashboard.html +++ b/templates/user/dashboard.html @@ -381,25 +381,29 @@ function esc(str) { }); } -/* ── Health dots ───────────────────────────────────────────── */ +/* ── Health dots (server-side probe) ───────────────────────── */ document.querySelectorAll('.site-card').forEach(function(card) { var id = card.dataset.id; - var url = card.dataset.url; var dot = document.getElementById('health-' + id); - if (!dot || !url) return; - var img = new Image(); - var t0 = Date.now(); - img.onload = function() { - var ms = Date.now() - t0; - dot.style.color = ms > 3000 ? '#d97706' : '#16a34a'; - dot.title = ms > 3000 ? 'Slow (' + ms + 'ms)' : 'Reachable (' + ms + 'ms)'; - }; - img.onerror = function() { - var ms = Date.now() - t0; - dot.style.color = ms < 6000 ? '#d97706' : '#dc2626'; - dot.title = ms < 6000 ? 'Reachable (restricted)' : 'Unreachable'; - }; - img.src = 'https://www.google.com/s2/favicons?domain=' + encodeURIComponent(url) + '&t=' + Date.now(); + if (!dot) return; + fetch('/dashboard/health/' + id, {credentials: 'same-origin'}) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.status === 'ok') { + dot.style.color = d.ms > 3000 ? '#d97706' : '#16a34a'; + dot.title = 'Reachable (' + d.ms + 'ms)'; + } else if (d.status === 'timeout') { + dot.style.color = '#d97706'; + dot.title = 'Timeout (>6s)'; + } else { + dot.style.color = '#dc2626'; + dot.title = 'Unreachable'; + } + }) + .catch(function() { + dot.style.color = '#9ca3af'; + dot.title = 'Health check failed'; + }); }); diff --git a/utils/email.py b/utils/email.py new file mode 100644 index 0000000..6c3b511 --- /dev/null +++ b/utils/email.py @@ -0,0 +1,57 @@ +""" +utils/email.py — SMTP email helper. +Reads server settings from app_settings (email.smtp_*) with .env fallbacks. +""" + +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from config import get_setting + +logger = logging.getLogger("utils.email") + + +def send_email(to, subject: str, body_text: str, body_html: str = None) -> None: + """Send email via configured SMTP. `to` may be a str or list[str]. Raises on failure.""" + host = get_setting("email.smtp_host", "").strip() + port = get_setting("email.smtp_port", "587").strip() + user = get_setting("email.smtp_user", "").strip() + passwd = get_setting("email.smtp_password", "").strip() + from_ = get_setting("email.smtp_from", "").strip() or user + + if not host: + raise ValueError("SMTP host not configured. Add it via Admin → Settings.") + + try: + port = int(port) + except (ValueError, TypeError): + port = 587 + + recipients = [to] if isinstance(to, str) else list(to) + if not recipients: + raise ValueError("No recipients specified.") + + if body_html: + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(body_text, "plain")) + msg.attach(MIMEText(body_html, "html")) + else: + msg = MIMEText(body_text, "plain") + + msg["Subject"] = subject + msg["From"] = from_ + msg["To"] = ", ".join(recipients) + + with smtplib.SMTP(host, port, timeout=15) as smtp: + smtp.ehlo() + try: + smtp.starttls() + smtp.ehlo() + except smtplib.SMTPException: + pass # server may not support STARTTLS + if user and passwd: + smtp.login(user, passwd) + smtp.sendmail(from_, recipients, msg.as_string()) + + logger.info("Email sent to %s: %r", recipients, subject)