Files
2026-09-13 20:15:10 +01:00

725 lines
26 KiB
Python

"""
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)