v_1.2
This commit is contained in:
@@ -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"]
|
||||||
Binary file not shown.
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 %}
|
||||||
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,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 `<img src="/uploads/…">` 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.
|
||||||
@@ -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 = (
|
||||||
|
"<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
|
||||||
|
);
|
||||||
|
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/<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))
|
||||||
|
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/<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("/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 <img> 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/<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)
|
||||||
|
_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)
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
flask>=3.0
|
||||||
|
pyotp>=2.9
|
||||||
|
qrcode[pil]>=7.4
|
||||||
|
APScheduler>=3.10
|
||||||
|
werkzeug>=3.0
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
{% 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>
|
||||||
|
|
||||||
|
<!-- ======================= Automatic send time ======================= -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>Automatic e-mail send time</h2>
|
||||||
|
<p class="muted">The birthday e-mails are sent automatically every day at this time.</p>
|
||||||
|
<form method="post" action="{{ url_for('save_send_time') }}" class="entry-form">
|
||||||
|
<label>Send time
|
||||||
|
<input type="time" name="send_time" value="{{ send_time }}" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" type="submit">Save send time</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================= 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 one or more images (15 MB total per upload). Each image shows the ready-to-use
|
||||||
|
<code><img></code> command — copy it into a template and the image is 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="images" accept=".png,.jpg,.jpeg,.gif,.webp" multiple required>
|
||||||
|
<button class="btn btn-primary" type="submit">Upload image(s)</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 }}">
|
||||||
|
<div class="copy-row">
|
||||||
|
<input type="text" readonly class="copy-input" value='<img src="/uploads/{{ img }}">'
|
||||||
|
onclick="this.select()">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm"
|
||||||
|
onclick="navigator.clipboard.writeText(this.previousElementSibling.value);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)">Copy</button>
|
||||||
|
</div>
|
||||||
|
<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,147 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active = 'dashboard' %}
|
||||||
|
{% block title %}Dashboard - Birthday Manager{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- ============== Yesterday / Today / Tomorrow birthdays ============== -->
|
||||||
|
<div class="tri-banner">
|
||||||
|
<section class="card day-panel">
|
||||||
|
<p class="eyebrow">Yesterday</p>
|
||||||
|
<h2>Yesterday's Birthdays</h2>
|
||||||
|
{% if yesterday_list %}
|
||||||
|
<ul class="today-people">
|
||||||
|
{% for item in yesterday_list %}
|
||||||
|
<li><strong>{{ item.row.first_name }} {{ item.row.last_name }}</strong> turned {{ item.age }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No birthdays yesterday.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card day-panel day-panel-today">
|
||||||
|
<div class="today-left">
|
||||||
|
<p class="eyebrow">Today, {{ today.strftime('%A %d %B %Y') }}</p>
|
||||||
|
<h2>Today's Birthdays</h2>
|
||||||
|
{% if today_list %}
|
||||||
|
<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 %}
|
||||||
|
<p class="muted">No birthdays today.</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>
|
||||||
|
|
||||||
|
<section class="card day-panel">
|
||||||
|
<p class="eyebrow">Tomorrow</p>
|
||||||
|
<h2>Tomorrow's Birthdays</h2>
|
||||||
|
{% if tomorrow_list %}
|
||||||
|
<ul class="today-people">
|
||||||
|
{% for item in tomorrow_list %}
|
||||||
|
<li><strong>{{ item.row.first_name }} {{ item.row.last_name }}</strong> turns {{ item.age }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No birthdays tomorrow.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======================= 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 %}
|
||||||
@@ -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 `<img src="/uploads/…">` 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.
|
||||||
@@ -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 = (
|
||||||
|
"<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
|
||||||
|
);
|
||||||
|
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/<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))
|
||||||
|
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/<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("/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 <img> 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/<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)
|
||||||
|
_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)
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
flask>=3.0
|
||||||
|
pyotp>=2.9
|
||||||
|
qrcode[pil]>=7.4
|
||||||
|
APScheduler>=3.10
|
||||||
|
werkzeug>=3.0
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
{% 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>
|
||||||
|
|
||||||
|
<!-- ======================= Automatic send time ======================= -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>Automatic e-mail send time</h2>
|
||||||
|
<p class="muted">The birthday e-mails are sent automatically every day at this time.</p>
|
||||||
|
<form method="post" action="{{ url_for('save_send_time') }}" class="entry-form">
|
||||||
|
<label>Send time
|
||||||
|
<input type="time" name="send_time" value="{{ send_time }}" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" type="submit">Save send time</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================= 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 one or more images (15 MB total per upload). Each image shows the ready-to-use
|
||||||
|
<code><img></code> command — copy it into a template and the image is 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="images" accept=".png,.jpg,.jpeg,.gif,.webp" multiple required>
|
||||||
|
<button class="btn btn-primary" type="submit">Upload image(s)</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 }}">
|
||||||
|
<div class="copy-row">
|
||||||
|
<input type="text" readonly class="copy-input" value='<img src="/uploads/{{ img }}">'
|
||||||
|
onclick="this.select()">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm"
|
||||||
|
onclick="navigator.clipboard.writeText(this.previousElementSibling.value);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)">Copy</button>
|
||||||
|
</div>
|
||||||
|
<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,147 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active = 'dashboard' %}
|
||||||
|
{% block title %}Dashboard - Birthday Manager{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- ============== Yesterday / Today / Tomorrow birthdays ============== -->
|
||||||
|
<div class="tri-banner">
|
||||||
|
<section class="card day-panel">
|
||||||
|
<p class="eyebrow">Yesterday</p>
|
||||||
|
<h2>Yesterday's Birthdays</h2>
|
||||||
|
{% if yesterday_list %}
|
||||||
|
<ul class="today-people">
|
||||||
|
{% for item in yesterday_list %}
|
||||||
|
<li><strong>{{ item.row.first_name }} {{ item.row.last_name }}</strong> turned {{ item.age }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No birthdays yesterday.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card day-panel day-panel-today">
|
||||||
|
<div class="today-left">
|
||||||
|
<p class="eyebrow">Today, {{ today.strftime('%A %d %B %Y') }}</p>
|
||||||
|
<h2>Today's Birthdays</h2>
|
||||||
|
{% if today_list %}
|
||||||
|
<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 %}
|
||||||
|
<p class="muted">No birthdays today.</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>
|
||||||
|
|
||||||
|
<section class="card day-panel">
|
||||||
|
<p class="eyebrow">Tomorrow</p>
|
||||||
|
<h2>Tomorrow's Birthdays</h2>
|
||||||
|
{% if tomorrow_list %}
|
||||||
|
<ul class="today-people">
|
||||||
|
{% for item in tomorrow_list %}
|
||||||
|
<li><strong>{{ item.row.first_name }} {{ item.row.last_name }}</strong> turns {{ item.age }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No birthdays tomorrow.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======================= 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