From 0072054fc7f23e17ee800beb8472e0df744df095 Mon Sep 17 00:00:00 2001 From: jpmvaz Date: Sun, 13 Sep 2026 20:15:10 +0100 Subject: [PATCH] v_1.2 --- Dockerfile | 9 + OLD_VERSIONS/v1.0/birthday-app.zip | Bin 0 -> 18747 bytes OLD_VERSIONS/v1.1/birthday-app.zip | Bin 0 -> 19913 bytes OLD_VERSIONS/v1.1/birthday-app/Dockerfile | 9 + OLD_VERSIONS/v1.1/birthday-app/README.md | 90 +++ OLD_VERSIONS/v1.1/birthday-app/app.py | 647 ++++++++++++++++ .../v1.1/birthday-app/docker-compose.yml | 30 + .../v1.1/birthday-app/requirements.txt | 5 + .../v1.1/birthday-app/static/style.css | 230 ++++++ .../v1.1/birthday-app/templates/admin.html | 148 ++++ .../v1.1/birthday-app/templates/base.html | 40 + .../birthday-app/templates/dashboard.html | 118 +++ .../v1.1/birthday-app/templates/login.html | 17 + .../birthday-app/templates/mfa_setup.html | 22 + .../birthday-app/templates/mfa_verify.html | 14 + OLD_VERSIONS/v1.2/birthday-app.zip | Bin 0 -> 21329 bytes OLD_VERSIONS/v1.2/birthday-app/Dockerfile | 9 + OLD_VERSIONS/v1.2/birthday-app/README.md | 91 +++ OLD_VERSIONS/v1.2/birthday-app/app.py | 724 ++++++++++++++++++ .../v1.2/birthday-app/docker-compose.yml | 30 + .../v1.2/birthday-app/requirements.txt | 5 + .../v1.2/birthday-app/static/style.css | 258 +++++++ .../v1.2/birthday-app/templates/admin.html | 165 ++++ .../v1.2/birthday-app/templates/base.html | 40 + .../birthday-app/templates/dashboard.html | 147 ++++ .../v1.2/birthday-app/templates/login.html | 17 + .../birthday-app/templates/mfa_setup.html | 22 + .../birthday-app/templates/mfa_verify.html | 14 + README.md | 91 +++ app.py | 724 ++++++++++++++++++ docker-compose.yml | 30 + requirements.txt | 5 + static/style.css | 258 +++++++ templates/admin.html | 165 ++++ templates/base.html | 40 + templates/dashboard.html | 147 ++++ templates/login.html | 17 + templates/mfa_setup.html | 22 + templates/mfa_verify.html | 14 + 39 files changed, 4414 insertions(+) create mode 100644 Dockerfile create mode 100644 OLD_VERSIONS/v1.0/birthday-app.zip create mode 100644 OLD_VERSIONS/v1.1/birthday-app.zip create mode 100644 OLD_VERSIONS/v1.1/birthday-app/Dockerfile create mode 100644 OLD_VERSIONS/v1.1/birthday-app/README.md create mode 100644 OLD_VERSIONS/v1.1/birthday-app/app.py create mode 100644 OLD_VERSIONS/v1.1/birthday-app/docker-compose.yml create mode 100644 OLD_VERSIONS/v1.1/birthday-app/requirements.txt create mode 100644 OLD_VERSIONS/v1.1/birthday-app/static/style.css create mode 100644 OLD_VERSIONS/v1.1/birthday-app/templates/admin.html create mode 100644 OLD_VERSIONS/v1.1/birthday-app/templates/base.html create mode 100644 OLD_VERSIONS/v1.1/birthday-app/templates/dashboard.html create mode 100644 OLD_VERSIONS/v1.1/birthday-app/templates/login.html create mode 100644 OLD_VERSIONS/v1.1/birthday-app/templates/mfa_setup.html create mode 100644 OLD_VERSIONS/v1.1/birthday-app/templates/mfa_verify.html create mode 100644 OLD_VERSIONS/v1.2/birthday-app.zip create mode 100644 OLD_VERSIONS/v1.2/birthday-app/Dockerfile create mode 100644 OLD_VERSIONS/v1.2/birthday-app/README.md create mode 100644 OLD_VERSIONS/v1.2/birthday-app/app.py create mode 100644 OLD_VERSIONS/v1.2/birthday-app/docker-compose.yml create mode 100644 OLD_VERSIONS/v1.2/birthday-app/requirements.txt create mode 100644 OLD_VERSIONS/v1.2/birthday-app/static/style.css create mode 100644 OLD_VERSIONS/v1.2/birthday-app/templates/admin.html create mode 100644 OLD_VERSIONS/v1.2/birthday-app/templates/base.html create mode 100644 OLD_VERSIONS/v1.2/birthday-app/templates/dashboard.html create mode 100644 OLD_VERSIONS/v1.2/birthday-app/templates/login.html create mode 100644 OLD_VERSIONS/v1.2/birthday-app/templates/mfa_setup.html create mode 100644 OLD_VERSIONS/v1.2/birthday-app/templates/mfa_verify.html create mode 100644 README.md create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 static/style.css create mode 100644 templates/admin.html create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/login.html create mode 100644 templates/mfa_setup.html create mode 100644 templates/mfa_verify.html 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 0000000000000000000000000000000000000000..a6528e77bf38c7a3398aa4689c5560ecf73a064e GIT binary patch literal 18747 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 FsVGAN0AC%bS^i60+@S#g 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∓1rt3dNw75f;?E~@i@YNS@j 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 Vl?_` 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 0000000000000000000000000000000000000000..b5646cecbf92b7ed15198d2886d3a1fb57ac6311 GIT binary patch literal 21329 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 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 %}