commit 0072054fc7f23e17ee800beb8472e0df744df095 Author: jpmvaz Date: Sun Sep 13 20:15:10 2026 +0100 v_1.2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..390c06a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATA_DIR=/data PORT=8080 +VOLUME /data +EXPOSE 8080 +CMD ["python", "app.py"] diff --git a/OLD_VERSIONS/v1.0/birthday-app.zip b/OLD_VERSIONS/v1.0/birthday-app.zip new file mode 100644 index 0000000..a6528e7 Binary files /dev/null and b/OLD_VERSIONS/v1.0/birthday-app.zip differ diff --git a/OLD_VERSIONS/v1.1/birthday-app.zip b/OLD_VERSIONS/v1.1/birthday-app.zip new file mode 100644 index 0000000..87a1e39 Binary files /dev/null and b/OLD_VERSIONS/v1.1/birthday-app.zip differ diff --git a/OLD_VERSIONS/v1.1/birthday-app/Dockerfile b/OLD_VERSIONS/v1.1/birthday-app/Dockerfile new file mode 100644 index 0000000..390c06a --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATA_DIR=/data PORT=8080 +VOLUME /data +EXPOSE 8080 +CMD ["python", "app.py"] diff --git a/OLD_VERSIONS/v1.1/birthday-app/README.md b/OLD_VERSIONS/v1.1/birthday-app/README.md new file mode 100644 index 0000000..94b77da --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/README.md @@ -0,0 +1,90 @@ +# Birthday Manager + +Self-hosted birthday reminder application that automatically e-mails people on their birthday. + +© 2026 Martinhal IT - Joao Vaz - Version 1.1 + +## Features + +**Page 1 — Dashboard** +- Banner showing whose birthday it is today (based on system date/time) with a **Force Send** button to resend the e-mail +- Add / remove birthday entries: First Name, Last Name, Date of Birth, Gender, E-mail address +- Yearly calendar overview of all entries (hover a marked day to see the names; browse other years) + +**Page 2 — Administration** +- Add / remove application users +- Every user can enable/disable MFA (TOTP — Google Authenticator, Authy, etc.) and change their own password +- Full e-mail log: who received mail, at what date & time, and whether it succeeded +- Two template zones: **E-mail Template Male** and **E-Mail Template Female**, with placeholders `{first_name}`, `{last_name}`, `{age}` +- Image upload — reference an uploaded image inside a template with `` and it is embedded inline in the outgoing e-mail + +Automatic sending: a scheduler runs every day at `SEND_HOUR` (default 08:00) and e-mails everyone whose birthday it is, using the template matching their gender. Duplicates are avoided (one automatic mail per person per day); **Force Send** always resends. February 29 birthdays are celebrated on February 28 in non-leap years. + +Every successfully sent e-mail is also copied to the mailbox's **Sent** folder via IMAP, so it shows up in your normal mail client. + +## Mail provider + +The app comes pre-configured for **Purelymail**: + +| | Server | Port | Security | +|---|---|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com` | `465` | SSL/TLS | +| Incoming (IMAP, Sent-folder copy) | `imap.purelymail.com` | `993` | SSL/TLS | + +You only need to fill in `SMTP_USER`, `SMTP_PASS` and `SMTP_FROM` with your Purelymail mailbox credentials. If your network only supports STARTTLS for SMTP, set `SMTP_PORT=587` and `SMTP_SECURITY=starttls` instead. Any other provider works too — just override the host/port variables. + +## Quick start (Docker — recommended) + +1. Edit `docker-compose.yml` and fill in your Purelymail credentials (`SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`) plus a random `SECRET_KEY` and your timezone. +2. Run: + +```bash +docker compose up -d --build +``` + +3. Open http://localhost:8080 and log in with **admin / admin** — then change the password on the Administration page immediately. + +All data (SQLite database + uploaded images) lives in `./data`, so back up that folder. + +## Quick start (plain Python) + +```bash +pip install -r requirements.txt + +# Purelymail servers are the built-in defaults - only credentials are needed +export SMTP_USER=you@yourdomain.com +export SMTP_PASS=your-purelymail-password +export SMTP_FROM=you@yourdomain.com +export SEND_HOUR=8 +export SECRET_KEY=$(python -c "import secrets;print(secrets.token_hex(32))") + +python app.py +``` + +The app listens on port 8080 (`PORT` env var to change). Data is stored in `./data` (`DATA_DIR` env var to change). + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `SMTP_HOST` | `smtp.purelymail.com` | SMTP server | +| `SMTP_PORT` | `465` | SMTP port | +| `SMTP_USER` | – | SMTP username / mailbox | +| `SMTP_PASS` | – | SMTP password | +| `SMTP_FROM` | `SMTP_USER` | From address | +| `SMTP_SECURITY` | `ssl` | `ssl`, `starttls` or `none` | +| `IMAP_HOST` | `imap.purelymail.com` | IMAP server for the Sent-folder copy | +| `IMAP_PORT` | `993` | IMAP port (SSL/TLS) | +| `IMAP_USER` | `SMTP_USER` | IMAP username | +| `IMAP_PASS` | `SMTP_PASS` | IMAP password | +| `IMAP_SENT_FOLDER` | `Sent` | Mailbox folder sent mail is stored in | +| `SEND_HOUR` | `8` | Hour (0–23) the daily automatic send runs | +| `SECRET_KEY` | random | Flask session key — set it so logins survive restarts | +| `PORT` | `8080` | HTTP port | +| `DATA_DIR` | `./data` | Where the database and uploads are stored | + +## Notes + +- The e-mail address field on entries is required because it is where the birthday greeting is sent. +- Failed sends (e.g. SMTP misconfigured) are also recorded in the e-mail log with the error as tooltip on the "Failed" status. If a mail is delivered but the IMAP copy to the Sent folder fails, the mail is still logged as Sent and the IMAP error appears as a tooltip. +- Run behind a reverse proxy (nginx / Caddy / Traefik) with HTTPS if you expose it beyond your LAN. diff --git a/OLD_VERSIONS/v1.1/birthday-app/app.py b/OLD_VERSIONS/v1.1/birthday-app/app.py new file mode 100644 index 0000000..f39cb8e --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/app.py @@ -0,0 +1,647 @@ +""" +Birthday Manager - self-hosted birthday reminder & e-mail application +(c) 2026 Martinhal IT - Joao Vaz - Version 1.1 +""" +import base64 +import calendar as cal +import imaplib +import io +import os +import re +import secrets +import smtplib +import sqlite3 +import uuid +from datetime import date, datetime +from email.mime.image import MIMEImage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from functools import wraps + +import pyotp +import qrcode +from apscheduler.schedulers.background import BackgroundScheduler +from flask import (Flask, flash, g, redirect, render_template, request, + send_from_directory, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(BASE_DIR, "data")) +UPLOAD_DIR = os.path.join(DATA_DIR, "uploads") +DB_PATH = os.path.join(DATA_DIR, "birthday.db") +ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp"} + +os.makedirs(UPLOAD_DIR, exist_ok=True) + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32)) +app.config["MAX_CONTENT_LENGTH"] = 8 * 1024 * 1024 # 8 MB uploads + +APP_VERSION = "Version 1.1" +FOOTER_TEXT = "\u00a9 2026 Martinhal IT - Joao Vaz - " + APP_VERSION + +# ---------------------------------------------------------------- database + +def get_db(): + if "db" not in g: + g.db = sqlite3.connect(DB_PATH) + g.db.row_factory = sqlite3.Row + g.db.execute("PRAGMA foreign_keys = ON") + return g.db + + +@app.teardown_appcontext +def close_db(_exc): + db = g.pop("db", None) + if db is not None: + db.close() + + +def db_connect(): + """Standalone connection for use outside a request (scheduler).""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +DEFAULT_TEMPLATE_MALE = ( + "

Dear {first_name} {last_name},

" + "

Happy Birthday! Wishing you a fantastic day and a wonderful year ahead. " + "Congratulations on turning {age}!

" + "

Best regards,
Martinhal IT

" +) +DEFAULT_TEMPLATE_FEMALE = ( + "

Dear {first_name} {last_name},

" + "

Happy Birthday! Wishing you a beautiful day filled with joy. " + "Congratulations on turning {age}!

" + "

Warm regards,
Martinhal IT

" +) + + +def init_db(): + conn = db_connect() + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + mfa_secret TEXT, + mfa_enabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS birthdays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + dob TEXT NOT NULL, -- YYYY-MM-DD + gender TEXT NOT NULL, -- Male / Female + email TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS email_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_name TEXT NOT NULL, + recipient_email TEXT NOT NULL, + subject TEXT NOT NULL, + sent_at TEXT NOT NULL, -- YYYY-MM-DD HH:MM:SS + status TEXT NOT NULL, -- Sent / Failed + detail TEXT + ); + CREATE TABLE IF NOT EXISTS templates ( + gender TEXT PRIMARY KEY, -- Male / Female + subject TEXT NOT NULL, + body TEXT NOT NULL + ); + """ + ) + # Seed default admin user + if conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + conn.execute( + "INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)", + ("admin", generate_password_hash("admin"), + datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + print(" * Created default user 'admin' with password 'admin' - change it!") + # Seed default templates + for gender, body in (("Male", DEFAULT_TEMPLATE_MALE), + ("Female", DEFAULT_TEMPLATE_FEMALE)): + conn.execute( + "INSERT OR IGNORE INTO templates (gender, subject, body) VALUES (?,?,?)", + (gender, "Happy Birthday, {first_name}!", body), + ) + conn.commit() + conn.close() + + +# ---------------------------------------------------------------- auth + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("user_id"): + return redirect(url_for("login", next=request.path)) + return view(*args, **kwargs) + return wrapped + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + user = get_db().execute( + "SELECT * FROM users WHERE username = ?", (username,) + ).fetchone() + if user and check_password_hash(user["password_hash"], password): + if user["mfa_enabled"]: + session.clear() + session["mfa_pending_user"] = user["id"] + return redirect(url_for("mfa_verify")) + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + return redirect(url_for("dashboard")) + flash("Invalid username or password.", "error") + return render_template("login.html", footer=FOOTER_TEXT) + + +@app.route("/mfa-verify", methods=["GET", "POST"]) +def mfa_verify(): + pending = session.get("mfa_pending_user") + if not pending: + return redirect(url_for("login")) + if request.method == "POST": + code = request.form.get("code", "").strip().replace(" ", "") + user = get_db().execute("SELECT * FROM users WHERE id = ?", (pending,)).fetchone() + if user and user["mfa_secret"] and pyotp.TOTP(user["mfa_secret"]).verify(code, valid_window=1): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + return redirect(url_for("dashboard")) + flash("Invalid authentication code.", "error") + return render_template("mfa_verify.html", footer=FOOTER_TEXT) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- helpers + +def calc_age(dob: date, on: date) -> int: + years = on.year - dob.year + if (on.month, on.day) < (dob.month, dob.day): + years -= 1 + return years + + +def birthday_matches_today(dob: date, today: date) -> bool: + """Feb 29 birthdays are celebrated on Feb 28 in non-leap years.""" + if dob.month == 2 and dob.day == 29 and not cal.isleap(today.year): + return today.month == 2 and today.day == 28 + return dob.month == today.month and dob.day == today.day + + +def todays_birthdays(conn): + today = date.today() + rows = conn.execute("SELECT * FROM birthdays").fetchall() + result = [] + for r in rows: + dob = datetime.strptime(r["dob"], "%Y-%m-%d").date() + if birthday_matches_today(dob, today): + result.append((r, calc_age(dob, today))) + return result + + +def render_placeholders(text: str, row, age: int) -> str: + return (text.replace("{first_name}", row["first_name"]) + .replace("{last_name}", row["last_name"]) + .replace("{age}", str(age))) + + +def smtp_settings(): + # Defaults are pre-configured for Purelymail (smtp.purelymail.com:465 SSL/TLS). + # If your client only supports STARTTLS, set SMTP_PORT=587 and SMTP_SECURITY=starttls. + return { + "host": os.environ.get("SMTP_HOST", "smtp.purelymail.com"), + "port": int(os.environ.get("SMTP_PORT", "465")), + "user": os.environ.get("SMTP_USER", ""), + "password": os.environ.get("SMTP_PASS", ""), + "sender": os.environ.get("SMTP_FROM", os.environ.get("SMTP_USER", "birthday@localhost")), + "security": os.environ.get("SMTP_SECURITY", "ssl").lower(), # ssl / starttls / none + } + + +def imap_settings(): + # Defaults are pre-configured for Purelymail (imap.purelymail.com:993 SSL/TLS). + # IMAP credentials fall back to the SMTP ones if not set separately. + return { + "host": os.environ.get("IMAP_HOST", "imap.purelymail.com"), + "port": int(os.environ.get("IMAP_PORT", "993")), + "user": os.environ.get("IMAP_USER", os.environ.get("SMTP_USER", "")), + "password": os.environ.get("IMAP_PASS", os.environ.get("SMTP_PASS", "")), + "folder": os.environ.get("IMAP_SENT_FOLDER", "Sent"), + } + + +def save_to_sent_folder(msg) -> str: + """Append the sent message to the mailbox's Sent folder via IMAP. + Returns '' on success or an error description on failure.""" + cfg = imap_settings() + if not cfg["user"]: + return "IMAP not configured - message not copied to Sent folder" + try: + imap = imaplib.IMAP4_SSL(cfg["host"], cfg["port"], timeout=20) + imap.login(cfg["user"], cfg["password"]) + folder = cfg["folder"] + # Create the folder if the server doesn't have it yet + status, _ = imap.select(folder) + if status != "OK": + imap.create(folder) + imap.append(folder, r"(\Seen)", imaplib.Time2Internaldate(datetime.now().timestamp()), + msg.as_bytes()) + imap.logout() + return "" + except Exception as exc: + return f"Delivered, but copy to Sent folder failed: {exc}" + + +IMG_SRC_RE = re.compile(r'src="/uploads/([^"]+)"') + + +def build_email(row, age: int, conn) -> MIMEMultipart: + tpl = conn.execute( + "SELECT * FROM templates WHERE gender = ?", (row["gender"],) + ).fetchone() + subject = render_placeholders(tpl["subject"], row, age) + body_html = render_placeholders(tpl["body"], row, age) + + msg = MIMEMultipart("related") + msg["Subject"] = subject + msg["From"] = smtp_settings()["sender"] + msg["To"] = row["email"] + + # Embed uploaded images referenced in the template as inline attachments + inline = [] + def to_cid(match): + fname = match.group(1) + path = os.path.join(UPLOAD_DIR, secure_filename(fname)) + if os.path.isfile(path): + cid = uuid.uuid4().hex + inline.append((path, cid)) + return f'src="cid:{cid}"' + return match.group(0) + + body_html = IMG_SRC_RE.sub(to_cid, body_html) + + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(re.sub(r"<[^>]+>", " ", body_html), "plain", "utf-8")) + alt.attach(MIMEText(body_html, "html", "utf-8")) + msg.attach(alt) + + for path, cid in inline: + with open(path, "rb") as f: + img = MIMEImage(f.read()) + img.add_header("Content-ID", f"<{cid}>") + img.add_header("Content-Disposition", "inline", filename=os.path.basename(path)) + msg.attach(img) + return msg + + +def send_birthday_email(row, age: int, conn) -> None: + cfg = smtp_settings() + msg = build_email(row, age, conn) + status, detail = "Sent", "" + try: + if not cfg["host"]: + raise RuntimeError("SMTP is not configured (set SMTP_HOST etc.)") + if cfg["security"] == "ssl": + server = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=20) + else: + server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) + if cfg["security"] == "starttls": + server.starttls() + if cfg["user"]: + server.login(cfg["user"], cfg["password"]) + server.send_message(msg) + server.quit() + detail = save_to_sent_folder(msg) # keep a copy in the mailbox Sent folder + except Exception as exc: # log failures too + status, detail = "Failed", str(exc) + conn.execute( + "INSERT INTO email_log (recipient_name, recipient_email, subject, sent_at, status, detail)" + " VALUES (?,?,?,?,?,?)", + (f'{row["first_name"]} {row["last_name"]}', row["email"], msg["Subject"], + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), status, detail), + ) + conn.commit() + + +def already_sent_today(conn, row) -> bool: + today = date.today().strftime("%Y-%m-%d") + hit = conn.execute( + "SELECT 1 FROM email_log WHERE recipient_email = ? AND status = 'Sent'" + " AND sent_at LIKE ?", (row["email"], today + "%"), + ).fetchone() + return hit is not None + + +def daily_birthday_job(): + conn = db_connect() + try: + for row, age in todays_birthdays(conn): + if not already_sent_today(conn, row): + send_birthday_email(row, age, conn) + finally: + conn.close() + + +# ---------------------------------------------------------------- page 1 + +MONTH_NAMES = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + + +def build_year_calendar(conn, year: int): + """Return list of month dicts with week grids; birthday days carry names.""" + rows = conn.execute("SELECT * FROM birthdays").fetchall() + marks = {} + for r in rows: + dob = datetime.strptime(r["dob"], "%Y-%m-%d").date() + m, d = dob.month, dob.day + if m == 2 and d == 29 and not cal.isleap(year): + d = 28 + marks.setdefault((m, d), []).append(f'{r["first_name"]} {r["last_name"]}') + months = [] + c = cal.Calendar(firstweekday=0) # Monday + today = date.today() + for m in range(1, 13): + weeks = [] + for week in c.monthdayscalendar(year, m): + weeks.append([ + { + "day": d or "", + "names": marks.get((m, d), []) if d else [], + "today": bool(d) and today.year == year and today.month == m and today.day == d, + } for d in week + ]) + months.append({"name": MONTH_NAMES[m - 1], "weeks": weeks}) + return months + + +@app.route("/", methods=["GET"]) +@login_required +def dashboard(): + conn = get_db() + year = request.args.get("year", type=int) or date.today().year + entries = conn.execute( + "SELECT * FROM birthdays ORDER BY substr(dob,6) ASC" + ).fetchall() + today_list = [ + {"row": r, "age": a, "sent": already_sent_today(conn, r)} + for r, a in todays_birthdays(conn) + ] + return render_template( + "dashboard.html", + footer=FOOTER_TEXT, + username=session.get("username"), + entries=entries, + today_list=today_list, + today=date.today(), + months=build_year_calendar(conn, year), + year=year, + ) + + +@app.route("/birthdays/add", methods=["POST"]) +@login_required +def add_birthday(): + f = request.form + first = f.get("first_name", "").strip() + last = f.get("last_name", "").strip() + dob = f.get("dob", "").strip() + gender = f.get("gender", "").strip() + email = f.get("email", "").strip() + try: + datetime.strptime(dob, "%Y-%m-%d") + assert first and last and email and gender in ("Male", "Female") + except (ValueError, AssertionError): + flash("Please fill in all fields with valid values.", "error") + return redirect(url_for("dashboard")) + get_db().execute( + "INSERT INTO birthdays (first_name, last_name, dob, gender, email) VALUES (?,?,?,?,?)", + (first, last, dob, gender, email), + ) + get_db().commit() + flash(f"Added {first} {last}.", "ok") + return redirect(url_for("dashboard")) + + +@app.route("/birthdays//delete", methods=["POST"]) +@login_required +def delete_birthday(bid): + get_db().execute("DELETE FROM birthdays WHERE id = ?", (bid,)) + get_db().commit() + flash("Entry removed.", "ok") + return redirect(url_for("dashboard")) + + +@app.route("/force-send", methods=["POST"]) +@login_required +def force_send(): + conn = get_db() + todays = todays_birthdays(conn) + if not todays: + flash("Nobody has a birthday today - nothing to send.", "error") + else: + for row, age in todays: + send_birthday_email(row, age, conn) + flash(f"Birthday e-mail resent to {len(todays)} recipient(s). Check the log on the Administration page.", "ok") + return redirect(url_for("dashboard")) + + +# ---------------------------------------------------------------- page 2 + +@app.route("/admin") +@login_required +def admin(): + conn = get_db() + users = conn.execute("SELECT id, username, mfa_enabled, created_at FROM users ORDER BY username").fetchall() + log = conn.execute("SELECT * FROM email_log ORDER BY sent_at DESC, id DESC LIMIT 500").fetchall() + tpl_male = conn.execute("SELECT * FROM templates WHERE gender='Male'").fetchone() + tpl_female = conn.execute("SELECT * FROM templates WHERE gender='Female'").fetchone() + images = sorted(os.listdir(UPLOAD_DIR)) + return render_template( + "admin.html", + footer=FOOTER_TEXT, + username=session.get("username"), + user_id=session.get("user_id"), + users=users, + log=log, + tpl_male=tpl_male, + tpl_female=tpl_female, + images=images, + ) + + +@app.route("/users/add", methods=["POST"]) +@login_required +def add_user(): + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if not username or len(password) < 4: + flash("Username and a password of at least 4 characters are required.", "error") + return redirect(url_for("admin")) + try: + get_db().execute( + "INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)", + (username, generate_password_hash(password), + datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + get_db().commit() + flash(f"User '{username}' created.", "ok") + except sqlite3.IntegrityError: + flash("That username already exists.", "error") + return redirect(url_for("admin")) + + +@app.route("/users//delete", methods=["POST"]) +@login_required +def delete_user(uid): + conn = get_db() + if uid == session.get("user_id"): + flash("You cannot delete the account you are logged in with.", "error") + elif conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] <= 1: + flash("At least one user must remain.", "error") + else: + conn.execute("DELETE FROM users WHERE id = ?", (uid,)) + conn.commit() + flash("User removed.", "ok") + return redirect(url_for("admin")) + + +@app.route("/account/password", methods=["POST"]) +@login_required +def change_password(): + current = request.form.get("current", "") + new = request.form.get("new", "") + confirm = request.form.get("confirm", "") + conn = get_db() + user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone() + if not check_password_hash(user["password_hash"], current): + flash("Current password is incorrect.", "error") + elif len(new) < 4: + flash("New password must be at least 4 characters.", "error") + elif new != confirm: + flash("New passwords do not match.", "error") + else: + conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", + (generate_password_hash(new), user["id"])) + conn.commit() + flash("Password changed.", "ok") + return redirect(url_for("admin")) + + +@app.route("/account/mfa/setup", methods=["GET", "POST"]) +@login_required +def mfa_setup(): + conn = get_db() + user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone() + if request.method == "POST": + code = request.form.get("code", "").strip().replace(" ", "") + secret = session.get("mfa_setup_secret") + if secret and pyotp.TOTP(secret).verify(code, valid_window=1): + conn.execute("UPDATE users SET mfa_secret = ?, mfa_enabled = 1 WHERE id = ?", + (secret, user["id"])) + conn.commit() + session.pop("mfa_setup_secret", None) + flash("Two-factor authentication is now enabled.", "ok") + return redirect(url_for("admin")) + flash("That code did not match. Scan the QR code and try again.", "error") + secret = session.get("mfa_setup_secret") or pyotp.random_base32() + session["mfa_setup_secret"] = secret + uri = pyotp.TOTP(secret).provisioning_uri( + name=user["username"], issuer_name="Birthday Manager") + buf = io.BytesIO() + qrcode.make(uri).save(buf, format="PNG") + qr_b64 = base64.b64encode(buf.getvalue()).decode() + return render_template("mfa_setup.html", footer=FOOTER_TEXT, + username=session.get("username"), + qr=qr_b64, secret=secret) + + +@app.route("/account/mfa/disable", methods=["POST"]) +@login_required +def mfa_disable(): + conn = get_db() + conn.execute("UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?", + (session["user_id"],)) + conn.commit() + flash("Two-factor authentication disabled for your account.", "ok") + return redirect(url_for("admin")) + + +@app.route("/templates/save", methods=["POST"]) +@login_required +def save_template(): + gender = request.form.get("gender") + if gender not in ("Male", "Female"): + flash("Unknown template.", "error") + return redirect(url_for("admin")) + subject = request.form.get("subject", "").strip() or "Happy Birthday, {first_name}!" + body = request.form.get("body", "").strip() + get_db().execute("UPDATE templates SET subject = ?, body = ? WHERE gender = ?", + (subject, body, gender)) + get_db().commit() + flash(f"E-mail template ({gender}) saved.", "ok") + return redirect(url_for("admin")) + + +@app.route("/images/upload", methods=["POST"]) +@login_required +def upload_image(): + file = request.files.get("image") + if not file or not file.filename: + flash("Choose an image file to upload.", "error") + return redirect(url_for("admin")) + name = secure_filename(file.filename) + ext = os.path.splitext(name)[1].lower() + if ext not in ALLOWED_IMAGE_EXT: + flash("Only PNG, JPG, GIF or WEBP images are allowed.", "error") + return redirect(url_for("admin")) + unique = f"{os.path.splitext(name)[0]}-{uuid.uuid4().hex[:6]}{ext}" + file.save(os.path.join(UPLOAD_DIR, unique)) + flash(f"Image uploaded. Insert it in a template with: ", "ok") + return redirect(url_for("admin")) + + +@app.route("/images//delete", methods=["POST"]) +@login_required +def delete_image(fname): + path = os.path.join(UPLOAD_DIR, secure_filename(fname)) + if os.path.isfile(path): + os.remove(path) + flash("Image deleted.", "ok") + return redirect(url_for("admin")) + + +@app.route("/uploads/") +@login_required +def serve_upload(fname): + return send_from_directory(UPLOAD_DIR, secure_filename(fname)) + + +# ---------------------------------------------------------------- startup + +init_db() +scheduler = BackgroundScheduler(daemon=True) +send_hour = int(os.environ.get("SEND_HOUR", "8")) +scheduler.add_job(daily_birthday_job, "cron", hour=send_hour, minute=0, + id="daily-birthday-mail") +scheduler.start() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")), debug=False) diff --git a/OLD_VERSIONS/v1.1/birthday-app/docker-compose.yml b/OLD_VERSIONS/v1.1/birthday-app/docker-compose.yml new file mode 100644 index 0000000..10ea775 --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/docker-compose.yml @@ -0,0 +1,30 @@ +services: + birthday-manager: + build: . + container_name: birthday-manager + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./data:/data + environment: + # --- Purelymail account (fill in your mailbox credentials) --- + SMTP_USER: "you@yourdomain.com" + SMTP_PASS: "your-purelymail-password" + SMTP_FROM: "you@yourdomain.com" + + # --- Outgoing mail (pre-configured for Purelymail, SSL/TLS) --- + SMTP_HOST: "smtp.purelymail.com" + SMTP_PORT: "465" + SMTP_SECURITY: "ssl" # if your network only allows STARTTLS: port 587 + "starttls" + + # --- Incoming mail / Sent-folder copy (pre-configured for Purelymail) --- + IMAP_HOST: "imap.purelymail.com" + IMAP_PORT: "993" # SSL/TLS + # IMAP_USER / IMAP_PASS default to the SMTP credentials above + IMAP_SENT_FOLDER: "Sent" + + # --- optional --- + SEND_HOUR: "8" # hour of day (0-23) automatic e-mails go out + SECRET_KEY: "change-this-to-a-long-random-string" + TZ: "Europe/Lisbon" diff --git a/OLD_VERSIONS/v1.1/birthday-app/requirements.txt b/OLD_VERSIONS/v1.1/birthday-app/requirements.txt new file mode 100644 index 0000000..98270a0 --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0 +pyotp>=2.9 +qrcode[pil]>=7.4 +APScheduler>=3.10 +werkzeug>=3.0 diff --git a/OLD_VERSIONS/v1.1/birthday-app/static/style.css b/OLD_VERSIONS/v1.1/birthday-app/static/style.css new file mode 100644 index 0000000..500ee5e --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/static/style.css @@ -0,0 +1,230 @@ +/* Birthday Manager — © 2026 Martinhal IT */ +:root { + --pine: #123f3a; + --pine-deep: #0c2b28; + --paper: #f6f4ee; + --card: #ffffff; + --ink: #21302d; + --muted: #6b7a76; + --line: #e2ddd1; + --marigold: #e8a13a; + --marigold-deep: #c07f1c; + --danger: #b3402a; + --ok: #2e7d4f; + font-size: 16px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--paper); + color: var(--ink); + font-family: "Inter", "Segoe UI", system-ui, sans-serif; +} + +h1, h2, h3, .brand-name, .cal-year { + font-family: "Sora", "Segoe UI", system-ui, sans-serif; + letter-spacing: -0.01em; +} +h1 { font-size: 1.6rem; margin: 0.2rem 0; } +h2 { font-size: 1.15rem; margin-top: 0; } +h3 { font-size: 0.9rem; margin: 0 0 0.4rem; } + +/* ---------- top bar ---------- */ +.topbar { + display: flex; + align-items: center; + gap: 2rem; + padding: 0.7rem 1.4rem; + background: var(--pine); + color: #f2efe6; +} +.brand { display: flex; align-items: center; gap: 0.55rem; font-weight: 700; } +.brand-mark { font-size: 1.3rem; } +.nav { display: flex; gap: 0.4rem; flex: 1; } +.nav a { + color: #cfe0d8; + text-decoration: none; + padding: 0.45rem 0.9rem; + border-radius: 8px; + font-weight: 500; +} +.nav a:hover { background: rgba(255,255,255,0.08); color: #fff; } +.nav a.active { background: var(--marigold); color: var(--pine-deep); font-weight: 600; } +.userbox { display: flex; align-items: center; gap: 0.7rem; } +.userchip { + background: rgba(255,255,255,0.12); + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.85rem; +} + +/* ---------- layout ---------- */ +.page { + flex: 1; + width: min(1180px, 100%); + margin: 0 auto; + padding: 1.4rem 1.2rem 2.5rem; +} +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 1.2rem 1.4rem; + margin-bottom: 1.3rem; + box-shadow: 0 1px 2px rgba(18, 63, 58, 0.05); +} +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1.3rem; } +.two-col .card { margin-bottom: 1.3rem; } +@media (max-width: 860px) { .two-col { grid-template-columns: 1fr; } } + +.footer { + text-align: center; + padding: 1rem; + background: var(--pine-deep); + color: #cfe0d8; + font-size: 0.85rem; +} + +/* ---------- today banner ---------- */ +.today-banner { + display: flex; + gap: 1.5rem; + align-items: center; + justify-content: space-between; + border-left: 6px solid var(--marigold); +} +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + color: var(--muted); + margin: 0; +} +.today-people { list-style: none; margin: 0.4rem 0 0; padding: 0; } +.today-people li { margin: 0.25rem 0; } +.today-right { text-align: right; flex-shrink: 0; max-width: 220px; } + +/* ---------- forms ---------- */ +label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; font-weight: 500; } +input, select, textarea { + font: inherit; + padding: 0.5rem 0.65rem; + border: 1px solid var(--line); + border-radius: 8px; + background: #fdfcf9; +} +input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible { + outline: 2px solid var(--marigold); + outline-offset: 1px; +} +textarea { resize: vertical; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 0.85rem; } +.entry-form { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + align-items: flex-end; + margin-bottom: 1rem; +} +.entry-form label { flex: 1 1 150px; } +.stack-form { display: flex; flex-direction: column; gap: 0.8rem; align-items: flex-start; } +.stack-form label { width: 100%; } +.row-gap { display: flex; gap: 0.7rem; margin-top: 0.8rem; } + +/* ---------- buttons ---------- */ +.btn { + font: inherit; + font-weight: 600; + border: none; + border-radius: 9px; + padding: 0.55rem 1.1rem; + cursor: pointer; + text-decoration: none; + display: inline-block; +} +.btn-primary { background: var(--pine); color: #fff; } +.btn-primary:hover { background: var(--pine-deep); } +.btn-accent { background: var(--marigold); color: var(--pine-deep); } +.btn-accent:hover { background: var(--marigold-deep); color: #fff; } +.btn-danger { background: #fbeae5; color: var(--danger); } +.btn-danger:hover { background: var(--danger); color: #fff; } +.btn-ghost { background: transparent; color: inherit; border: 1px solid currentColor; opacity: 0.85; } +.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.8rem; } +.btn-lg { padding: 0.8rem 1.6rem; font-size: 1.05rem; } +.btn-block { width: 100%; } +.btn[disabled] { opacity: 0.45; cursor: not-allowed; } + +/* ---------- tables ---------- */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } +th { text-align: left; color: var(--muted); font-weight: 600; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; } +th, td { padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--line); } +tbody tr:hover { background: #faf8f2; } +.ta-right { text-align: right; } +.nowrap { white-space: nowrap; } +.log-wrap { max-height: 420px; overflow-y: auto; } + +/* ---------- pills / flash ---------- */ +.pill { border-radius: 999px; padding: 0.15rem 0.6rem; font-size: 0.72rem; font-weight: 600; } +.pill-ok { background: #e2f2e8; color: var(--ok); } +.pill-bad { background: #fbeae5; color: var(--danger); } +.pill-wait { background: #fdf1dc; color: var(--marigold-deep); } +.flash { padding: 0.7rem 1rem; border-radius: 10px; margin-bottom: 1rem; font-size: 0.9rem; } +.flash-ok { background: #e2f2e8; color: var(--ok); } +.flash-error { background: #fbeae5; color: var(--danger); } + +/* ---------- yearly calendar ---------- */ +.cal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.8rem; } +.cal-nav { display: flex; align-items: center; gap: 0.7rem; } +.cal-year { font-size: 1.2rem; font-weight: 700; } +.year-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); + gap: 1rem; +} +.mini-cal { font-size: 0.72rem; } +.mini-cal th, .mini-cal td { padding: 0.22rem; text-align: center; border: none; } +.mini-cal td { position: relative; border-radius: 6px; } +.mini-cal td.bday { background: #fdf1dc; font-weight: 700; color: var(--marigold-deep); cursor: help; } +.mini-cal td.today { outline: 2px solid var(--pine); } +.mini-cal .dot { + position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); + width: 4px; height: 4px; border-radius: 50%; background: var(--marigold-deep); +} + +/* ---------- images ---------- */ +.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-top: 1rem; } +.image-tile { + border: 1px solid var(--line); border-radius: 10px; padding: 0.6rem; + display: flex; flex-direction: column; gap: 0.5rem; align-items: center; text-align: center; +} +.image-tile img { max-width: 100%; max-height: 110px; object-fit: contain; border-radius: 6px; } +.image-tile code { word-break: break-all; } + +/* ---------- auth / misc ---------- */ +.auth-card { + background: var(--card); border: 1px solid var(--line); border-radius: 14px; + max-width: 380px; margin: 8vh auto 0; padding: 2rem; + display: flex; flex-direction: column; gap: 0.4rem; +} +.auth-card.wide { max-width: 480px; margin-top: 3vh; } +.auth-card form { display: flex; flex-direction: column; gap: 0.9rem; margin-top: 0.8rem; } +.qr-wrap { text-align: center; margin: 0.5rem 0; } +.qr-wrap img { width: 190px; height: 190px; } +.muted { color: var(--muted); } +.small { font-size: 0.8rem; } +code { background: #f0ede4; padding: 0.1rem 0.35rem; border-radius: 5px; font-size: 0.82em; } +.tpl-zone textarea { width: 100%; } + +@media (prefers-reduced-motion: no-preference) { + .btn { transition: background 0.15s ease, color 0.15s ease; } +} +@media (max-width: 700px) { + .topbar { flex-wrap: wrap; gap: 0.6rem; } + .today-banner { flex-direction: column; align-items: flex-start; } + .today-right { text-align: left; } +} diff --git a/OLD_VERSIONS/v1.1/birthday-app/templates/admin.html b/OLD_VERSIONS/v1.1/birthday-app/templates/admin.html new file mode 100644 index 0000000..e3c7955 --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/templates/admin.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% set active = 'admin' %} +{% block title %}Administration - Birthday Manager{% endblock %} +{% block content %} + + +
+

Users

+
+ + + +
+
+ + + + {% for u in users %} + + + + + + + {% endfor %} + +
UsernameMFACreated
{{ u.username }}{% if u.id == user_id %} you{% endif %}{{ 'Enabled' if u.mfa_enabled else 'Off' }}{{ u.created_at }} + {% if u.id != user_id %} +
+ +
+ {% endif %} +
+
+
+ + +
+
+

Change my password

+
+ + + + +
+
+ +
+

Two-factor authentication (MFA)

+ {% set me = users | selectattr('id', 'equalto', user_id) | first %} + {% if me and me.mfa_enabled %} +

MFA is enabled for your account. You'll be asked for a 6-digit code every time you sign in.

+
+ +
+ {% else %} +

Protect your account with a one-time code from an authenticator app (Google Authenticator, Authy, Microsoft Authenticator…).

+ Enable MFA + {% endif %} +
+
+ + +
+

E-mail log

+ {% if log %} +
+ + + + {% for l in log %} + + + + + + + + {% endfor %} + +
Date & timeRecipientE-mail addressSubjectStatus
{{ l.sent_at }}{{ l.recipient_name }}{{ l.recipient_email }}{{ l.subject }} + {{ l.status }} +
+
+ {% else %} +

No e-mails have been sent yet.

+ {% endif %} +
+ + +
+

Images

+

Upload images here, then place them inside a template with + <img src="/uploads/filename">. Images are embedded inline in the e-mail when it is sent.

+
+ + +
+ {% if images %} +
+ {% for img in images %} +
+ {{ img }} + /uploads/{{ img }} +
+ +
+
+ {% endfor %} +
+ {% endif %} +
+ + +
+
+

E-mail Template Male

+
+ + + +

Placeholders: {first_name} {last_name} {age} — insert images with <img src="/uploads/…">

+ +
+
+ +
+

E-Mail Template Female

+
+ + + +

Placeholders: {first_name} {last_name} {age} — insert images with <img src="/uploads/…">

+ +
+
+
+ +{% endblock %} diff --git a/OLD_VERSIONS/v1.1/birthday-app/templates/base.html b/OLD_VERSIONS/v1.1/birthday-app/templates/base.html new file mode 100644 index 0000000..522cb7c --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/templates/base.html @@ -0,0 +1,40 @@ + + + + + + {% block title %}Birthday Manager{% endblock %} + + + + + +
+
+ 🎂 + Birthday Manager +
+ {% if username %} + +
+ {{ username }} + Log out +
+ {% endif %} +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
{{ footer }}
+ + diff --git a/OLD_VERSIONS/v1.1/birthday-app/templates/dashboard.html b/OLD_VERSIONS/v1.1/birthday-app/templates/dashboard.html new file mode 100644 index 0000000..5742ced --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/templates/dashboard.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% set active = 'dashboard' %} +{% block title %}Dashboard - Birthday Manager{% endblock %} +{% block content %} + + +
+
+

Today, {{ today.strftime('%A %d %B %Y') }}

+ {% if today_list %} +

🎉 Birthday{{ 's' if today_list|length > 1 }} today

+
    + {% for item in today_list %} +
  • + {{ item.row.first_name }} {{ item.row.last_name }} + turns {{ item.age }} + + {{ 'e-mail sent' if item.sent else 'e-mail pending' }} + +
  • + {% endfor %} +
+ {% else %} +

No birthdays today

+

The next celebration is waiting on the calendar below.

+ {% endif %} +
+
+ +

Sends today's birthday e-mail{{ 's' if today_list|length != 1 }} again immediately.

+
+
+ + +
+

Birthday entries

+
+ + + + + + +
+ + {% if entries %} +
+ + + + + + {% for e in entries %} + + + + + + + + + {% endfor %} + +
First NameLast NameDate of BirthGenderE-mail
{{ e.first_name }}{{ e.last_name }}{{ e.dob }}{{ e.gender }}{{ e.email }} +
+ +
+
+
+ {% else %} +

No entries yet. Add the first birthday above.

+ {% endif %} +
+ + +
+
+

Yearly overview

+ +
+
+ {% for month in months %} +
+

{{ month.name }}

+ + + + {% for week in month.weeks %} + + {% for cell in week %} + + {% endfor %} + + {% endfor %} + +
MoTuWeThFrSaSu
+ {{ cell.day }} + {% if cell.names %}{% endif %} +
+
+ {% endfor %} +
+

Days marked with a dot have a birthday — hover to see who. Today is outlined.

+
+ +{% endblock %} diff --git a/OLD_VERSIONS/v1.1/birthday-app/templates/login.html b/OLD_VERSIONS/v1.1/birthday-app/templates/login.html new file mode 100644 index 0000000..9a8afa3 --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Sign in - Birthday Manager{% endblock %} +{% block content %} +
+

Sign in

+

Birthday Manager

+
+ + + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v1.1/birthday-app/templates/mfa_setup.html b/OLD_VERSIONS/v1.1/birthday-app/templates/mfa_setup.html new file mode 100644 index 0000000..9c3e3f0 --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/templates/mfa_setup.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% set active = 'admin' %} +{% block title %}Enable two-factor authentication{% endblock %} +{% block content %} +
+

Enable two-factor authentication

+

Scan this QR code with Google Authenticator, Microsoft Authenticator, Authy or any TOTP app, then enter the 6-digit code it shows to confirm.

+
+ MFA QR code +

Can't scan? Enter this key manually: {{ secret }}

+
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/OLD_VERSIONS/v1.1/birthday-app/templates/mfa_verify.html b/OLD_VERSIONS/v1.1/birthday-app/templates/mfa_verify.html new file mode 100644 index 0000000..d0cdae8 --- /dev/null +++ b/OLD_VERSIONS/v1.1/birthday-app/templates/mfa_verify.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Two-factor authentication{% endblock %} +{% block content %} +
+

Two-factor code

+

Enter the 6-digit code from your authenticator app.

+
+ + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v1.2/birthday-app.zip b/OLD_VERSIONS/v1.2/birthday-app.zip new file mode 100644 index 0000000..b5646ce Binary files /dev/null and b/OLD_VERSIONS/v1.2/birthday-app.zip differ diff --git a/OLD_VERSIONS/v1.2/birthday-app/Dockerfile b/OLD_VERSIONS/v1.2/birthday-app/Dockerfile new file mode 100644 index 0000000..390c06a --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATA_DIR=/data PORT=8080 +VOLUME /data +EXPOSE 8080 +CMD ["python", "app.py"] diff --git a/OLD_VERSIONS/v1.2/birthday-app/README.md b/OLD_VERSIONS/v1.2/birthday-app/README.md new file mode 100644 index 0000000..631eccf --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/README.md @@ -0,0 +1,91 @@ +# Birthday Manager + +Self-hosted birthday reminder application that automatically e-mails people on their birthday. + +© 2026 Martinhal IT - Joao Vaz - Version 1.2 + +## Features + +**Page 1 — Dashboard** +- Three panels at the top: **Yesterday's Birthdays**, **Today's Birthdays** (with the **Force Send** button to resend the e-mail) and **Tomorrow's Birthdays** +- Add / remove birthday entries: First Name, Last Name, Date of Birth, Gender, E-mail address +- Yearly calendar overview of all entries (hover a marked day to see the names; browse other years) + +**Page 2 — Administration** +- Add / remove application users +- Every user can enable/disable MFA (TOTP — Google Authenticator, Authy, etc.) and change their own password +- Full e-mail log: who received mail, at what date & time, and whether it succeeded +- Setting for the time of day the automatic birthday e-mails are sent (applies immediately, no restart needed) +- Two template zones: **E-mail Template Male** and **E-Mail Template Female**, with placeholders `{first_name}`, `{last_name}`, `{age}` +- Image upload — select multiple files at once (15 MB total per upload); each image shows its ready-to-copy `` command, and images are embedded inline in the outgoing e-mail + +Automatic sending: a scheduler runs every day at `SEND_HOUR` (default 08:00) and e-mails everyone whose birthday it is, using the template matching their gender. Duplicates are avoided (one automatic mail per person per day); **Force Send** always resends. February 29 birthdays are celebrated on February 28 in non-leap years. + +Every successfully sent e-mail is also copied to the mailbox's **Sent** folder via IMAP, so it shows up in your normal mail client. + +## Mail provider + +The app comes pre-configured for **Purelymail**: + +| | Server | Port | Security | +|---|---|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com` | `465` | SSL/TLS | +| Incoming (IMAP, Sent-folder copy) | `imap.purelymail.com` | `993` | SSL/TLS | + +You only need to fill in `SMTP_USER`, `SMTP_PASS` and `SMTP_FROM` with your Purelymail mailbox credentials. If your network only supports STARTTLS for SMTP, set `SMTP_PORT=587` and `SMTP_SECURITY=starttls` instead. Any other provider works too — just override the host/port variables. + +## Quick start (Docker — recommended) + +1. Edit `docker-compose.yml` and fill in your Purelymail credentials (`SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`) plus a random `SECRET_KEY` and your timezone. +2. Run: + +```bash +docker compose up -d --build +``` + +3. Open http://localhost:8080 and log in with **admin / admin** — then change the password on the Administration page immediately. + +All data (SQLite database + uploaded images) lives in `./data`, so back up that folder. + +## Quick start (plain Python) + +```bash +pip install -r requirements.txt + +# Purelymail servers are the built-in defaults - only credentials are needed +export SMTP_USER=you@yourdomain.com +export SMTP_PASS=your-purelymail-password +export SMTP_FROM=you@yourdomain.com +export SEND_HOUR=8 +export SECRET_KEY=$(python -c "import secrets;print(secrets.token_hex(32))") + +python app.py +``` + +The app listens on port 8080 (`PORT` env var to change). Data is stored in `./data` (`DATA_DIR` env var to change). + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `SMTP_HOST` | `smtp.purelymail.com` | SMTP server | +| `SMTP_PORT` | `465` | SMTP port | +| `SMTP_USER` | – | SMTP username / mailbox | +| `SMTP_PASS` | – | SMTP password | +| `SMTP_FROM` | `SMTP_USER` | From address | +| `SMTP_SECURITY` | `ssl` | `ssl`, `starttls` or `none` | +| `IMAP_HOST` | `imap.purelymail.com` | IMAP server for the Sent-folder copy | +| `IMAP_PORT` | `993` | IMAP port (SSL/TLS) | +| `IMAP_USER` | `SMTP_USER` | IMAP username | +| `IMAP_PASS` | `SMTP_PASS` | IMAP password | +| `IMAP_SENT_FOLDER` | `Sent` | Mailbox folder sent mail is stored in | +| `SEND_HOUR` | `8` | Default hour for the daily send — the time picked on the Administration page overrides this | +| `SECRET_KEY` | random | Flask session key — set it so logins survive restarts | +| `PORT` | `8080` | HTTP port | +| `DATA_DIR` | `./data` | Where the database and uploads are stored | + +## Notes + +- The e-mail address field on entries is required because it is where the birthday greeting is sent. +- Failed sends (e.g. SMTP misconfigured) are also recorded in the e-mail log with the error as tooltip on the "Failed" status. If a mail is delivered but the IMAP copy to the Sent folder fails, the mail is still logged as Sent and the IMAP error appears as a tooltip. +- Run behind a reverse proxy (nginx / Caddy / Traefik) with HTTPS if you expose it beyond your LAN. diff --git a/OLD_VERSIONS/v1.2/birthday-app/app.py b/OLD_VERSIONS/v1.2/birthday-app/app.py new file mode 100644 index 0000000..314d7c8 --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/app.py @@ -0,0 +1,724 @@ +""" +Birthday Manager - self-hosted birthday reminder & e-mail application +(c) 2026 Martinhal IT - Joao Vaz - Version 1.2 +""" +import base64 +import calendar as cal +import imaplib +import io +import os +import re +import secrets +import smtplib +import sqlite3 +import uuid +from datetime import date, datetime +from email.mime.image import MIMEImage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from functools import wraps + +import pyotp +import qrcode +from apscheduler.schedulers.background import BackgroundScheduler +from flask import (Flask, flash, g, redirect, render_template, request, + send_from_directory, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(BASE_DIR, "data")) +UPLOAD_DIR = os.path.join(DATA_DIR, "uploads") +DB_PATH = os.path.join(DATA_DIR, "birthday.db") +ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp"} + +os.makedirs(UPLOAD_DIR, exist_ok=True) + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32)) +app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024 # 15 MB total upload limit + +APP_VERSION = "Version 1.2" +FOOTER_TEXT = "\u00a9 2026 Martinhal IT - Joao Vaz - " + APP_VERSION + +# ---------------------------------------------------------------- database + +def get_db(): + if "db" not in g: + g.db = sqlite3.connect(DB_PATH) + g.db.row_factory = sqlite3.Row + g.db.execute("PRAGMA foreign_keys = ON") + return g.db + + +@app.teardown_appcontext +def close_db(_exc): + db = g.pop("db", None) + if db is not None: + db.close() + + +def db_connect(): + """Standalone connection for use outside a request (scheduler).""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +DEFAULT_TEMPLATE_MALE = ( + "

Dear {first_name} {last_name},

" + "

Happy Birthday! Wishing you a fantastic day and a wonderful year ahead. " + "Congratulations on turning {age}!

" + "

Best regards,
Martinhal IT

" +) +DEFAULT_TEMPLATE_FEMALE = ( + "

Dear {first_name} {last_name},

" + "

Happy Birthday! Wishing you a beautiful day filled with joy. " + "Congratulations on turning {age}!

" + "

Warm regards,
Martinhal IT

" +) + + +def init_db(): + conn = db_connect() + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + mfa_secret TEXT, + mfa_enabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS birthdays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + dob TEXT NOT NULL, -- YYYY-MM-DD + gender TEXT NOT NULL, -- Male / Female + email TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS email_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_name TEXT NOT NULL, + recipient_email TEXT NOT NULL, + subject TEXT NOT NULL, + sent_at TEXT NOT NULL, -- YYYY-MM-DD HH:MM:SS + status TEXT NOT NULL, -- Sent / Failed + detail TEXT + ); + CREATE TABLE IF NOT EXISTS templates ( + gender TEXT PRIMARY KEY, -- Male / Female + subject TEXT NOT NULL, + body TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """ + ) + # Seed default admin user + if conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + conn.execute( + "INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)", + ("admin", generate_password_hash("admin"), + datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + print(" * Created default user 'admin' with password 'admin' - change it!") + # Seed default templates + for gender, body in (("Male", DEFAULT_TEMPLATE_MALE), + ("Female", DEFAULT_TEMPLATE_FEMALE)): + conn.execute( + "INSERT OR IGNORE INTO templates (gender, subject, body) VALUES (?,?,?)", + (gender, "Happy Birthday, {first_name}!", body), + ) + conn.commit() + conn.close() + + +def get_setting(conn, key, default=None): + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else default + + +def set_setting(conn, key, value): + conn.execute( + "INSERT INTO settings (key, value) VALUES (?,?)" + " ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value)) + conn.commit() + + +def get_send_time(conn) -> tuple[int, int]: + default = f'{int(os.environ.get("SEND_HOUR", "8")):02d}:00' + raw = get_setting(conn, "send_time", default) + try: + h, m = raw.split(":") + return max(0, min(23, int(h))), max(0, min(59, int(m))) + except ValueError: + return 8, 0 + + +# ---------------------------------------------------------------- auth + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("user_id"): + return redirect(url_for("login", next=request.path)) + return view(*args, **kwargs) + return wrapped + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + user = get_db().execute( + "SELECT * FROM users WHERE username = ?", (username,) + ).fetchone() + if user and check_password_hash(user["password_hash"], password): + if user["mfa_enabled"]: + session.clear() + session["mfa_pending_user"] = user["id"] + return redirect(url_for("mfa_verify")) + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + return redirect(url_for("dashboard")) + flash("Invalid username or password.", "error") + return render_template("login.html", footer=FOOTER_TEXT) + + +@app.route("/mfa-verify", methods=["GET", "POST"]) +def mfa_verify(): + pending = session.get("mfa_pending_user") + if not pending: + return redirect(url_for("login")) + if request.method == "POST": + code = request.form.get("code", "").strip().replace(" ", "") + user = get_db().execute("SELECT * FROM users WHERE id = ?", (pending,)).fetchone() + if user and user["mfa_secret"] and pyotp.TOTP(user["mfa_secret"]).verify(code, valid_window=1): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + return redirect(url_for("dashboard")) + flash("Invalid authentication code.", "error") + return render_template("mfa_verify.html", footer=FOOTER_TEXT) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- helpers + +def calc_age(dob: date, on: date) -> int: + years = on.year - dob.year + if (on.month, on.day) < (dob.month, dob.day): + years -= 1 + return years + + +def birthday_matches_day(dob: date, day: date) -> bool: + """Feb 29 birthdays are celebrated on Feb 28 in non-leap years.""" + if dob.month == 2 and dob.day == 29 and not cal.isleap(day.year): + return day.month == 2 and day.day == 28 + return dob.month == day.month and dob.day == day.day + + +def birthdays_on(conn, day: date): + rows = conn.execute("SELECT * FROM birthdays").fetchall() + result = [] + for r in rows: + dob = datetime.strptime(r["dob"], "%Y-%m-%d").date() + if birthday_matches_day(dob, day): + result.append((r, calc_age(dob, day))) + return result + + +def todays_birthdays(conn): + return birthdays_on(conn, date.today()) + + +def render_placeholders(text: str, row, age: int) -> str: + return (text.replace("{first_name}", row["first_name"]) + .replace("{last_name}", row["last_name"]) + .replace("{age}", str(age))) + + +def smtp_settings(): + # Defaults are pre-configured for Purelymail (smtp.purelymail.com:465 SSL/TLS). + # If your client only supports STARTTLS, set SMTP_PORT=587 and SMTP_SECURITY=starttls. + return { + "host": os.environ.get("SMTP_HOST", "smtp.purelymail.com"), + "port": int(os.environ.get("SMTP_PORT", "465")), + "user": os.environ.get("SMTP_USER", ""), + "password": os.environ.get("SMTP_PASS", ""), + "sender": os.environ.get("SMTP_FROM", os.environ.get("SMTP_USER", "birthday@localhost")), + "security": os.environ.get("SMTP_SECURITY", "ssl").lower(), # ssl / starttls / none + } + + +def imap_settings(): + # Defaults are pre-configured for Purelymail (imap.purelymail.com:993 SSL/TLS). + # IMAP credentials fall back to the SMTP ones if not set separately. + return { + "host": os.environ.get("IMAP_HOST", "imap.purelymail.com"), + "port": int(os.environ.get("IMAP_PORT", "993")), + "user": os.environ.get("IMAP_USER", os.environ.get("SMTP_USER", "")), + "password": os.environ.get("IMAP_PASS", os.environ.get("SMTP_PASS", "")), + "folder": os.environ.get("IMAP_SENT_FOLDER", "Sent"), + } + + +def save_to_sent_folder(msg) -> str: + """Append the sent message to the mailbox's Sent folder via IMAP. + Returns '' on success or an error description on failure.""" + cfg = imap_settings() + if not cfg["user"]: + return "IMAP not configured - message not copied to Sent folder" + try: + imap = imaplib.IMAP4_SSL(cfg["host"], cfg["port"], timeout=20) + imap.login(cfg["user"], cfg["password"]) + folder = cfg["folder"] + # Create the folder if the server doesn't have it yet + status, _ = imap.select(folder) + if status != "OK": + imap.create(folder) + imap.append(folder, r"(\Seen)", imaplib.Time2Internaldate(datetime.now().timestamp()), + msg.as_bytes()) + imap.logout() + return "" + except Exception as exc: + return f"Delivered, but copy to Sent folder failed: {exc}" + + +IMG_SRC_RE = re.compile(r'src="/uploads/([^"]+)"') + + +def build_email(row, age: int, conn) -> MIMEMultipart: + tpl = conn.execute( + "SELECT * FROM templates WHERE gender = ?", (row["gender"],) + ).fetchone() + subject = render_placeholders(tpl["subject"], row, age) + body_html = render_placeholders(tpl["body"], row, age) + + msg = MIMEMultipart("related") + msg["Subject"] = subject + msg["From"] = smtp_settings()["sender"] + msg["To"] = row["email"] + + # Embed uploaded images referenced in the template as inline attachments + inline = [] + def to_cid(match): + fname = match.group(1) + path = os.path.join(UPLOAD_DIR, secure_filename(fname)) + if os.path.isfile(path): + cid = uuid.uuid4().hex + inline.append((path, cid)) + return f'src="cid:{cid}"' + return match.group(0) + + body_html = IMG_SRC_RE.sub(to_cid, body_html) + + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(re.sub(r"<[^>]+>", " ", body_html), "plain", "utf-8")) + alt.attach(MIMEText(body_html, "html", "utf-8")) + msg.attach(alt) + + for path, cid in inline: + with open(path, "rb") as f: + img = MIMEImage(f.read()) + img.add_header("Content-ID", f"<{cid}>") + img.add_header("Content-Disposition", "inline", filename=os.path.basename(path)) + msg.attach(img) + return msg + + +def send_birthday_email(row, age: int, conn) -> None: + cfg = smtp_settings() + msg = build_email(row, age, conn) + status, detail = "Sent", "" + try: + if not cfg["host"]: + raise RuntimeError("SMTP is not configured (set SMTP_HOST etc.)") + if cfg["security"] == "ssl": + server = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=20) + else: + server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) + if cfg["security"] == "starttls": + server.starttls() + if cfg["user"]: + server.login(cfg["user"], cfg["password"]) + server.send_message(msg) + server.quit() + detail = save_to_sent_folder(msg) # keep a copy in the mailbox Sent folder + except Exception as exc: # log failures too + status, detail = "Failed", str(exc) + conn.execute( + "INSERT INTO email_log (recipient_name, recipient_email, subject, sent_at, status, detail)" + " VALUES (?,?,?,?,?,?)", + (f'{row["first_name"]} {row["last_name"]}', row["email"], msg["Subject"], + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), status, detail), + ) + conn.commit() + + +def already_sent_today(conn, row) -> bool: + today = date.today().strftime("%Y-%m-%d") + hit = conn.execute( + "SELECT 1 FROM email_log WHERE recipient_email = ? AND status = 'Sent'" + " AND sent_at LIKE ?", (row["email"], today + "%"), + ).fetchone() + return hit is not None + + +def daily_birthday_job(): + conn = db_connect() + try: + for row, age in todays_birthdays(conn): + if not already_sent_today(conn, row): + send_birthday_email(row, age, conn) + finally: + conn.close() + + +# ---------------------------------------------------------------- page 1 + +MONTH_NAMES = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + + +def build_year_calendar(conn, year: int): + """Return list of month dicts with week grids; birthday days carry names.""" + rows = conn.execute("SELECT * FROM birthdays").fetchall() + marks = {} + for r in rows: + dob = datetime.strptime(r["dob"], "%Y-%m-%d").date() + m, d = dob.month, dob.day + if m == 2 and d == 29 and not cal.isleap(year): + d = 28 + marks.setdefault((m, d), []).append(f'{r["first_name"]} {r["last_name"]}') + months = [] + c = cal.Calendar(firstweekday=0) # Monday + today = date.today() + for m in range(1, 13): + weeks = [] + for week in c.monthdayscalendar(year, m): + weeks.append([ + { + "day": d or "", + "names": marks.get((m, d), []) if d else [], + "today": bool(d) and today.year == year and today.month == m and today.day == d, + } for d in week + ]) + months.append({"name": MONTH_NAMES[m - 1], "weeks": weeks}) + return months + + +@app.route("/", methods=["GET"]) +@login_required +def dashboard(): + conn = get_db() + year = request.args.get("year", type=int) or date.today().year + entries = conn.execute( + "SELECT * FROM birthdays ORDER BY substr(dob,6) ASC" + ).fetchall() + from datetime import timedelta + today = date.today() + today_list = [ + {"row": r, "age": a, "sent": already_sent_today(conn, r)} + for r, a in todays_birthdays(conn) + ] + yesterday_list = [{"row": r, "age": a} for r, a in birthdays_on(conn, today - timedelta(days=1))] + tomorrow_list = [{"row": r, "age": a} for r, a in birthdays_on(conn, today + timedelta(days=1))] + return render_template( + "dashboard.html", + footer=FOOTER_TEXT, + username=session.get("username"), + entries=entries, + today_list=today_list, + yesterday_list=yesterday_list, + tomorrow_list=tomorrow_list, + today=today, + months=build_year_calendar(conn, year), + year=year, + ) + + +@app.route("/birthdays/add", methods=["POST"]) +@login_required +def add_birthday(): + f = request.form + first = f.get("first_name", "").strip() + last = f.get("last_name", "").strip() + dob = f.get("dob", "").strip() + gender = f.get("gender", "").strip() + email = f.get("email", "").strip() + try: + datetime.strptime(dob, "%Y-%m-%d") + assert first and last and email and gender in ("Male", "Female") + except (ValueError, AssertionError): + flash("Please fill in all fields with valid values.", "error") + return redirect(url_for("dashboard")) + get_db().execute( + "INSERT INTO birthdays (first_name, last_name, dob, gender, email) VALUES (?,?,?,?,?)", + (first, last, dob, gender, email), + ) + get_db().commit() + flash(f"Added {first} {last}.", "ok") + return redirect(url_for("dashboard")) + + +@app.route("/birthdays//delete", methods=["POST"]) +@login_required +def delete_birthday(bid): + get_db().execute("DELETE FROM birthdays WHERE id = ?", (bid,)) + get_db().commit() + flash("Entry removed.", "ok") + return redirect(url_for("dashboard")) + + +@app.route("/force-send", methods=["POST"]) +@login_required +def force_send(): + conn = get_db() + todays = todays_birthdays(conn) + if not todays: + flash("Nobody has a birthday today - nothing to send.", "error") + else: + for row, age in todays: + send_birthday_email(row, age, conn) + flash(f"Birthday e-mail resent to {len(todays)} recipient(s). Check the log on the Administration page.", "ok") + return redirect(url_for("dashboard")) + + +# ---------------------------------------------------------------- page 2 + +@app.route("/admin") +@login_required +def admin(): + conn = get_db() + users = conn.execute("SELECT id, username, mfa_enabled, created_at FROM users ORDER BY username").fetchall() + log = conn.execute("SELECT * FROM email_log ORDER BY sent_at DESC, id DESC LIMIT 500").fetchall() + tpl_male = conn.execute("SELECT * FROM templates WHERE gender='Male'").fetchone() + tpl_female = conn.execute("SELECT * FROM templates WHERE gender='Female'").fetchone() + images = sorted(os.listdir(UPLOAD_DIR)) + h, m = get_send_time(conn) + return render_template( + "admin.html", + send_time=f"{h:02d}:{m:02d}", + footer=FOOTER_TEXT, + username=session.get("username"), + user_id=session.get("user_id"), + users=users, + log=log, + tpl_male=tpl_male, + tpl_female=tpl_female, + images=images, + ) + + +@app.route("/users/add", methods=["POST"]) +@login_required +def add_user(): + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if not username or len(password) < 4: + flash("Username and a password of at least 4 characters are required.", "error") + return redirect(url_for("admin")) + try: + get_db().execute( + "INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)", + (username, generate_password_hash(password), + datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + get_db().commit() + flash(f"User '{username}' created.", "ok") + except sqlite3.IntegrityError: + flash("That username already exists.", "error") + return redirect(url_for("admin")) + + +@app.route("/users//delete", methods=["POST"]) +@login_required +def delete_user(uid): + conn = get_db() + if uid == session.get("user_id"): + flash("You cannot delete the account you are logged in with.", "error") + elif conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] <= 1: + flash("At least one user must remain.", "error") + else: + conn.execute("DELETE FROM users WHERE id = ?", (uid,)) + conn.commit() + flash("User removed.", "ok") + return redirect(url_for("admin")) + + +@app.route("/account/password", methods=["POST"]) +@login_required +def change_password(): + current = request.form.get("current", "") + new = request.form.get("new", "") + confirm = request.form.get("confirm", "") + conn = get_db() + user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone() + if not check_password_hash(user["password_hash"], current): + flash("Current password is incorrect.", "error") + elif len(new) < 4: + flash("New password must be at least 4 characters.", "error") + elif new != confirm: + flash("New passwords do not match.", "error") + else: + conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", + (generate_password_hash(new), user["id"])) + conn.commit() + flash("Password changed.", "ok") + return redirect(url_for("admin")) + + +@app.route("/account/mfa/setup", methods=["GET", "POST"]) +@login_required +def mfa_setup(): + conn = get_db() + user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone() + if request.method == "POST": + code = request.form.get("code", "").strip().replace(" ", "") + secret = session.get("mfa_setup_secret") + if secret and pyotp.TOTP(secret).verify(code, valid_window=1): + conn.execute("UPDATE users SET mfa_secret = ?, mfa_enabled = 1 WHERE id = ?", + (secret, user["id"])) + conn.commit() + session.pop("mfa_setup_secret", None) + flash("Two-factor authentication is now enabled.", "ok") + return redirect(url_for("admin")) + flash("That code did not match. Scan the QR code and try again.", "error") + secret = session.get("mfa_setup_secret") or pyotp.random_base32() + session["mfa_setup_secret"] = secret + uri = pyotp.TOTP(secret).provisioning_uri( + name=user["username"], issuer_name="Birthday Manager") + buf = io.BytesIO() + qrcode.make(uri).save(buf, format="PNG") + qr_b64 = base64.b64encode(buf.getvalue()).decode() + return render_template("mfa_setup.html", footer=FOOTER_TEXT, + username=session.get("username"), + qr=qr_b64, secret=secret) + + +@app.route("/account/mfa/disable", methods=["POST"]) +@login_required +def mfa_disable(): + conn = get_db() + conn.execute("UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?", + (session["user_id"],)) + conn.commit() + flash("Two-factor authentication disabled for your account.", "ok") + return redirect(url_for("admin")) + + +@app.route("/settings/send-time", methods=["POST"]) +@login_required +def save_send_time(): + raw = request.form.get("send_time", "").strip() + try: + h, m = raw.split(":") + h, m = int(h), int(m) + assert 0 <= h <= 23 and 0 <= m <= 59 + except (ValueError, AssertionError): + flash("Please pick a valid time.", "error") + return redirect(url_for("admin")) + set_setting(get_db(), "send_time", f"{h:02d}:{m:02d}") + scheduler.reschedule_job("daily-birthday-mail", trigger="cron", hour=h, minute=m) + flash(f"Automatic birthday e-mails will now be sent daily at {h:02d}:{m:02d}.", "ok") + return redirect(url_for("admin")) + + +@app.route("/templates/save", methods=["POST"]) +@login_required +def save_template(): + gender = request.form.get("gender") + if gender not in ("Male", "Female"): + flash("Unknown template.", "error") + return redirect(url_for("admin")) + subject = request.form.get("subject", "").strip() or "Happy Birthday, {first_name}!" + body = request.form.get("body", "").strip() + get_db().execute("UPDATE templates SET subject = ?, body = ? WHERE gender = ?", + (subject, body, gender)) + get_db().commit() + flash(f"E-mail template ({gender}) saved.", "ok") + return redirect(url_for("admin")) + + +@app.route("/images/upload", methods=["POST"]) +@login_required +def upload_image(): + files = [f for f in request.files.getlist("images") if f and f.filename] + if not files: + flash("Choose one or more image files to upload.", "error") + return redirect(url_for("admin")) + # Enforce the 15 MB total limit across all files in this upload + total = 0 + for f in files: + f.stream.seek(0, os.SEEK_END) + total += f.stream.tell() + f.stream.seek(0) + if total > 15 * 1024 * 1024: + flash(f"Upload rejected: total size {total / (1024*1024):.1f} MB exceeds the 15 MB limit.", "error") + return redirect(url_for("admin")) + saved, skipped = [], [] + for f in files: + name = secure_filename(f.filename) + ext = os.path.splitext(name)[1].lower() + if ext not in ALLOWED_IMAGE_EXT: + skipped.append(f.filename) + continue + unique = f"{os.path.splitext(name)[0]}-{uuid.uuid4().hex[:6]}{ext}" + f.save(os.path.join(UPLOAD_DIR, unique)) + saved.append(unique) + if saved: + flash(f"{len(saved)} image(s) uploaded. Copy the command shown under each image to use it in a template.", "ok") + if skipped: + flash(f"Skipped (not PNG/JPG/GIF/WEBP): {', '.join(skipped)}", "error") + return redirect(url_for("admin")) + + +@app.errorhandler(413) +def too_large(_e): + flash("Upload rejected: the total upload size exceeds the 15 MB limit.", "error") + return redirect(url_for("admin")) + + +@app.route("/images//delete", methods=["POST"]) +@login_required +def delete_image(fname): + path = os.path.join(UPLOAD_DIR, secure_filename(fname)) + if os.path.isfile(path): + os.remove(path) + flash("Image deleted.", "ok") + return redirect(url_for("admin")) + + +@app.route("/uploads/") +@login_required +def serve_upload(fname): + return send_from_directory(UPLOAD_DIR, secure_filename(fname)) + + +# ---------------------------------------------------------------- startup + +init_db() +scheduler = BackgroundScheduler(daemon=True) +_conn = db_connect() +_h, _m = get_send_time(_conn) +_conn.close() +scheduler.add_job(daily_birthday_job, "cron", hour=_h, minute=_m, + id="daily-birthday-mail") +scheduler.start() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")), debug=False) diff --git a/OLD_VERSIONS/v1.2/birthday-app/docker-compose.yml b/OLD_VERSIONS/v1.2/birthday-app/docker-compose.yml new file mode 100644 index 0000000..4be567d --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/docker-compose.yml @@ -0,0 +1,30 @@ +services: + birthday-manager: + build: . + container_name: birthday-manager + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./data:/data + environment: + # --- Purelymail account (fill in your mailbox credentials) --- + SMTP_USER: "you@yourdomain.com" + SMTP_PASS: "your-purelymail-password" + SMTP_FROM: "you@yourdomain.com" + + # --- Outgoing mail (pre-configured for Purelymail, SSL/TLS) --- + SMTP_HOST: "smtp.purelymail.com" + SMTP_PORT: "465" + SMTP_SECURITY: "ssl" # if your network only allows STARTTLS: port 587 + "starttls" + + # --- Incoming mail / Sent-folder copy (pre-configured for Purelymail) --- + IMAP_HOST: "imap.purelymail.com" + IMAP_PORT: "993" # SSL/TLS + # IMAP_USER / IMAP_PASS default to the SMTP credentials above + IMAP_SENT_FOLDER: "Sent" + + # --- optional --- + SEND_HOUR: "8" # default only - set the real time on the Administration page + SECRET_KEY: "change-this-to-a-long-random-string" + TZ: "Europe/Lisbon" diff --git a/OLD_VERSIONS/v1.2/birthday-app/requirements.txt b/OLD_VERSIONS/v1.2/birthday-app/requirements.txt new file mode 100644 index 0000000..98270a0 --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0 +pyotp>=2.9 +qrcode[pil]>=7.4 +APScheduler>=3.10 +werkzeug>=3.0 diff --git a/OLD_VERSIONS/v1.2/birthday-app/static/style.css b/OLD_VERSIONS/v1.2/birthday-app/static/style.css new file mode 100644 index 0000000..32b7fd3 --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/static/style.css @@ -0,0 +1,258 @@ +/* Birthday Manager — © 2026 Martinhal IT */ +:root { + --pine: #123f3a; + --pine-deep: #0c2b28; + --paper: #f6f4ee; + --card: #ffffff; + --ink: #21302d; + --muted: #6b7a76; + --line: #e2ddd1; + --marigold: #e8a13a; + --marigold-deep: #c07f1c; + --danger: #b3402a; + --ok: #2e7d4f; + font-size: 16px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--paper); + color: var(--ink); + font-family: "Inter", "Segoe UI", system-ui, sans-serif; +} + +h1, h2, h3, .brand-name, .cal-year { + font-family: "Sora", "Segoe UI", system-ui, sans-serif; + letter-spacing: -0.01em; +} +h1 { font-size: 1.6rem; margin: 0.2rem 0; } +h2 { font-size: 1.15rem; margin-top: 0; } +h3 { font-size: 0.9rem; margin: 0 0 0.4rem; } + +/* ---------- top bar ---------- */ +.topbar { + display: flex; + align-items: center; + gap: 2rem; + padding: 0.7rem 1.4rem; + background: var(--pine); + color: #f2efe6; +} +.brand { display: flex; align-items: center; gap: 0.55rem; font-weight: 700; } +.brand-mark { font-size: 1.3rem; } +.nav { display: flex; gap: 0.4rem; flex: 1; } +.nav a { + color: #cfe0d8; + text-decoration: none; + padding: 0.45rem 0.9rem; + border-radius: 8px; + font-weight: 500; +} +.nav a:hover { background: rgba(255,255,255,0.08); color: #fff; } +.nav a.active { background: var(--marigold); color: var(--pine-deep); font-weight: 600; } +.userbox { display: flex; align-items: center; gap: 0.7rem; } +.userchip { + background: rgba(255,255,255,0.12); + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.85rem; +} + +/* ---------- layout ---------- */ +.page { + flex: 1; + width: min(1180px, 100%); + margin: 0 auto; + padding: 1.4rem 1.2rem 2.5rem; +} +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 1.2rem 1.4rem; + margin-bottom: 1.3rem; + box-shadow: 0 1px 2px rgba(18, 63, 58, 0.05); +} +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1.3rem; } +.two-col .card { margin-bottom: 1.3rem; } +@media (max-width: 860px) { .two-col { grid-template-columns: 1fr; } } + +.footer { + text-align: center; + padding: 1rem; + background: var(--pine-deep); + color: #cfe0d8; + font-size: 0.85rem; +} + +/* ---------- yesterday / today / tomorrow banner ---------- */ +.tri-banner { + display: grid; + grid-template-columns: 1fr 1.6fr 1fr; + gap: 1.3rem; + align-items: stretch; +} +.day-panel { margin-bottom: 1.3rem; } +.day-panel-today { + border-left: 6px solid var(--marigold); + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; +} +@media (max-width: 980px) { .tri-banner { grid-template-columns: 1fr; } } + +/* ---------- image copy command ---------- */ +.copy-row { display: flex; gap: 0.35rem; width: 100%; } +.copy-input { + flex: 1; + min-width: 0; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 0.72rem; + padding: 0.35rem 0.45rem; + background: #f0ede4; +} + +/* ---------- today banner ---------- */ +.today-banner { + display: flex; + gap: 1.5rem; + align-items: center; + justify-content: space-between; + border-left: 6px solid var(--marigold); +} +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + color: var(--muted); + margin: 0; +} +.today-people { list-style: none; margin: 0.4rem 0 0; padding: 0; } +.today-people li { margin: 0.25rem 0; } +.today-right { text-align: right; flex-shrink: 0; max-width: 220px; } + +/* ---------- forms ---------- */ +label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; font-weight: 500; } +input, select, textarea { + font: inherit; + padding: 0.5rem 0.65rem; + border: 1px solid var(--line); + border-radius: 8px; + background: #fdfcf9; +} +input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible { + outline: 2px solid var(--marigold); + outline-offset: 1px; +} +textarea { resize: vertical; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 0.85rem; } +.entry-form { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + align-items: flex-end; + margin-bottom: 1rem; +} +.entry-form label { flex: 1 1 150px; } +.stack-form { display: flex; flex-direction: column; gap: 0.8rem; align-items: flex-start; } +.stack-form label { width: 100%; } +.row-gap { display: flex; gap: 0.7rem; margin-top: 0.8rem; } + +/* ---------- buttons ---------- */ +.btn { + font: inherit; + font-weight: 600; + border: none; + border-radius: 9px; + padding: 0.55rem 1.1rem; + cursor: pointer; + text-decoration: none; + display: inline-block; +} +.btn-primary { background: var(--pine); color: #fff; } +.btn-primary:hover { background: var(--pine-deep); } +.btn-accent { background: var(--marigold); color: var(--pine-deep); } +.btn-accent:hover { background: var(--marigold-deep); color: #fff; } +.btn-danger { background: #fbeae5; color: var(--danger); } +.btn-danger:hover { background: var(--danger); color: #fff; } +.btn-ghost { background: transparent; color: inherit; border: 1px solid currentColor; opacity: 0.85; } +.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.8rem; } +.btn-lg { padding: 0.8rem 1.6rem; font-size: 1.05rem; } +.btn-block { width: 100%; } +.btn[disabled] { opacity: 0.45; cursor: not-allowed; } + +/* ---------- tables ---------- */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } +th { text-align: left; color: var(--muted); font-weight: 600; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; } +th, td { padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--line); } +tbody tr:hover { background: #faf8f2; } +.ta-right { text-align: right; } +.nowrap { white-space: nowrap; } +.log-wrap { max-height: 420px; overflow-y: auto; } + +/* ---------- pills / flash ---------- */ +.pill { border-radius: 999px; padding: 0.15rem 0.6rem; font-size: 0.72rem; font-weight: 600; } +.pill-ok { background: #e2f2e8; color: var(--ok); } +.pill-bad { background: #fbeae5; color: var(--danger); } +.pill-wait { background: #fdf1dc; color: var(--marigold-deep); } +.flash { padding: 0.7rem 1rem; border-radius: 10px; margin-bottom: 1rem; font-size: 0.9rem; } +.flash-ok { background: #e2f2e8; color: var(--ok); } +.flash-error { background: #fbeae5; color: var(--danger); } + +/* ---------- yearly calendar ---------- */ +.cal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.8rem; } +.cal-nav { display: flex; align-items: center; gap: 0.7rem; } +.cal-year { font-size: 1.2rem; font-weight: 700; } +.year-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); + gap: 1rem; +} +.mini-cal { font-size: 0.72rem; } +.mini-cal th, .mini-cal td { padding: 0.22rem; text-align: center; border: none; } +.mini-cal td { position: relative; border-radius: 6px; } +.mini-cal td.bday { background: #fdf1dc; font-weight: 700; color: var(--marigold-deep); cursor: help; } +.mini-cal td.today { outline: 2px solid var(--pine); } +.mini-cal .dot { + position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); + width: 4px; height: 4px; border-radius: 50%; background: var(--marigold-deep); +} + +/* ---------- images ---------- */ +.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-top: 1rem; } +.image-tile { + border: 1px solid var(--line); border-radius: 10px; padding: 0.6rem; + display: flex; flex-direction: column; gap: 0.5rem; align-items: center; text-align: center; +} +.image-tile img { max-width: 100%; max-height: 110px; object-fit: contain; border-radius: 6px; } +.image-tile code { word-break: break-all; } + +/* ---------- auth / misc ---------- */ +.auth-card { + background: var(--card); border: 1px solid var(--line); border-radius: 14px; + max-width: 380px; margin: 8vh auto 0; padding: 2rem; + display: flex; flex-direction: column; gap: 0.4rem; +} +.auth-card.wide { max-width: 480px; margin-top: 3vh; } +.auth-card form { display: flex; flex-direction: column; gap: 0.9rem; margin-top: 0.8rem; } +.qr-wrap { text-align: center; margin: 0.5rem 0; } +.qr-wrap img { width: 190px; height: 190px; } +.muted { color: var(--muted); } +.small { font-size: 0.8rem; } +code { background: #f0ede4; padding: 0.1rem 0.35rem; border-radius: 5px; font-size: 0.82em; } +.tpl-zone textarea { width: 100%; } + +@media (prefers-reduced-motion: no-preference) { + .btn { transition: background 0.15s ease, color 0.15s ease; } +} +@media (max-width: 700px) { + .topbar { flex-wrap: wrap; gap: 0.6rem; } + .today-banner { flex-direction: column; align-items: flex-start; } + .today-right { text-align: left; } +} diff --git a/OLD_VERSIONS/v1.2/birthday-app/templates/admin.html b/OLD_VERSIONS/v1.2/birthday-app/templates/admin.html new file mode 100644 index 0000000..903d0a9 --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/templates/admin.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} +{% set active = 'admin' %} +{% block title %}Administration - Birthday Manager{% endblock %} +{% block content %} + + +
+

Users

+
+ + + +
+
+ + + + {% for u in users %} + + + + + + + {% endfor %} + +
UsernameMFACreated
{{ u.username }}{% if u.id == user_id %} you{% endif %}{{ 'Enabled' if u.mfa_enabled else 'Off' }}{{ u.created_at }} + {% if u.id != user_id %} +
+ +
+ {% endif %} +
+
+
+ + +
+
+

Change my password

+
+ + + + +
+
+ +
+

Two-factor authentication (MFA)

+ {% set me = users | selectattr('id', 'equalto', user_id) | first %} + {% if me and me.mfa_enabled %} +

MFA is enabled for your account. You'll be asked for a 6-digit code every time you sign in.

+
+ +
+ {% else %} +

Protect your account with a one-time code from an authenticator app (Google Authenticator, Authy, Microsoft Authenticator…).

+ Enable MFA + {% endif %} +
+
+ + +
+

Automatic e-mail send time

+

The birthday e-mails are sent automatically every day at this time.

+
+ + +
+
+ + +
+

E-mail log

+ {% if log %} +
+ + + + {% for l in log %} + + + + + + + + {% endfor %} + +
Date & timeRecipientE-mail addressSubjectStatus
{{ l.sent_at }}{{ l.recipient_name }}{{ l.recipient_email }}{{ l.subject }} + {{ l.status }} +
+
+ {% else %} +

No e-mails have been sent yet.

+ {% endif %} +
+ + +
+

Images

+

Upload one or more images (15 MB total per upload). Each image shows the ready-to-use + <img> command — copy it into a template and the image is embedded inline in the e-mail when it is sent.

+
+ + +
+ {% if images %} +
+ {% for img in images %} +
+ {{ img }} +
+ + +
+
+ +
+
+ {% endfor %} +
+ {% endif %} +
+ + +
+
+

E-mail Template Male

+
+ + + +

Placeholders: {first_name} {last_name} {age} — insert images with <img src="/uploads/…">

+ +
+
+ +
+

E-Mail Template Female

+
+ + + +

Placeholders: {first_name} {last_name} {age} — insert images with <img src="/uploads/…">

+ +
+
+
+ +{% endblock %} diff --git a/OLD_VERSIONS/v1.2/birthday-app/templates/base.html b/OLD_VERSIONS/v1.2/birthday-app/templates/base.html new file mode 100644 index 0000000..522cb7c --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/templates/base.html @@ -0,0 +1,40 @@ + + + + + + {% block title %}Birthday Manager{% endblock %} + + + + + +
+
+ 🎂 + Birthday Manager +
+ {% if username %} + +
+ {{ username }} + Log out +
+ {% endif %} +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
{{ footer }}
+ + diff --git a/OLD_VERSIONS/v1.2/birthday-app/templates/dashboard.html b/OLD_VERSIONS/v1.2/birthday-app/templates/dashboard.html new file mode 100644 index 0000000..710cea8 --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/templates/dashboard.html @@ -0,0 +1,147 @@ +{% extends "base.html" %} +{% set active = 'dashboard' %} +{% block title %}Dashboard - Birthday Manager{% endblock %} +{% block content %} + + +
+
+

Yesterday

+

Yesterday's Birthdays

+ {% if yesterday_list %} +
    + {% for item in yesterday_list %} +
  • {{ item.row.first_name }} {{ item.row.last_name }} turned {{ item.age }}
  • + {% endfor %} +
+ {% else %} +

No birthdays yesterday.

+ {% endif %} +
+ +
+
+

Today, {{ today.strftime('%A %d %B %Y') }}

+

Today's Birthdays

+ {% if today_list %} +
    + {% for item in today_list %} +
  • + 🎉 {{ item.row.first_name }} {{ item.row.last_name }} + turns {{ item.age }} + + {{ 'e-mail sent' if item.sent else 'e-mail pending' }} + +
  • + {% endfor %} +
+ {% else %} +

No birthdays today.

+ {% endif %} +
+
+ +

Sends today's birthday e-mail{{ 's' if today_list|length != 1 }} again immediately.

+
+
+ +
+

Tomorrow

+

Tomorrow's Birthdays

+ {% if tomorrow_list %} +
    + {% for item in tomorrow_list %} +
  • {{ item.row.first_name }} {{ item.row.last_name }} turns {{ item.age }}
  • + {% endfor %} +
+ {% else %} +

No birthdays tomorrow.

+ {% endif %} +
+
+ + +
+

Birthday entries

+
+ + + + + + +
+ + {% if entries %} +
+ + + + + + {% for e in entries %} + + + + + + + + + {% endfor %} + +
First NameLast NameDate of BirthGenderE-mail
{{ e.first_name }}{{ e.last_name }}{{ e.dob }}{{ e.gender }}{{ e.email }} +
+ +
+
+
+ {% else %} +

No entries yet. Add the first birthday above.

+ {% endif %} +
+ + +
+
+

Yearly overview

+ +
+
+ {% for month in months %} +
+

{{ month.name }}

+ + + + {% for week in month.weeks %} + + {% for cell in week %} + + {% endfor %} + + {% endfor %} + +
MoTuWeThFrSaSu
+ {{ cell.day }} + {% if cell.names %}{% endif %} +
+
+ {% endfor %} +
+

Days marked with a dot have a birthday — hover to see who. Today is outlined.

+
+ +{% endblock %} diff --git a/OLD_VERSIONS/v1.2/birthday-app/templates/login.html b/OLD_VERSIONS/v1.2/birthday-app/templates/login.html new file mode 100644 index 0000000..9a8afa3 --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Sign in - Birthday Manager{% endblock %} +{% block content %} +
+

Sign in

+

Birthday Manager

+
+ + + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v1.2/birthday-app/templates/mfa_setup.html b/OLD_VERSIONS/v1.2/birthday-app/templates/mfa_setup.html new file mode 100644 index 0000000..9c3e3f0 --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/templates/mfa_setup.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% set active = 'admin' %} +{% block title %}Enable two-factor authentication{% endblock %} +{% block content %} +
+

Enable two-factor authentication

+

Scan this QR code with Google Authenticator, Microsoft Authenticator, Authy or any TOTP app, then enter the 6-digit code it shows to confirm.

+
+ MFA QR code +

Can't scan? Enter this key manually: {{ secret }}

+
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/OLD_VERSIONS/v1.2/birthday-app/templates/mfa_verify.html b/OLD_VERSIONS/v1.2/birthday-app/templates/mfa_verify.html new file mode 100644 index 0000000..d0cdae8 --- /dev/null +++ b/OLD_VERSIONS/v1.2/birthday-app/templates/mfa_verify.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Two-factor authentication{% endblock %} +{% block content %} +
+

Two-factor code

+

Enter the 6-digit code from your authenticator app.

+
+ + +
+
+{% endblock %} diff --git a/README.md b/README.md new file mode 100644 index 0000000..631eccf --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Birthday Manager + +Self-hosted birthday reminder application that automatically e-mails people on their birthday. + +© 2026 Martinhal IT - Joao Vaz - Version 1.2 + +## Features + +**Page 1 — Dashboard** +- Three panels at the top: **Yesterday's Birthdays**, **Today's Birthdays** (with the **Force Send** button to resend the e-mail) and **Tomorrow's Birthdays** +- Add / remove birthday entries: First Name, Last Name, Date of Birth, Gender, E-mail address +- Yearly calendar overview of all entries (hover a marked day to see the names; browse other years) + +**Page 2 — Administration** +- Add / remove application users +- Every user can enable/disable MFA (TOTP — Google Authenticator, Authy, etc.) and change their own password +- Full e-mail log: who received mail, at what date & time, and whether it succeeded +- Setting for the time of day the automatic birthday e-mails are sent (applies immediately, no restart needed) +- Two template zones: **E-mail Template Male** and **E-Mail Template Female**, with placeholders `{first_name}`, `{last_name}`, `{age}` +- Image upload — select multiple files at once (15 MB total per upload); each image shows its ready-to-copy `` command, and images are embedded inline in the outgoing e-mail + +Automatic sending: a scheduler runs every day at `SEND_HOUR` (default 08:00) and e-mails everyone whose birthday it is, using the template matching their gender. Duplicates are avoided (one automatic mail per person per day); **Force Send** always resends. February 29 birthdays are celebrated on February 28 in non-leap years. + +Every successfully sent e-mail is also copied to the mailbox's **Sent** folder via IMAP, so it shows up in your normal mail client. + +## Mail provider + +The app comes pre-configured for **Purelymail**: + +| | Server | Port | Security | +|---|---|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com` | `465` | SSL/TLS | +| Incoming (IMAP, Sent-folder copy) | `imap.purelymail.com` | `993` | SSL/TLS | + +You only need to fill in `SMTP_USER`, `SMTP_PASS` and `SMTP_FROM` with your Purelymail mailbox credentials. If your network only supports STARTTLS for SMTP, set `SMTP_PORT=587` and `SMTP_SECURITY=starttls` instead. Any other provider works too — just override the host/port variables. + +## Quick start (Docker — recommended) + +1. Edit `docker-compose.yml` and fill in your Purelymail credentials (`SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`) plus a random `SECRET_KEY` and your timezone. +2. Run: + +```bash +docker compose up -d --build +``` + +3. Open http://localhost:8080 and log in with **admin / admin** — then change the password on the Administration page immediately. + +All data (SQLite database + uploaded images) lives in `./data`, so back up that folder. + +## Quick start (plain Python) + +```bash +pip install -r requirements.txt + +# Purelymail servers are the built-in defaults - only credentials are needed +export SMTP_USER=you@yourdomain.com +export SMTP_PASS=your-purelymail-password +export SMTP_FROM=you@yourdomain.com +export SEND_HOUR=8 +export SECRET_KEY=$(python -c "import secrets;print(secrets.token_hex(32))") + +python app.py +``` + +The app listens on port 8080 (`PORT` env var to change). Data is stored in `./data` (`DATA_DIR` env var to change). + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `SMTP_HOST` | `smtp.purelymail.com` | SMTP server | +| `SMTP_PORT` | `465` | SMTP port | +| `SMTP_USER` | – | SMTP username / mailbox | +| `SMTP_PASS` | – | SMTP password | +| `SMTP_FROM` | `SMTP_USER` | From address | +| `SMTP_SECURITY` | `ssl` | `ssl`, `starttls` or `none` | +| `IMAP_HOST` | `imap.purelymail.com` | IMAP server for the Sent-folder copy | +| `IMAP_PORT` | `993` | IMAP port (SSL/TLS) | +| `IMAP_USER` | `SMTP_USER` | IMAP username | +| `IMAP_PASS` | `SMTP_PASS` | IMAP password | +| `IMAP_SENT_FOLDER` | `Sent` | Mailbox folder sent mail is stored in | +| `SEND_HOUR` | `8` | Default hour for the daily send — the time picked on the Administration page overrides this | +| `SECRET_KEY` | random | Flask session key — set it so logins survive restarts | +| `PORT` | `8080` | HTTP port | +| `DATA_DIR` | `./data` | Where the database and uploads are stored | + +## Notes + +- The e-mail address field on entries is required because it is where the birthday greeting is sent. +- Failed sends (e.g. SMTP misconfigured) are also recorded in the e-mail log with the error as tooltip on the "Failed" status. If a mail is delivered but the IMAP copy to the Sent folder fails, the mail is still logged as Sent and the IMAP error appears as a tooltip. +- Run behind a reverse proxy (nginx / Caddy / Traefik) with HTTPS if you expose it beyond your LAN. diff --git a/app.py b/app.py new file mode 100644 index 0000000..314d7c8 --- /dev/null +++ b/app.py @@ -0,0 +1,724 @@ +""" +Birthday Manager - self-hosted birthday reminder & e-mail application +(c) 2026 Martinhal IT - Joao Vaz - Version 1.2 +""" +import base64 +import calendar as cal +import imaplib +import io +import os +import re +import secrets +import smtplib +import sqlite3 +import uuid +from datetime import date, datetime +from email.mime.image import MIMEImage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from functools import wraps + +import pyotp +import qrcode +from apscheduler.schedulers.background import BackgroundScheduler +from flask import (Flask, flash, g, redirect, render_template, request, + send_from_directory, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(BASE_DIR, "data")) +UPLOAD_DIR = os.path.join(DATA_DIR, "uploads") +DB_PATH = os.path.join(DATA_DIR, "birthday.db") +ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp"} + +os.makedirs(UPLOAD_DIR, exist_ok=True) + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32)) +app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024 # 15 MB total upload limit + +APP_VERSION = "Version 1.2" +FOOTER_TEXT = "\u00a9 2026 Martinhal IT - Joao Vaz - " + APP_VERSION + +# ---------------------------------------------------------------- database + +def get_db(): + if "db" not in g: + g.db = sqlite3.connect(DB_PATH) + g.db.row_factory = sqlite3.Row + g.db.execute("PRAGMA foreign_keys = ON") + return g.db + + +@app.teardown_appcontext +def close_db(_exc): + db = g.pop("db", None) + if db is not None: + db.close() + + +def db_connect(): + """Standalone connection for use outside a request (scheduler).""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +DEFAULT_TEMPLATE_MALE = ( + "

Dear {first_name} {last_name},

" + "

Happy Birthday! Wishing you a fantastic day and a wonderful year ahead. " + "Congratulations on turning {age}!

" + "

Best regards,
Martinhal IT

" +) +DEFAULT_TEMPLATE_FEMALE = ( + "

Dear {first_name} {last_name},

" + "

Happy Birthday! Wishing you a beautiful day filled with joy. " + "Congratulations on turning {age}!

" + "

Warm regards,
Martinhal IT

" +) + + +def init_db(): + conn = db_connect() + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + mfa_secret TEXT, + mfa_enabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS birthdays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + dob TEXT NOT NULL, -- YYYY-MM-DD + gender TEXT NOT NULL, -- Male / Female + email TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS email_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_name TEXT NOT NULL, + recipient_email TEXT NOT NULL, + subject TEXT NOT NULL, + sent_at TEXT NOT NULL, -- YYYY-MM-DD HH:MM:SS + status TEXT NOT NULL, -- Sent / Failed + detail TEXT + ); + CREATE TABLE IF NOT EXISTS templates ( + gender TEXT PRIMARY KEY, -- Male / Female + subject TEXT NOT NULL, + body TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """ + ) + # Seed default admin user + if conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + conn.execute( + "INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)", + ("admin", generate_password_hash("admin"), + datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + print(" * Created default user 'admin' with password 'admin' - change it!") + # Seed default templates + for gender, body in (("Male", DEFAULT_TEMPLATE_MALE), + ("Female", DEFAULT_TEMPLATE_FEMALE)): + conn.execute( + "INSERT OR IGNORE INTO templates (gender, subject, body) VALUES (?,?,?)", + (gender, "Happy Birthday, {first_name}!", body), + ) + conn.commit() + conn.close() + + +def get_setting(conn, key, default=None): + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else default + + +def set_setting(conn, key, value): + conn.execute( + "INSERT INTO settings (key, value) VALUES (?,?)" + " ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value)) + conn.commit() + + +def get_send_time(conn) -> tuple[int, int]: + default = f'{int(os.environ.get("SEND_HOUR", "8")):02d}:00' + raw = get_setting(conn, "send_time", default) + try: + h, m = raw.split(":") + return max(0, min(23, int(h))), max(0, min(59, int(m))) + except ValueError: + return 8, 0 + + +# ---------------------------------------------------------------- auth + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("user_id"): + return redirect(url_for("login", next=request.path)) + return view(*args, **kwargs) + return wrapped + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + user = get_db().execute( + "SELECT * FROM users WHERE username = ?", (username,) + ).fetchone() + if user and check_password_hash(user["password_hash"], password): + if user["mfa_enabled"]: + session.clear() + session["mfa_pending_user"] = user["id"] + return redirect(url_for("mfa_verify")) + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + return redirect(url_for("dashboard")) + flash("Invalid username or password.", "error") + return render_template("login.html", footer=FOOTER_TEXT) + + +@app.route("/mfa-verify", methods=["GET", "POST"]) +def mfa_verify(): + pending = session.get("mfa_pending_user") + if not pending: + return redirect(url_for("login")) + if request.method == "POST": + code = request.form.get("code", "").strip().replace(" ", "") + user = get_db().execute("SELECT * FROM users WHERE id = ?", (pending,)).fetchone() + if user and user["mfa_secret"] and pyotp.TOTP(user["mfa_secret"]).verify(code, valid_window=1): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + return redirect(url_for("dashboard")) + flash("Invalid authentication code.", "error") + return render_template("mfa_verify.html", footer=FOOTER_TEXT) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- helpers + +def calc_age(dob: date, on: date) -> int: + years = on.year - dob.year + if (on.month, on.day) < (dob.month, dob.day): + years -= 1 + return years + + +def birthday_matches_day(dob: date, day: date) -> bool: + """Feb 29 birthdays are celebrated on Feb 28 in non-leap years.""" + if dob.month == 2 and dob.day == 29 and not cal.isleap(day.year): + return day.month == 2 and day.day == 28 + return dob.month == day.month and dob.day == day.day + + +def birthdays_on(conn, day: date): + rows = conn.execute("SELECT * FROM birthdays").fetchall() + result = [] + for r in rows: + dob = datetime.strptime(r["dob"], "%Y-%m-%d").date() + if birthday_matches_day(dob, day): + result.append((r, calc_age(dob, day))) + return result + + +def todays_birthdays(conn): + return birthdays_on(conn, date.today()) + + +def render_placeholders(text: str, row, age: int) -> str: + return (text.replace("{first_name}", row["first_name"]) + .replace("{last_name}", row["last_name"]) + .replace("{age}", str(age))) + + +def smtp_settings(): + # Defaults are pre-configured for Purelymail (smtp.purelymail.com:465 SSL/TLS). + # If your client only supports STARTTLS, set SMTP_PORT=587 and SMTP_SECURITY=starttls. + return { + "host": os.environ.get("SMTP_HOST", "smtp.purelymail.com"), + "port": int(os.environ.get("SMTP_PORT", "465")), + "user": os.environ.get("SMTP_USER", ""), + "password": os.environ.get("SMTP_PASS", ""), + "sender": os.environ.get("SMTP_FROM", os.environ.get("SMTP_USER", "birthday@localhost")), + "security": os.environ.get("SMTP_SECURITY", "ssl").lower(), # ssl / starttls / none + } + + +def imap_settings(): + # Defaults are pre-configured for Purelymail (imap.purelymail.com:993 SSL/TLS). + # IMAP credentials fall back to the SMTP ones if not set separately. + return { + "host": os.environ.get("IMAP_HOST", "imap.purelymail.com"), + "port": int(os.environ.get("IMAP_PORT", "993")), + "user": os.environ.get("IMAP_USER", os.environ.get("SMTP_USER", "")), + "password": os.environ.get("IMAP_PASS", os.environ.get("SMTP_PASS", "")), + "folder": os.environ.get("IMAP_SENT_FOLDER", "Sent"), + } + + +def save_to_sent_folder(msg) -> str: + """Append the sent message to the mailbox's Sent folder via IMAP. + Returns '' on success or an error description on failure.""" + cfg = imap_settings() + if not cfg["user"]: + return "IMAP not configured - message not copied to Sent folder" + try: + imap = imaplib.IMAP4_SSL(cfg["host"], cfg["port"], timeout=20) + imap.login(cfg["user"], cfg["password"]) + folder = cfg["folder"] + # Create the folder if the server doesn't have it yet + status, _ = imap.select(folder) + if status != "OK": + imap.create(folder) + imap.append(folder, r"(\Seen)", imaplib.Time2Internaldate(datetime.now().timestamp()), + msg.as_bytes()) + imap.logout() + return "" + except Exception as exc: + return f"Delivered, but copy to Sent folder failed: {exc}" + + +IMG_SRC_RE = re.compile(r'src="/uploads/([^"]+)"') + + +def build_email(row, age: int, conn) -> MIMEMultipart: + tpl = conn.execute( + "SELECT * FROM templates WHERE gender = ?", (row["gender"],) + ).fetchone() + subject = render_placeholders(tpl["subject"], row, age) + body_html = render_placeholders(tpl["body"], row, age) + + msg = MIMEMultipart("related") + msg["Subject"] = subject + msg["From"] = smtp_settings()["sender"] + msg["To"] = row["email"] + + # Embed uploaded images referenced in the template as inline attachments + inline = [] + def to_cid(match): + fname = match.group(1) + path = os.path.join(UPLOAD_DIR, secure_filename(fname)) + if os.path.isfile(path): + cid = uuid.uuid4().hex + inline.append((path, cid)) + return f'src="cid:{cid}"' + return match.group(0) + + body_html = IMG_SRC_RE.sub(to_cid, body_html) + + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(re.sub(r"<[^>]+>", " ", body_html), "plain", "utf-8")) + alt.attach(MIMEText(body_html, "html", "utf-8")) + msg.attach(alt) + + for path, cid in inline: + with open(path, "rb") as f: + img = MIMEImage(f.read()) + img.add_header("Content-ID", f"<{cid}>") + img.add_header("Content-Disposition", "inline", filename=os.path.basename(path)) + msg.attach(img) + return msg + + +def send_birthday_email(row, age: int, conn) -> None: + cfg = smtp_settings() + msg = build_email(row, age, conn) + status, detail = "Sent", "" + try: + if not cfg["host"]: + raise RuntimeError("SMTP is not configured (set SMTP_HOST etc.)") + if cfg["security"] == "ssl": + server = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=20) + else: + server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) + if cfg["security"] == "starttls": + server.starttls() + if cfg["user"]: + server.login(cfg["user"], cfg["password"]) + server.send_message(msg) + server.quit() + detail = save_to_sent_folder(msg) # keep a copy in the mailbox Sent folder + except Exception as exc: # log failures too + status, detail = "Failed", str(exc) + conn.execute( + "INSERT INTO email_log (recipient_name, recipient_email, subject, sent_at, status, detail)" + " VALUES (?,?,?,?,?,?)", + (f'{row["first_name"]} {row["last_name"]}', row["email"], msg["Subject"], + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), status, detail), + ) + conn.commit() + + +def already_sent_today(conn, row) -> bool: + today = date.today().strftime("%Y-%m-%d") + hit = conn.execute( + "SELECT 1 FROM email_log WHERE recipient_email = ? AND status = 'Sent'" + " AND sent_at LIKE ?", (row["email"], today + "%"), + ).fetchone() + return hit is not None + + +def daily_birthday_job(): + conn = db_connect() + try: + for row, age in todays_birthdays(conn): + if not already_sent_today(conn, row): + send_birthday_email(row, age, conn) + finally: + conn.close() + + +# ---------------------------------------------------------------- page 1 + +MONTH_NAMES = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + + +def build_year_calendar(conn, year: int): + """Return list of month dicts with week grids; birthday days carry names.""" + rows = conn.execute("SELECT * FROM birthdays").fetchall() + marks = {} + for r in rows: + dob = datetime.strptime(r["dob"], "%Y-%m-%d").date() + m, d = dob.month, dob.day + if m == 2 and d == 29 and not cal.isleap(year): + d = 28 + marks.setdefault((m, d), []).append(f'{r["first_name"]} {r["last_name"]}') + months = [] + c = cal.Calendar(firstweekday=0) # Monday + today = date.today() + for m in range(1, 13): + weeks = [] + for week in c.monthdayscalendar(year, m): + weeks.append([ + { + "day": d or "", + "names": marks.get((m, d), []) if d else [], + "today": bool(d) and today.year == year and today.month == m and today.day == d, + } for d in week + ]) + months.append({"name": MONTH_NAMES[m - 1], "weeks": weeks}) + return months + + +@app.route("/", methods=["GET"]) +@login_required +def dashboard(): + conn = get_db() + year = request.args.get("year", type=int) or date.today().year + entries = conn.execute( + "SELECT * FROM birthdays ORDER BY substr(dob,6) ASC" + ).fetchall() + from datetime import timedelta + today = date.today() + today_list = [ + {"row": r, "age": a, "sent": already_sent_today(conn, r)} + for r, a in todays_birthdays(conn) + ] + yesterday_list = [{"row": r, "age": a} for r, a in birthdays_on(conn, today - timedelta(days=1))] + tomorrow_list = [{"row": r, "age": a} for r, a in birthdays_on(conn, today + timedelta(days=1))] + return render_template( + "dashboard.html", + footer=FOOTER_TEXT, + username=session.get("username"), + entries=entries, + today_list=today_list, + yesterday_list=yesterday_list, + tomorrow_list=tomorrow_list, + today=today, + months=build_year_calendar(conn, year), + year=year, + ) + + +@app.route("/birthdays/add", methods=["POST"]) +@login_required +def add_birthday(): + f = request.form + first = f.get("first_name", "").strip() + last = f.get("last_name", "").strip() + dob = f.get("dob", "").strip() + gender = f.get("gender", "").strip() + email = f.get("email", "").strip() + try: + datetime.strptime(dob, "%Y-%m-%d") + assert first and last and email and gender in ("Male", "Female") + except (ValueError, AssertionError): + flash("Please fill in all fields with valid values.", "error") + return redirect(url_for("dashboard")) + get_db().execute( + "INSERT INTO birthdays (first_name, last_name, dob, gender, email) VALUES (?,?,?,?,?)", + (first, last, dob, gender, email), + ) + get_db().commit() + flash(f"Added {first} {last}.", "ok") + return redirect(url_for("dashboard")) + + +@app.route("/birthdays//delete", methods=["POST"]) +@login_required +def delete_birthday(bid): + get_db().execute("DELETE FROM birthdays WHERE id = ?", (bid,)) + get_db().commit() + flash("Entry removed.", "ok") + return redirect(url_for("dashboard")) + + +@app.route("/force-send", methods=["POST"]) +@login_required +def force_send(): + conn = get_db() + todays = todays_birthdays(conn) + if not todays: + flash("Nobody has a birthday today - nothing to send.", "error") + else: + for row, age in todays: + send_birthday_email(row, age, conn) + flash(f"Birthday e-mail resent to {len(todays)} recipient(s). Check the log on the Administration page.", "ok") + return redirect(url_for("dashboard")) + + +# ---------------------------------------------------------------- page 2 + +@app.route("/admin") +@login_required +def admin(): + conn = get_db() + users = conn.execute("SELECT id, username, mfa_enabled, created_at FROM users ORDER BY username").fetchall() + log = conn.execute("SELECT * FROM email_log ORDER BY sent_at DESC, id DESC LIMIT 500").fetchall() + tpl_male = conn.execute("SELECT * FROM templates WHERE gender='Male'").fetchone() + tpl_female = conn.execute("SELECT * FROM templates WHERE gender='Female'").fetchone() + images = sorted(os.listdir(UPLOAD_DIR)) + h, m = get_send_time(conn) + return render_template( + "admin.html", + send_time=f"{h:02d}:{m:02d}", + footer=FOOTER_TEXT, + username=session.get("username"), + user_id=session.get("user_id"), + users=users, + log=log, + tpl_male=tpl_male, + tpl_female=tpl_female, + images=images, + ) + + +@app.route("/users/add", methods=["POST"]) +@login_required +def add_user(): + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if not username or len(password) < 4: + flash("Username and a password of at least 4 characters are required.", "error") + return redirect(url_for("admin")) + try: + get_db().execute( + "INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)", + (username, generate_password_hash(password), + datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + get_db().commit() + flash(f"User '{username}' created.", "ok") + except sqlite3.IntegrityError: + flash("That username already exists.", "error") + return redirect(url_for("admin")) + + +@app.route("/users//delete", methods=["POST"]) +@login_required +def delete_user(uid): + conn = get_db() + if uid == session.get("user_id"): + flash("You cannot delete the account you are logged in with.", "error") + elif conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] <= 1: + flash("At least one user must remain.", "error") + else: + conn.execute("DELETE FROM users WHERE id = ?", (uid,)) + conn.commit() + flash("User removed.", "ok") + return redirect(url_for("admin")) + + +@app.route("/account/password", methods=["POST"]) +@login_required +def change_password(): + current = request.form.get("current", "") + new = request.form.get("new", "") + confirm = request.form.get("confirm", "") + conn = get_db() + user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone() + if not check_password_hash(user["password_hash"], current): + flash("Current password is incorrect.", "error") + elif len(new) < 4: + flash("New password must be at least 4 characters.", "error") + elif new != confirm: + flash("New passwords do not match.", "error") + else: + conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", + (generate_password_hash(new), user["id"])) + conn.commit() + flash("Password changed.", "ok") + return redirect(url_for("admin")) + + +@app.route("/account/mfa/setup", methods=["GET", "POST"]) +@login_required +def mfa_setup(): + conn = get_db() + user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone() + if request.method == "POST": + code = request.form.get("code", "").strip().replace(" ", "") + secret = session.get("mfa_setup_secret") + if secret and pyotp.TOTP(secret).verify(code, valid_window=1): + conn.execute("UPDATE users SET mfa_secret = ?, mfa_enabled = 1 WHERE id = ?", + (secret, user["id"])) + conn.commit() + session.pop("mfa_setup_secret", None) + flash("Two-factor authentication is now enabled.", "ok") + return redirect(url_for("admin")) + flash("That code did not match. Scan the QR code and try again.", "error") + secret = session.get("mfa_setup_secret") or pyotp.random_base32() + session["mfa_setup_secret"] = secret + uri = pyotp.TOTP(secret).provisioning_uri( + name=user["username"], issuer_name="Birthday Manager") + buf = io.BytesIO() + qrcode.make(uri).save(buf, format="PNG") + qr_b64 = base64.b64encode(buf.getvalue()).decode() + return render_template("mfa_setup.html", footer=FOOTER_TEXT, + username=session.get("username"), + qr=qr_b64, secret=secret) + + +@app.route("/account/mfa/disable", methods=["POST"]) +@login_required +def mfa_disable(): + conn = get_db() + conn.execute("UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?", + (session["user_id"],)) + conn.commit() + flash("Two-factor authentication disabled for your account.", "ok") + return redirect(url_for("admin")) + + +@app.route("/settings/send-time", methods=["POST"]) +@login_required +def save_send_time(): + raw = request.form.get("send_time", "").strip() + try: + h, m = raw.split(":") + h, m = int(h), int(m) + assert 0 <= h <= 23 and 0 <= m <= 59 + except (ValueError, AssertionError): + flash("Please pick a valid time.", "error") + return redirect(url_for("admin")) + set_setting(get_db(), "send_time", f"{h:02d}:{m:02d}") + scheduler.reschedule_job("daily-birthday-mail", trigger="cron", hour=h, minute=m) + flash(f"Automatic birthday e-mails will now be sent daily at {h:02d}:{m:02d}.", "ok") + return redirect(url_for("admin")) + + +@app.route("/templates/save", methods=["POST"]) +@login_required +def save_template(): + gender = request.form.get("gender") + if gender not in ("Male", "Female"): + flash("Unknown template.", "error") + return redirect(url_for("admin")) + subject = request.form.get("subject", "").strip() or "Happy Birthday, {first_name}!" + body = request.form.get("body", "").strip() + get_db().execute("UPDATE templates SET subject = ?, body = ? WHERE gender = ?", + (subject, body, gender)) + get_db().commit() + flash(f"E-mail template ({gender}) saved.", "ok") + return redirect(url_for("admin")) + + +@app.route("/images/upload", methods=["POST"]) +@login_required +def upload_image(): + files = [f for f in request.files.getlist("images") if f and f.filename] + if not files: + flash("Choose one or more image files to upload.", "error") + return redirect(url_for("admin")) + # Enforce the 15 MB total limit across all files in this upload + total = 0 + for f in files: + f.stream.seek(0, os.SEEK_END) + total += f.stream.tell() + f.stream.seek(0) + if total > 15 * 1024 * 1024: + flash(f"Upload rejected: total size {total / (1024*1024):.1f} MB exceeds the 15 MB limit.", "error") + return redirect(url_for("admin")) + saved, skipped = [], [] + for f in files: + name = secure_filename(f.filename) + ext = os.path.splitext(name)[1].lower() + if ext not in ALLOWED_IMAGE_EXT: + skipped.append(f.filename) + continue + unique = f"{os.path.splitext(name)[0]}-{uuid.uuid4().hex[:6]}{ext}" + f.save(os.path.join(UPLOAD_DIR, unique)) + saved.append(unique) + if saved: + flash(f"{len(saved)} image(s) uploaded. Copy the command shown under each image to use it in a template.", "ok") + if skipped: + flash(f"Skipped (not PNG/JPG/GIF/WEBP): {', '.join(skipped)}", "error") + return redirect(url_for("admin")) + + +@app.errorhandler(413) +def too_large(_e): + flash("Upload rejected: the total upload size exceeds the 15 MB limit.", "error") + return redirect(url_for("admin")) + + +@app.route("/images//delete", methods=["POST"]) +@login_required +def delete_image(fname): + path = os.path.join(UPLOAD_DIR, secure_filename(fname)) + if os.path.isfile(path): + os.remove(path) + flash("Image deleted.", "ok") + return redirect(url_for("admin")) + + +@app.route("/uploads/") +@login_required +def serve_upload(fname): + return send_from_directory(UPLOAD_DIR, secure_filename(fname)) + + +# ---------------------------------------------------------------- startup + +init_db() +scheduler = BackgroundScheduler(daemon=True) +_conn = db_connect() +_h, _m = get_send_time(_conn) +_conn.close() +scheduler.add_job(daily_birthday_job, "cron", hour=_h, minute=_m, + id="daily-birthday-mail") +scheduler.start() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")), debug=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4be567d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + birthday-manager: + build: . + container_name: birthday-manager + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./data:/data + environment: + # --- Purelymail account (fill in your mailbox credentials) --- + SMTP_USER: "you@yourdomain.com" + SMTP_PASS: "your-purelymail-password" + SMTP_FROM: "you@yourdomain.com" + + # --- Outgoing mail (pre-configured for Purelymail, SSL/TLS) --- + SMTP_HOST: "smtp.purelymail.com" + SMTP_PORT: "465" + SMTP_SECURITY: "ssl" # if your network only allows STARTTLS: port 587 + "starttls" + + # --- Incoming mail / Sent-folder copy (pre-configured for Purelymail) --- + IMAP_HOST: "imap.purelymail.com" + IMAP_PORT: "993" # SSL/TLS + # IMAP_USER / IMAP_PASS default to the SMTP credentials above + IMAP_SENT_FOLDER: "Sent" + + # --- optional --- + SEND_HOUR: "8" # default only - set the real time on the Administration page + SECRET_KEY: "change-this-to-a-long-random-string" + TZ: "Europe/Lisbon" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..98270a0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0 +pyotp>=2.9 +qrcode[pil]>=7.4 +APScheduler>=3.10 +werkzeug>=3.0 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..32b7fd3 --- /dev/null +++ b/static/style.css @@ -0,0 +1,258 @@ +/* Birthday Manager — © 2026 Martinhal IT */ +:root { + --pine: #123f3a; + --pine-deep: #0c2b28; + --paper: #f6f4ee; + --card: #ffffff; + --ink: #21302d; + --muted: #6b7a76; + --line: #e2ddd1; + --marigold: #e8a13a; + --marigold-deep: #c07f1c; + --danger: #b3402a; + --ok: #2e7d4f; + font-size: 16px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--paper); + color: var(--ink); + font-family: "Inter", "Segoe UI", system-ui, sans-serif; +} + +h1, h2, h3, .brand-name, .cal-year { + font-family: "Sora", "Segoe UI", system-ui, sans-serif; + letter-spacing: -0.01em; +} +h1 { font-size: 1.6rem; margin: 0.2rem 0; } +h2 { font-size: 1.15rem; margin-top: 0; } +h3 { font-size: 0.9rem; margin: 0 0 0.4rem; } + +/* ---------- top bar ---------- */ +.topbar { + display: flex; + align-items: center; + gap: 2rem; + padding: 0.7rem 1.4rem; + background: var(--pine); + color: #f2efe6; +} +.brand { display: flex; align-items: center; gap: 0.55rem; font-weight: 700; } +.brand-mark { font-size: 1.3rem; } +.nav { display: flex; gap: 0.4rem; flex: 1; } +.nav a { + color: #cfe0d8; + text-decoration: none; + padding: 0.45rem 0.9rem; + border-radius: 8px; + font-weight: 500; +} +.nav a:hover { background: rgba(255,255,255,0.08); color: #fff; } +.nav a.active { background: var(--marigold); color: var(--pine-deep); font-weight: 600; } +.userbox { display: flex; align-items: center; gap: 0.7rem; } +.userchip { + background: rgba(255,255,255,0.12); + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.85rem; +} + +/* ---------- layout ---------- */ +.page { + flex: 1; + width: min(1180px, 100%); + margin: 0 auto; + padding: 1.4rem 1.2rem 2.5rem; +} +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 1.2rem 1.4rem; + margin-bottom: 1.3rem; + box-shadow: 0 1px 2px rgba(18, 63, 58, 0.05); +} +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1.3rem; } +.two-col .card { margin-bottom: 1.3rem; } +@media (max-width: 860px) { .two-col { grid-template-columns: 1fr; } } + +.footer { + text-align: center; + padding: 1rem; + background: var(--pine-deep); + color: #cfe0d8; + font-size: 0.85rem; +} + +/* ---------- yesterday / today / tomorrow banner ---------- */ +.tri-banner { + display: grid; + grid-template-columns: 1fr 1.6fr 1fr; + gap: 1.3rem; + align-items: stretch; +} +.day-panel { margin-bottom: 1.3rem; } +.day-panel-today { + border-left: 6px solid var(--marigold); + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; +} +@media (max-width: 980px) { .tri-banner { grid-template-columns: 1fr; } } + +/* ---------- image copy command ---------- */ +.copy-row { display: flex; gap: 0.35rem; width: 100%; } +.copy-input { + flex: 1; + min-width: 0; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 0.72rem; + padding: 0.35rem 0.45rem; + background: #f0ede4; +} + +/* ---------- today banner ---------- */ +.today-banner { + display: flex; + gap: 1.5rem; + align-items: center; + justify-content: space-between; + border-left: 6px solid var(--marigold); +} +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + color: var(--muted); + margin: 0; +} +.today-people { list-style: none; margin: 0.4rem 0 0; padding: 0; } +.today-people li { margin: 0.25rem 0; } +.today-right { text-align: right; flex-shrink: 0; max-width: 220px; } + +/* ---------- forms ---------- */ +label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; font-weight: 500; } +input, select, textarea { + font: inherit; + padding: 0.5rem 0.65rem; + border: 1px solid var(--line); + border-radius: 8px; + background: #fdfcf9; +} +input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible { + outline: 2px solid var(--marigold); + outline-offset: 1px; +} +textarea { resize: vertical; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 0.85rem; } +.entry-form { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + align-items: flex-end; + margin-bottom: 1rem; +} +.entry-form label { flex: 1 1 150px; } +.stack-form { display: flex; flex-direction: column; gap: 0.8rem; align-items: flex-start; } +.stack-form label { width: 100%; } +.row-gap { display: flex; gap: 0.7rem; margin-top: 0.8rem; } + +/* ---------- buttons ---------- */ +.btn { + font: inherit; + font-weight: 600; + border: none; + border-radius: 9px; + padding: 0.55rem 1.1rem; + cursor: pointer; + text-decoration: none; + display: inline-block; +} +.btn-primary { background: var(--pine); color: #fff; } +.btn-primary:hover { background: var(--pine-deep); } +.btn-accent { background: var(--marigold); color: var(--pine-deep); } +.btn-accent:hover { background: var(--marigold-deep); color: #fff; } +.btn-danger { background: #fbeae5; color: var(--danger); } +.btn-danger:hover { background: var(--danger); color: #fff; } +.btn-ghost { background: transparent; color: inherit; border: 1px solid currentColor; opacity: 0.85; } +.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.8rem; } +.btn-lg { padding: 0.8rem 1.6rem; font-size: 1.05rem; } +.btn-block { width: 100%; } +.btn[disabled] { opacity: 0.45; cursor: not-allowed; } + +/* ---------- tables ---------- */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } +th { text-align: left; color: var(--muted); font-weight: 600; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; } +th, td { padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--line); } +tbody tr:hover { background: #faf8f2; } +.ta-right { text-align: right; } +.nowrap { white-space: nowrap; } +.log-wrap { max-height: 420px; overflow-y: auto; } + +/* ---------- pills / flash ---------- */ +.pill { border-radius: 999px; padding: 0.15rem 0.6rem; font-size: 0.72rem; font-weight: 600; } +.pill-ok { background: #e2f2e8; color: var(--ok); } +.pill-bad { background: #fbeae5; color: var(--danger); } +.pill-wait { background: #fdf1dc; color: var(--marigold-deep); } +.flash { padding: 0.7rem 1rem; border-radius: 10px; margin-bottom: 1rem; font-size: 0.9rem; } +.flash-ok { background: #e2f2e8; color: var(--ok); } +.flash-error { background: #fbeae5; color: var(--danger); } + +/* ---------- yearly calendar ---------- */ +.cal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.8rem; } +.cal-nav { display: flex; align-items: center; gap: 0.7rem; } +.cal-year { font-size: 1.2rem; font-weight: 700; } +.year-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); + gap: 1rem; +} +.mini-cal { font-size: 0.72rem; } +.mini-cal th, .mini-cal td { padding: 0.22rem; text-align: center; border: none; } +.mini-cal td { position: relative; border-radius: 6px; } +.mini-cal td.bday { background: #fdf1dc; font-weight: 700; color: var(--marigold-deep); cursor: help; } +.mini-cal td.today { outline: 2px solid var(--pine); } +.mini-cal .dot { + position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); + width: 4px; height: 4px; border-radius: 50%; background: var(--marigold-deep); +} + +/* ---------- images ---------- */ +.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-top: 1rem; } +.image-tile { + border: 1px solid var(--line); border-radius: 10px; padding: 0.6rem; + display: flex; flex-direction: column; gap: 0.5rem; align-items: center; text-align: center; +} +.image-tile img { max-width: 100%; max-height: 110px; object-fit: contain; border-radius: 6px; } +.image-tile code { word-break: break-all; } + +/* ---------- auth / misc ---------- */ +.auth-card { + background: var(--card); border: 1px solid var(--line); border-radius: 14px; + max-width: 380px; margin: 8vh auto 0; padding: 2rem; + display: flex; flex-direction: column; gap: 0.4rem; +} +.auth-card.wide { max-width: 480px; margin-top: 3vh; } +.auth-card form { display: flex; flex-direction: column; gap: 0.9rem; margin-top: 0.8rem; } +.qr-wrap { text-align: center; margin: 0.5rem 0; } +.qr-wrap img { width: 190px; height: 190px; } +.muted { color: var(--muted); } +.small { font-size: 0.8rem; } +code { background: #f0ede4; padding: 0.1rem 0.35rem; border-radius: 5px; font-size: 0.82em; } +.tpl-zone textarea { width: 100%; } + +@media (prefers-reduced-motion: no-preference) { + .btn { transition: background 0.15s ease, color 0.15s ease; } +} +@media (max-width: 700px) { + .topbar { flex-wrap: wrap; gap: 0.6rem; } + .today-banner { flex-direction: column; align-items: flex-start; } + .today-right { text-align: left; } +} diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..903d0a9 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} +{% set active = 'admin' %} +{% block title %}Administration - Birthday Manager{% endblock %} +{% block content %} + + +
+

Users

+
+ + + +
+
+ + + + {% for u in users %} + + + + + + + {% endfor %} + +
UsernameMFACreated
{{ u.username }}{% if u.id == user_id %} you{% endif %}{{ 'Enabled' if u.mfa_enabled else 'Off' }}{{ u.created_at }} + {% if u.id != user_id %} +
+ +
+ {% endif %} +
+
+
+ + +
+
+

Change my password

+
+ + + + +
+
+ +
+

Two-factor authentication (MFA)

+ {% set me = users | selectattr('id', 'equalto', user_id) | first %} + {% if me and me.mfa_enabled %} +

MFA is enabled for your account. You'll be asked for a 6-digit code every time you sign in.

+
+ +
+ {% else %} +

Protect your account with a one-time code from an authenticator app (Google Authenticator, Authy, Microsoft Authenticator…).

+ Enable MFA + {% endif %} +
+
+ + +
+

Automatic e-mail send time

+

The birthday e-mails are sent automatically every day at this time.

+
+ + +
+
+ + +
+

E-mail log

+ {% if log %} +
+ + + + {% for l in log %} + + + + + + + + {% endfor %} + +
Date & timeRecipientE-mail addressSubjectStatus
{{ l.sent_at }}{{ l.recipient_name }}{{ l.recipient_email }}{{ l.subject }} + {{ l.status }} +
+
+ {% else %} +

No e-mails have been sent yet.

+ {% endif %} +
+ + +
+

Images

+

Upload one or more images (15 MB total per upload). Each image shows the ready-to-use + <img> command — copy it into a template and the image is embedded inline in the e-mail when it is sent.

+
+ + +
+ {% if images %} +
+ {% for img in images %} +
+ {{ img }} +
+ + +
+
+ +
+
+ {% endfor %} +
+ {% endif %} +
+ + +
+
+

E-mail Template Male

+
+ + + +

Placeholders: {first_name} {last_name} {age} — insert images with <img src="/uploads/…">

+ +
+
+ +
+

E-Mail Template Female

+
+ + + +

Placeholders: {first_name} {last_name} {age} — insert images with <img src="/uploads/…">

+ +
+
+
+ +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..522cb7c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,40 @@ + + + + + + {% block title %}Birthday Manager{% endblock %} + + + + + +
+
+ 🎂 + Birthday Manager +
+ {% if username %} + +
+ {{ username }} + Log out +
+ {% endif %} +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
{{ footer }}
+ + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..710cea8 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,147 @@ +{% extends "base.html" %} +{% set active = 'dashboard' %} +{% block title %}Dashboard - Birthday Manager{% endblock %} +{% block content %} + + +
+
+

Yesterday

+

Yesterday's Birthdays

+ {% if yesterday_list %} +
    + {% for item in yesterday_list %} +
  • {{ item.row.first_name }} {{ item.row.last_name }} turned {{ item.age }}
  • + {% endfor %} +
+ {% else %} +

No birthdays yesterday.

+ {% endif %} +
+ +
+
+

Today, {{ today.strftime('%A %d %B %Y') }}

+

Today's Birthdays

+ {% if today_list %} +
    + {% for item in today_list %} +
  • + 🎉 {{ item.row.first_name }} {{ item.row.last_name }} + turns {{ item.age }} + + {{ 'e-mail sent' if item.sent else 'e-mail pending' }} + +
  • + {% endfor %} +
+ {% else %} +

No birthdays today.

+ {% endif %} +
+
+ +

Sends today's birthday e-mail{{ 's' if today_list|length != 1 }} again immediately.

+
+
+ +
+

Tomorrow

+

Tomorrow's Birthdays

+ {% if tomorrow_list %} +
    + {% for item in tomorrow_list %} +
  • {{ item.row.first_name }} {{ item.row.last_name }} turns {{ item.age }}
  • + {% endfor %} +
+ {% else %} +

No birthdays tomorrow.

+ {% endif %} +
+
+ + +
+

Birthday entries

+
+ + + + + + +
+ + {% if entries %} +
+ + + + + + {% for e in entries %} + + + + + + + + + {% endfor %} + +
First NameLast NameDate of BirthGenderE-mail
{{ e.first_name }}{{ e.last_name }}{{ e.dob }}{{ e.gender }}{{ e.email }} +
+ +
+
+
+ {% else %} +

No entries yet. Add the first birthday above.

+ {% endif %} +
+ + +
+
+

Yearly overview

+ +
+
+ {% for month in months %} +
+

{{ month.name }}

+ + + + {% for week in month.weeks %} + + {% for cell in week %} + + {% endfor %} + + {% endfor %} + +
MoTuWeThFrSaSu
+ {{ cell.day }} + {% if cell.names %}{% endif %} +
+
+ {% endfor %} +
+

Days marked with a dot have a birthday — hover to see who. Today is outlined.

+
+ +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..9a8afa3 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Sign in - Birthday Manager{% endblock %} +{% block content %} +
+

Sign in

+

Birthday Manager

+
+ + + +
+
+{% endblock %} diff --git a/templates/mfa_setup.html b/templates/mfa_setup.html new file mode 100644 index 0000000..9c3e3f0 --- /dev/null +++ b/templates/mfa_setup.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% set active = 'admin' %} +{% block title %}Enable two-factor authentication{% endblock %} +{% block content %} +
+

Enable two-factor authentication

+

Scan this QR code with Google Authenticator, Microsoft Authenticator, Authy or any TOTP app, then enter the 6-digit code it shows to confirm.

+
+ MFA QR code +

Can't scan? Enter this key manually: {{ secret }}

+
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/templates/mfa_verify.html b/templates/mfa_verify.html new file mode 100644 index 0000000..d0cdae8 --- /dev/null +++ b/templates/mfa_verify.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Two-factor authentication{% endblock %} +{% block content %} +
+

Two-factor code

+

Enter the 6-digit code from your authenticator app.

+
+ + +
+
+{% endblock %}