feat(collab): v4.9.0 Collaboration - commentaires inline, mentions @, notifications in-app + email
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-04 23:40:21 -04:00
parent 23df10732b
commit b60cc8a7c6
21 changed files with 1515 additions and 35 deletions
+86
View File
@@ -0,0 +1,86 @@
"""FlowDeck — Email notifications via SMTP (v4.9.0).
If SMTP is not configured (smtp_host empty) this is a safe no-op, so the
application works locally out of the box while still logging intent.
"""
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from app.config import settings
logger = logging.getLogger(__name__)
def _configured() -> bool:
return bool(settings.smtp_host)
def _html_body(body_text: str, cta_url: str = "") -> str:
cta = ""
if cta_url:
cta = (
'<p style="margin:24px 0 0;">'
f'<a href="{cta_url}" '
'style="background:#2383E2;color:#fff;text-decoration:none;'
'padding:10px 20px;border-radius:8px;display:inline-block;'
'font-weight:600;">Open in FlowDeck &rarr;</a></p>'
)
return f"""<div style="font-family:-apple-system,'Segoe UI',Roboto,sans-serif;
background:#191919;color:#e0e0e0;padding:32px;">
<div style="max-width:520px;margin:0 auto;background:#252525;border:1px solid #333;
border-radius:12px;padding:24px;">
<div style="font-size:18px;font-weight:700;color:#fff;margin-bottom:8px;">FlowDeck</div>
<p style="color:#e0e0e0;line-height:1.6;white-space:pre-wrap;">{body_text}</p>
{cta}
<p style="margin-top:24px;font-size:12px;color:#999;">You received this because your
notifications preferences in FlowDeck allow it.</p>
</div></div>"""
def send_email(to_email: str, subject: str, body_text: str, cta_url: str = "") -> bool:
"""Send an email. Returns True on success, False if skipped or failed."""
if not to_email or not _configured():
return False
try:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = settings.smtp_from
msg["To"] = to_email
msg.set_content(body_text)
msg.add_alternative(_html_body(body_text, cta_url), subtype="html")
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as server:
if settings.smtp_use_tls:
server.starttls()
if settings.smtp_user:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg)
logger.info("Email sent to %s: %s", to_email, subject)
return True
except Exception as e: # never break the request on mail failure
logger.warning("Email send failed to %s: %s", to_email, e)
return False
def notify_user(
user_id: int,
subject: str,
body_text: str,
cta_url: str = "",
prefs_key: str = "mentions",
) -> bool:
"""Resolve a user's email + preferences and send an email notification."""
from app.db import get_conn
from app.services import notifications
with get_conn() as conn:
row = conn.execute("SELECT id, email FROM users WHERE id=?", (user_id,)).fetchone()
if not row or not row["email"]:
return False
prefs = notifications.get_user_prefs(user_id)
if not prefs.get(prefs_key, True):
return False
return send_email(row["email"], subject, body_text, cta_url)
+147
View File
@@ -0,0 +1,147 @@
"""FlowDeck — Notification service (v4.9.0 collaboration).
Creates in-app notifications (mentions, comments, page changes) and triggers
email delivery via :mod:`app.services.mailer` when the target user has opted in.
"""
from __future__ import annotations
import json
import re
from app.db import get_conn
from app.services import mailer
def create_notification(
user_id: int,
actor_id: int | None,
ntype: str,
title: str,
message: str,
resource_type: str = "page",
resource_id: int = 0,
url: str = "",
conn=None,
commit: bool = True,
) -> int | None:
"""Insert a notification row. Returns the new id (or None if skipped).
``conn`` may be supplied to join an existing transaction (caller controls
commit). Otherwise a dedicated connection is opened and committed.
"""
if not user_id:
return None
if conn is not None:
cur = conn.execute(
"""INSERT INTO notifications
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
VALUES (?,?,?,?,?,?,?,?)""",
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
)
if commit:
conn.commit()
return cur.lastrowid
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO notifications
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
VALUES (?,?,?,?,?,?,?,?)""",
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
)
conn.commit()
return cur.lastrowid
_MENTION_RE = re.compile(r"(?:^|\s)@([\w\-\.]+)")
def extract_mentions(text: str) -> list[str]:
"""Return the set of @login handles mentioned in ``text`` (lowercase)."""
return list(dict.fromkeys(m.lower() for m in _MENTION_RE.findall(text or "")))
def process_mentions(
text: str,
actor_id: int,
ntype: str,
title: str,
message: str,
resource_type: str = "page",
resource_id: int = 0,
url: str = "",
conn=None,
) -> list[int]:
"""Create notifications for every user @-mentioned in ``text``.
Returns the list of mentioned user ids that were notified.
"""
handles = extract_mentions(text)
if not handles:
return []
notified = []
if conn is not None:
_do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified)
return notified
with get_conn() as conn:
_do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified)
conn.commit()
return notified
def _do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified):
placeholders = ",".join("?" * len(handles))
rows = conn.execute(
"SELECT id, login, email FROM users WHERE lower(login) IN (%s)" % placeholders,
handles,
).fetchall()
for row in rows:
if row["id"] == actor_id:
continue
create_notification(
row["id"], actor_id, ntype, title, message,
resource_type, resource_id, url, conn=conn, commit=False,
)
notified.append(row["id"])
mailer.notify_user(
row["id"], subject=title, body_text=message, cta_url=url or "",
)
def get_user_prefs(user_id: int, conn=None) -> dict:
if conn is not None:
row = conn.execute(
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
).fetchone()
else:
with get_conn() as conn:
row = conn.execute(
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
).fetchone()
if not row or not row["notification_prefs"]:
return {"comments": True, "mentions": True}
try:
prefs = json.loads(row["notification_prefs"])
except (TypeError, json.JSONDecodeError):
prefs = {}
return {"comments": bool(prefs.get("comments", True)),
"mentions": bool(prefs.get("mentions", True))}
def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
if conn is not None:
conn.execute(
"UPDATE users SET notification_prefs=? WHERE id=?",
(json.dumps(prefs), user_id),
)
conn.commit()
else:
with get_conn() as conn:
conn.execute(
"UPDATE users SET notification_prefs=? WHERE id=?",
(json.dumps(prefs), user_id),
)
conn.commit()
return prefs