"""Daily agent digest — "Your book this morning": one email per user, their queue only. Delivery reuses the ONE sanctioned Odoo write path (modules/collections_send.py: mail.mail create through the Office 365 relay) including its SAFE_MODE allow-list — while the guardrail is ON no digest can leave the allow-list. Content rules (adoption canon, engagement-adoption brief): ≤10 lines, only non-empty sections, every line deep-links back to the exact row (?src=digest), and the email is SKIPPED entirely when there is nothing to act on — silence keeps opens high. Internal mail: no model/res_id (these must NOT land in customer chatter). Scheduling: app.py starts one daemon ticker (in the cache_resource singleton) that calls run_due() every few minutes; run_due sends at most once per (weekday, user) after DIGEST_UTC_HOUR (default 11:00 UTC = 7am ET in summer), idempotent across Space restarts via the store key 'digest_log' (empty-skips are logged too, so a quiet day isn't rebuilt all day). BU isolation carries into email: a single-BU user's digest is scoped to that BU's team_id. """ import os import html as _html import datetime as dt import core.store as store import core.users as users import core.links as links import modules.collections_send as cs import modules.customer_data as md # call-list / win-back queues (ex-myday, ex-customer_list) import modules.tasks as tasks import modules.customers as cust KEY = 'digest_log' UTC_HOUR = int(os.environ.get('DIGEST_UTC_HOUR', '11')) WEEKDAYS_ONLY = os.environ.get('DIGEST_WEEKDAYS_ONLY', '1').strip().lower() \ not in ('0', 'false', 'no', '') ENABLED = os.environ.get('DIGEST_ENABLED', '1').strip().lower() not in ('0', 'false', 'no', '') _NAVY, _GOLD, _MUTED = '#5B8FD9', '#D9A93A', '#6B7280' def _team_for(user): """Single-BU users get a digest scoped to that BU — isolation carries into email.""" bus = (user or {}).get('bus', 'all') if isinstance(bus, (list, tuple)) and len(bus) == 1: return bus[0] return None def _m(x): try: return f"${float(x):,.0f}" except (TypeError, ValueError): return '—' def _line(text, href, meta=''): t = _html.escape(str(text)) m = f" {_html.escape(str(meta))}" if meta else '' return (f"" f"{t}" f"{m}") def _sec(title): return (f"{_html.escape(title)}") def build(user, t=None): """(subject, html, n_actionable) for one user. n_actionable == 0 → skip the send.""" t = t or dt.date.today() uname = user.get('username', '') agent = user.get('agent') team_id = _team_for(user) pids = md.agent_pids(agent) today = t.isoformat() my = [x for x in tasks.for_owner(uname)] if store.available() else [] due = [x for x in my if (x.get('due') or '9999-12-31') <= today] q = md.queues(agent, team_id, limit=25) calls, risk = q['calls'][:5], q['risk'][:3] # yesterday's orders for the book (one read_group; skip silently on a transport error — # the digest must never fail because one section couldn't be pulled) yday = (t - dt.timedelta(days=1)).isoformat() y_rev = y_orders = 0 try: rev_map = cust._cust_rev(yday, yday, team_id, pids) y_rev = sum(v.get('rev', 0.0) for v in rev_map.values()) y_orders = sum(v.get('orders', 0) for v in rev_map.values()) except Exception: pass n = len(due) + len(calls) + len(risk) if n == 0 and y_orders == 0: return None, None, 0 rows = [] if due: rows.append(_sec(f'Tasks due ({len(due)})')) for x in due[:5]: ent = x.get('entity') or {} href = links.deeplink('customer_data', src='digest') if ent.get('kind') and ent.get('id') is not None: href = links.deeplink('customer_data', ent['kind'], ent['id'], src='digest') meta = f"due {x.get('due') or '—'}" rows.append(_line(x.get('title', 'Task'), href, meta)) if calls: rows.append(_sec('Call today — overdue vs their own cadence')) for r in calls: href = links.deeplink('customer_data', 'customer', r['pid'], src='digest') meta = (f"{int(r.get('overdue_days') or 0)}d past cycle · " f"est. missed {_m(r.get('est_missed'))}") rows.append(_line(r.get('customer', '?'), href, meta)) if risk: rows.append(_sec('Win-back — down vs last year')) for r in risk: href = links.deeplink('customer_data', 'customer', r['pid'], src='digest') meta = f"{_m(r.get('at_risk'))} at risk · {r.get('status', '')}" rows.append(_line(r.get('customer', '?'), href, meta)) if y_orders: rows.append(_sec('Yesterday')) # Wave 16: the Sales page is retired (registry row archived) — yesterday's orders now # link to the Customer grid, the surface that carries the book. A link to an archived # page would fall through to the landing and read as a broken link. rows.append(_line(f"{y_orders} orders · {_m(y_rev)}", links.deeplink('customer_data', src='digest'))) scope = f"{agent}'s book" if agent else 'the whole book' list_url = links.deeplink('customer_data', src='digest') html_body = f"""
Your book this morning
{t.strftime('%A, %B %d')} · {scope}
{''.join(rows)}
Open your Customer List →
Internal daily digest from Loopable. Sent only on days with something to act on.
""" subject = f"Your book this morning — {n} to act on · {t.strftime('%b %d')}" return subject, html_body, n def send_one(user, odoo=None, override_to=None): """Build + queue one digest. Returns a short result string (never raises past itself).""" to = override_to or user.get('email') if not to: return 'no-email' subject, body, n = build(user) if n == 0: return 'empty-skip' if not cs.safe_recipient_ok(to): return 'safe-mode-blocked' odoo = odoo or cs.Odoo() mid = odoo.queue_mail({ 'subject': subject, 'body_html': body, 'email_to': to, 'email_from': cs.SENDER_DISPLAY, 'reply_to': cs.REPLY_TO, 'mail_server_id': cs.ROYAL_MAIL_SERVER_ID, 'author_id': cs.ROYAL_AUTHOR_ID, 'state': 'outgoing', 'auto_delete': False, # This Odoo runs the custom rt_multi_dba module whose mail-create hook browses # env[mail.model].browse(mail.res_id) — a mail WITHOUT model/res_id crashes with # KeyError: False (verified 2026-07-05). Anchor internal mail on the house partner # (Royal Imports, cs.ROYAL_AUTHOR_ID) so nothing lands in a CUSTOMER's chatter. 'model': 'res.partner', 'res_id': cs.ROYAL_AUTHOR_ID, }) return f'queued:{mid}' def run_due(force_user=None, override_to=None): """The ticker entry point. Normal path: after UTC_HOUR on weekdays, send each active emailed user their digest at most once per day (dedup via digest_log, including empty-skips). force_user bypasses the schedule/dedup (the 'Send my digest now' button) but NEVER the SAFE_MODE guardrail; forced sends are not logged (the 7am run still runs).""" if not store.available(): return {} now = dt.datetime.utcnow() today = now.date().isoformat() if force_user is None: if not ENABLED: return {} if WEEKDAYS_ONLY and now.weekday() >= 5: return {} if now.hour < UTC_HOUR: return {} try: reg = users.registry() except Exception: return {} already = (store.get(KEY) or {}).get(today, {}) if force_user is None else {} results = {} odoo = None for un, u in sorted(reg.items()): if force_user is not None and un != force_user: continue if not u.get('active', True) or not u.get('email'): continue if force_user is None and un in already: continue pub = users._public(un, u) try: if odoo is None: odoo = cs.Odoo() results[un] = send_one(pub, odoo, override_to=override_to) except cs.SafeModeBlocked: results[un] = 'safe-mode-blocked' except Exception as e: results[un] = f'error:{str(e)[:80]}' if force_user is None and results: stamp = now.strftime('%H:%M') def _rec(d): day = d.setdefault(today, {}) for un, r in results.items(): day[un] = {'at': stamp, 'result': r} return d try: store.update(KEY, _rec) except Exception: pass return results def today_log(): return (store.get(KEY) or {}).get(dt.date.today().isoformat(), {})