v_1.2
This commit is contained in:
Binary file not shown.
@@ -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"]
|
||||
@@ -0,0 +1,90 @@
|
||||
# Birthday Manager
|
||||
|
||||
Self-hosted birthday reminder application that automatically e-mails people on their birthday.
|
||||
|
||||
© 2026 Martinhal IT - Joao Vaz - Version 1.1
|
||||
|
||||
## Features
|
||||
|
||||
**Page 1 — Dashboard**
|
||||
- Banner showing whose birthday it is today (based on system date/time) with a **Force Send** button to resend the e-mail
|
||||
- Add / remove birthday entries: First Name, Last Name, Date of Birth, Gender, E-mail address
|
||||
- Yearly calendar overview of all entries (hover a marked day to see the names; browse other years)
|
||||
|
||||
**Page 2 — Administration**
|
||||
- Add / remove application users
|
||||
- Every user can enable/disable MFA (TOTP — Google Authenticator, Authy, etc.) and change their own password
|
||||
- Full e-mail log: who received mail, at what date & time, and whether it succeeded
|
||||
- Two template zones: **E-mail Template Male** and **E-Mail Template Female**, with placeholders `{first_name}`, `{last_name}`, `{age}`
|
||||
- Image upload — reference an uploaded image inside a template with `<img src="/uploads/filename">` 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.
|
||||
@@ -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 = (
|
||||
"<p>Dear {first_name} {last_name},</p>"
|
||||
"<p>Happy Birthday! Wishing you a fantastic day and a wonderful year ahead. "
|
||||
"Congratulations on turning {age}!</p>"
|
||||
"<p>Best regards,<br>Martinhal IT</p>"
|
||||
)
|
||||
DEFAULT_TEMPLATE_FEMALE = (
|
||||
"<p>Dear {first_name} {last_name},</p>"
|
||||
"<p>Happy Birthday! Wishing you a beautiful day filled with joy. "
|
||||
"Congratulations on turning {age}!</p>"
|
||||
"<p>Warm regards,<br>Martinhal IT</p>"
|
||||
)
|
||||
|
||||
|
||||
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/<int:bid>/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/<int:uid>/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: <img src=\"/uploads/{unique}\">", "ok")
|
||||
return redirect(url_for("admin"))
|
||||
|
||||
|
||||
@app.route("/images/<path:fname>/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/<path:fname>")
|
||||
@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)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
flask>=3.0
|
||||
pyotp>=2.9
|
||||
qrcode[pil]>=7.4
|
||||
APScheduler>=3.10
|
||||
werkzeug>=3.0
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active = 'admin' %}
|
||||
{% block title %}Administration - Birthday Manager{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- ======================= Users ======================= -->
|
||||
<section class="card">
|
||||
<h2>Users</h2>
|
||||
<form method="post" action="{{ url_for('add_user') }}" class="entry-form">
|
||||
<label>Username<input type="text" name="username" required></label>
|
||||
<label>Password<input type="password" name="password" required minlength="4"></label>
|
||||
<button class="btn btn-primary" type="submit">Add user</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Username</th><th>MFA</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username }}{% if u.id == user_id %} <span class="pill pill-ok">you</span>{% endif %}</td>
|
||||
<td>{{ 'Enabled' if u.mfa_enabled else 'Off' }}</td>
|
||||
<td>{{ u.created_at }}</td>
|
||||
<td class="ta-right">
|
||||
{% if u.id != user_id %}
|
||||
<form method="post" action="{{ url_for('delete_user', uid=u.id) }}"
|
||||
onsubmit="return confirm('Remove user {{ u.username }}?')">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Remove</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= My account: password + MFA ======================= -->
|
||||
<div class="two-col">
|
||||
<section class="card">
|
||||
<h2>Change my password</h2>
|
||||
<form method="post" action="{{ url_for('change_password') }}" class="stack-form">
|
||||
<label>Current password<input type="password" name="current" required autocomplete="current-password"></label>
|
||||
<label>New password<input type="password" name="new" required minlength="4" autocomplete="new-password"></label>
|
||||
<label>Confirm new password<input type="password" name="confirm" required minlength="4" autocomplete="new-password"></label>
|
||||
<button class="btn btn-primary" type="submit">Change password</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Two-factor authentication (MFA)</h2>
|
||||
{% set me = users | selectattr('id', 'equalto', user_id) | first %}
|
||||
{% if me and me.mfa_enabled %}
|
||||
<p>MFA is <strong>enabled</strong> for your account. You'll be asked for a 6-digit code every time you sign in.</p>
|
||||
<form method="post" action="{{ url_for('mfa_disable') }}"
|
||||
onsubmit="return confirm('Disable two-factor authentication for your account?')">
|
||||
<button class="btn btn-danger" type="submit">Disable MFA</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Protect your account with a one-time code from an authenticator app (Google Authenticator, Authy, Microsoft Authenticator…).</p>
|
||||
<a class="btn btn-primary" href="{{ url_for('mfa_setup') }}">Enable MFA</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ======================= E-mail log ======================= -->
|
||||
<section class="card">
|
||||
<h2>E-mail log</h2>
|
||||
{% if log %}
|
||||
<div class="table-wrap log-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Date & time</th><th>Recipient</th><th>E-mail address</th><th>Subject</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for l in log %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ l.sent_at }}</td>
|
||||
<td>{{ l.recipient_name }}</td>
|
||||
<td>{{ l.recipient_email }}</td>
|
||||
<td>{{ l.subject }}</td>
|
||||
<td>
|
||||
<span class="pill {{ 'pill-ok' if l.status == 'Sent' else 'pill-bad' }}"
|
||||
{% if l.detail %}title="{{ l.detail }}"{% endif %}>{{ l.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No e-mails have been sent yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ======================= Images ======================= -->
|
||||
<section class="card">
|
||||
<h2>Images</h2>
|
||||
<p class="muted">Upload images here, then place them inside a template with
|
||||
<code><img src="/uploads/filename"></code>. Images are embedded inline in the e-mail when it is sent.</p>
|
||||
<form method="post" action="{{ url_for('upload_image') }}" enctype="multipart/form-data" class="entry-form">
|
||||
<input type="file" name="image" accept=".png,.jpg,.jpeg,.gif,.webp" required>
|
||||
<button class="btn btn-primary" type="submit">Upload image</button>
|
||||
</form>
|
||||
{% if images %}
|
||||
<div class="image-grid">
|
||||
{% for img in images %}
|
||||
<div class="image-tile">
|
||||
<img src="{{ url_for('serve_upload', fname=img) }}" alt="{{ img }}">
|
||||
<code class="small">/uploads/{{ img }}</code>
|
||||
<form method="post" action="{{ url_for('delete_image', fname=img) }}"
|
||||
onsubmit="return confirm('Delete this image?')">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ======================= Template zones ======================= -->
|
||||
<div class="two-col">
|
||||
<section class="card tpl-zone">
|
||||
<h2>E-mail Template Male</h2>
|
||||
<form method="post" action="{{ url_for('save_template') }}" class="stack-form">
|
||||
<input type="hidden" name="gender" value="Male">
|
||||
<label>Subject<input type="text" name="subject" value="{{ tpl_male.subject }}"></label>
|
||||
<label>Body (HTML)
|
||||
<textarea name="body" rows="10">{{ tpl_male.body }}</textarea>
|
||||
</label>
|
||||
<p class="muted small">Placeholders: <code>{first_name}</code> <code>{last_name}</code> <code>{age}</code> — insert images with <code><img src="/uploads/…"></code></p>
|
||||
<button class="btn btn-primary" type="submit">Save Male template</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card tpl-zone">
|
||||
<h2>E-Mail Template Female</h2>
|
||||
<form method="post" action="{{ url_for('save_template') }}" class="stack-form">
|
||||
<input type="hidden" name="gender" value="Female">
|
||||
<label>Subject<input type="text" name="subject" value="{{ tpl_female.subject }}"></label>
|
||||
<label>Body (HTML)
|
||||
<textarea name="body" rows="10">{{ tpl_female.body }}</textarea>
|
||||
</label>
|
||||
<p class="muted small">Placeholders: <code>{first_name}</code> <code>{last_name}</code> <code>{age}</code> — insert images with <code><img src="/uploads/…"></code></p>
|
||||
<button class="btn btn-primary" type="submit">Save Female template</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Birthday Manager{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">🎂</span>
|
||||
<span class="brand-name">Birthday Manager</span>
|
||||
</div>
|
||||
{% if username %}
|
||||
<nav class="nav">
|
||||
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if active == 'dashboard' }}">Dashboard</a>
|
||||
<a href="{{ url_for('admin') }}" class="{{ 'active' if active == 'admin' }}">Administration</a>
|
||||
</nav>
|
||||
<div class="userbox">
|
||||
<span class="userchip">{{ username }}</span>
|
||||
<a class="btn btn-ghost" href="{{ url_for('logout') }}">Log out</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, msg in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">{{ footer }}</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,118 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active = 'dashboard' %}
|
||||
{% block title %}Dashboard - Birthday Manager{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- ======================= Today's birthdays + Force Send ======================= -->
|
||||
<section class="card today-banner">
|
||||
<div class="today-left">
|
||||
<p class="eyebrow">Today, {{ today.strftime('%A %d %B %Y') }}</p>
|
||||
{% if today_list %}
|
||||
<h1>🎉 Birthday{{ 's' if today_list|length > 1 }} today</h1>
|
||||
<ul class="today-people">
|
||||
{% for item in today_list %}
|
||||
<li>
|
||||
<strong>{{ item.row.first_name }} {{ item.row.last_name }}</strong>
|
||||
turns {{ item.age }}
|
||||
<span class="pill {{ 'pill-ok' if item.sent else 'pill-wait' }}">
|
||||
{{ 'e-mail sent' if item.sent else 'e-mail pending' }}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<h1>No birthdays today</h1>
|
||||
<p class="muted">The next celebration is waiting on the calendar below.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('force_send') }}" class="today-right">
|
||||
<button class="btn btn-accent btn-lg" type="submit" {{ 'disabled' if not today_list }}>Force Send</button>
|
||||
<p class="muted small">Sends today's birthday e-mail{{ 's' if today_list|length != 1 }} again immediately.</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- ======================= Add / remove entries ======================= -->
|
||||
<section class="card">
|
||||
<h2>Birthday entries</h2>
|
||||
<form method="post" action="{{ url_for('add_birthday') }}" class="entry-form">
|
||||
<label>First Name<input type="text" name="first_name" required></label>
|
||||
<label>Last Name<input type="text" name="last_name" required></label>
|
||||
<label>Date of Birth<input type="date" name="dob" required></label>
|
||||
<label>Gender
|
||||
<select name="gender" required>
|
||||
<option value="" disabled selected>Select…</option>
|
||||
<option>Male</option>
|
||||
<option>Female</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>E-mail address<input type="email" name="email" required placeholder="needed to send the greeting"></label>
|
||||
<button class="btn btn-primary" type="submit">Add entry</button>
|
||||
</form>
|
||||
|
||||
{% if entries %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>First Name</th><th>Last Name</th><th>Date of Birth</th><th>Gender</th><th>E-mail</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td>{{ e.first_name }}</td>
|
||||
<td>{{ e.last_name }}</td>
|
||||
<td>{{ e.dob }}</td>
|
||||
<td>{{ e.gender }}</td>
|
||||
<td>{{ e.email }}</td>
|
||||
<td class="ta-right">
|
||||
<form method="post" action="{{ url_for('delete_birthday', bid=e.id) }}"
|
||||
onsubmit="return confirm('Remove {{ e.first_name }} {{ e.last_name }}?')">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No entries yet. Add the first birthday above.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ======================= Yearly calendar ======================= -->
|
||||
<section class="card">
|
||||
<div class="cal-head">
|
||||
<h2>Yearly overview</h2>
|
||||
<div class="cal-nav">
|
||||
<a class="btn btn-ghost btn-sm" href="{{ url_for('dashboard', year=year-1) }}">← {{ year - 1 }}</a>
|
||||
<span class="cal-year">{{ year }}</span>
|
||||
<a class="btn btn-ghost btn-sm" href="{{ url_for('dashboard', year=year+1) }}">{{ year + 1 }} →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="year-grid">
|
||||
{% for month in months %}
|
||||
<div class="month">
|
||||
<h3>{{ month.name }}</h3>
|
||||
<table class="mini-cal">
|
||||
<thead><tr><th>Mo</th><th>Tu</th><th>We</th><th>Th</th><th>Fr</th><th>Sa</th><th>Su</th></tr></thead>
|
||||
<tbody>
|
||||
{% for week in month.weeks %}
|
||||
<tr>
|
||||
{% for cell in week %}
|
||||
<td class="{{ 'bday' if cell.names }} {{ 'today' if cell.today }}"
|
||||
{% if cell.names %}title="{{ cell.names | join(', ') }}"{% endif %}>
|
||||
{{ cell.day }}
|
||||
{% if cell.names %}<span class="dot"></span>{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="muted small">Days marked with a dot have a birthday — hover to see who. Today is outlined.</p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sign in - Birthday Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-card">
|
||||
<h1>Sign in</h1>
|
||||
<p class="muted">Birthday Manager</p>
|
||||
<form method="post">
|
||||
<label>Username
|
||||
<input type="text" name="username" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active = 'admin' %}
|
||||
{% block title %}Enable two-factor authentication{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-card wide">
|
||||
<h1>Enable two-factor authentication</h1>
|
||||
<p class="muted">Scan this QR code with Google Authenticator, Microsoft Authenticator, Authy or any TOTP app, then enter the 6-digit code it shows to confirm.</p>
|
||||
<div class="qr-wrap">
|
||||
<img alt="MFA QR code" src="data:image/png;base64,{{ qr }}">
|
||||
<p class="muted small">Can't scan? Enter this key manually: <code>{{ secret }}</code></p>
|
||||
</div>
|
||||
<form method="post">
|
||||
<label>Authentication code
|
||||
<input type="text" name="code" inputmode="numeric" maxlength="7" required autofocus autocomplete="one-time-code">
|
||||
</label>
|
||||
<div class="row-gap">
|
||||
<button class="btn btn-primary" type="submit">Confirm & enable</button>
|
||||
<a class="btn btn-ghost" href="{{ url_for('admin') }}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Two-factor authentication{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-card">
|
||||
<h1>Two-factor code</h1>
|
||||
<p class="muted">Enter the 6-digit code from your authenticator app.</p>
|
||||
<form method="post">
|
||||
<label>Authentication code
|
||||
<input type="text" name="code" inputmode="numeric" pattern="[0-9 ]*" maxlength="7" required autofocus autocomplete="one-time-code">
|
||||
</label>
|
||||
<button class="btn btn-primary btn-block" type="submit">Verify</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user