#!/usr/bin/env python3
"""Résumé hebdomadaire « messages non lus » (CDC n° 13 suite, § 6.4 — décisions du 17/10/2026) : UN email par membre et par semaine, LE LUNDI MATIN (heure locale du membre), qui regroupe TOUTES ses conversations.
Jour et heure, fuseaux, délai, regroupement, préférences, heures calmes, blocage, texte jamais reproduit, reprise après panne, verrou, script cron. Les passages se font à un instant SIMULÉ (un lundi) : le lundi 19/10/2026 est un lundi."""
import datetime, json, os, subprocess, sys, tempfile, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib import ROOT, call, mk_user, ok, reset_rate_limits, sql, summary

SINK = os.path.join(tempfile.gettempdir(), 'digest.jsonl')
def setting(k, v): sql(f"INSERT INTO messaging_settings (setting_key, setting_value) VALUES ('{k}', '{json.dumps(v)}') ON DUPLICATE KEY UPDATE setting_value = '{json.dumps(v)}'")
def unset(k): sql(f"DELETE FROM messaging_settings WHERE setting_key = '{k}'")
def cnt(q): return int(sql(q))
def follow(a, b): sql(f"INSERT IGNORE INTO member_follows (follower_id, followed_id) VALUES ('{a}', '{b}')")
def wipe():
    p = "'e\\_%'"
    sql(f"DELETE FROM messages WHERE user_id LIKE {p}; DELETE FROM message_requests WHERE sender_id LIKE {p}; DELETE FROM conversation_members WHERE user_id LIKE {p}; DELETE FROM conversations WHERE created_by LIKE {p}; DELETE FROM message_digest_state WHERE user_id LIKE {p}")
    sql(f"DELETE FROM notifications WHERE user_id LIKE {p} OR actor_id LIKE {p}; DELETE FROM notification_prefs WHERE user_id LIKE {p}; DELETE FROM notification_settings WHERE user_id LIKE {p}; DELETE FROM member_follows WHERE follower_id LIKE {p} OR followed_id LIKE {p}")
MON = '2026-10-19 09:30:00'          # un lundi, 09:30 UTC = 11:30 à Paris (heure d'été) : le bon moment par défaut
def run(mail_ok=True, now=MON):
    open(SINK, 'w').close()
    php = '$GLOBALS["COLLAB_MAILER"] = function ($to, $subject, $html) { file_put_contents(getenv("DIGEST_SINK"), json_encode(["to" => $to, "subject" => $subject, "html" => $html], JSON_UNESCAPED_UNICODE) . "\\n", FILE_APPEND); return getenv("DIGEST_OK") !== "0"; }; echo json_encode(message_digest_run(db(), new DateTimeImmutable(getenv("DIGEST_NOW"), new DateTimeZone("UTC"))));'
    r = subprocess.run(['php', '-d', 'display_errors=0', '-r', '$_SERVER["REQUEST_METHOD"]="GET"; require "api/_message_digest.php"; ' + php], capture_output=True, text=True, cwd=ROOT, env=dict(os.environ, DIGEST_SINK=SINK, DIGEST_OK='1' if mail_ok else '0', DIGEST_NOW=now))
    res = json.loads(r.stdout.strip().splitlines()[-1]) if r.stdout.strip() else {'error': r.stderr[:200]}
    return res, [json.loads(l) for l in open(SINK, encoding='utf-8').read().split('\n') if l.strip()]
def age(uid, minutes): sql(f"UPDATE messages SET created_at = created_at - INTERVAL {minutes} MINUTE WHERE user_id = '{uid}'")
def user(uid, first, last, lang='fr'):
    mk_user(uid, 'client_essentiel', first, last); sql(f"UPDATE users SET created_at = NOW() - INTERVAL 60 DAY, lang = '{lang}' WHERE id = '{uid}'")
def pair(tag, lang='fr', direct=True, sfirst='Sam'):
    s, r = f'e_s{tag}', f'e_r{tag}'; user(s, sfirst, f'S{tag}', lang); user(r, 'Rita', f'R{tag}', lang)
    if direct: follow(r, s)
    return s, r
def sender(r, tag, first, lang='fr'):
    s = f'e_{first.lower()}{tag}'; user(s, first, f'X{tag}', lang); follow(r, s); return s
def say(s, r, text='Texte secret du message', n=1):
    reset_rate_limits(); st, d, _ = call('POST', s, '/conversations', {'user_id': r, 'body': text}); assert st in (200, 201), (st, d); cid = d['conversation_id']
    for i in range(n - 1): assert call('POST', s, f'/conversations/{cid}/messages', {'body': f'{text} {i}'})[0] == 201
    return cid
def state(r): return cnt(f"SELECT COUNT(*) FROM message_digest_state WHERE user_id = '{r}'")
def covered(cid, r): return cnt(f"SELECT last_emailed_message_id FROM conversation_members WHERE conversation_id = {cid} AND user_id = '{r}'")
def last(cid): return cnt(f"SELECT MAX(id) FROM messages WHERE conversation_id = {cid}")
def section(t): print('\n■ ' + t); reset_rate_limits()
SUB1 = 'Vous avez 1 message non lu sur Uluana'

wipe(); sql("DELETE FROM users WHERE id LIKE 'e\\_%'")
for k in ('unread_email_enabled', 'unread_email_delay_min', 'unread_email_weekday', 'unread_email_hour', 'messages_per_minute'): unset(k)
setting('messages_per_minute', 100)

section("Réglages et structure")
mk_user('e_admin', 'admin', 'Adm', 'In'); S = {x['key']: x for x in call('GET', 'e_admin', '/admin-messaging-settings')[1]['settings']}
ok(S['unread_email_weekday']['default'] == 'monday' and S['unread_email_weekday']['values'] == ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] and S['unread_email_hour']['default'] == 9 and (S['unread_email_hour']['min'], S['unread_email_hour']['max']) == (0, 23), 'réglages du résumé : jour (lundi par défaut) et heure (9 h par défaut, de 0 à 23)')
ok('unread_email_max_per_conversation_per_hour' not in S and 'unread_email_interval_days' not in S, 'les anciens réglages (plafond par conversation et par heure, intervalle en jours) n\'existent plus : pas de réglage sans effet')
ok(cnt("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'message_digest_state'") == 2 and cnt("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'message_email_log'") == 0, 'la table d\'état ne contient que (membre, date) — aucun texte — et l\'ancien journal par conversation a disparu')

section("Un résumé : délai, contenu, une seule fois")
s, r = pair('a'); cid = say(s, r); res, m = run(); ok(res['sent'] == 0 and not m, 'message tout neuf : aucun email')
age(s, 14); res, m = run(); ok(res['sent'] == 0 and not m, 'non lu depuis 14 minutes (délai : 15) : aucun email')
age(s, 2); res, m = run(); ok(res['sent'] == 1 and len(m) == 1 and m[0]['to'] == f'{r}@test.test' and m[0]['subject'] == SUB1, 'non lu depuis 16 minutes : UN email, à l\'adresse du destinataire, « Vous avez 1 message non lu sur Uluana »')
h = m[0]['html']; ok('Sam S.' in h and f'/messagerie?conversation={cid}' in h and 'Cette conversation vous attend' in h and 'Ouvrir mes messages' in h and h.count('/messagerie') == 2, 'le corps nomme l\'expéditeur, lie CETTE conversation, et propose « Ouvrir mes messages »')
ok('Texte secret du message' not in h and 'Texte secret du message' not in m[0]['subject'] and f'{s}@test.test' not in h and 'ne sont pas reproduits' in h, 'le TEXTE du message n\'est nulle part, l\'adresse de l\'expéditeur non plus ; le corps explique que les messages ne sont pas reproduits')
ok('une fois par semaine, le lundi' in h and 'Notifications' in h, 'le pied de page dit « une fois par semaine, le lundi » et où le désactiver')
ok(state(r) == 1 and covered(cid, r) == last(cid) and state(s) == 0, 'le membre est daté, le message est marqué comme signalé ; l\'expéditeur n\'est pas concerné')
res, m = run(); ok(res['sent'] == 0 and not m, 'deuxième passage : RIEN de plus')

section("Regroupement : toutes les conversations dans UN email")
r = 'e_rb'; user(r, 'Rita', 'Rb'); sa, sb, sc = sender(r, 'b', 'Ana'), sender(r, 'b', 'Ben'), sender(r, 'b', 'Cleo'); ca = say(sa, r, n=2); cb = say(sb, r, n=1); cc = say(sc, r, n=3)
for x in (sa, sb, sc): age(x, 20)
res, m = run(); h = m[0]['html'] if m else ''
ok(res['sent'] == 1 and len(m) == 1 and m[0]['subject'] == 'Vous avez 6 messages non lus sur Uluana', '3 conversations (2 + 1 + 3 messages) : UN SEUL email, « Vous avez 6 messages non lus »')
ok('Ces 3 conversations vous attendent' in h and 'Ana' in h and 'Ben' in h and 'Cleo' in h and all(f'/messagerie?conversation={c}' in h for c in (ca, cb, cc)), 'le corps liste les trois expéditeurs, chacun avec le lien de SA conversation')
ok('2 messages' in h and '1 message<' in h.replace('1 message ', '1 message<') and '3 messages' in h, 'avec le nombre de messages de chacune (2, 1, 3)')
ok(h.index('Cleo') < h.index('Ben') < h.index('Ana'), 'la conversation la plus récente en premier')
ok(all(covered(c, r) == last(c) for c in (ca, cb, cc)) and state(r) == 1, 'toutes les conversations sont marquées comme signalées, le membre daté UNE fois')
res, m = run(); ok(res['sent'] == 0 and not m, 'passage suivant : rien')
s1, r1 = pair('c1'); s2, r2 = pair('c2'); say(s1, r1); say(s2, r2); age(s1, 20); age(s2, 20); res, m = run()
ok(res['sent'] == 2 and {x['to'] for x in m} == {f'{r1}@test.test', f'{r2}@test.test'} and all((s1 in x['html'] or 'Sam' in x['html']) for x in m), 'deux membres : un email CHACUN')
ok(f'/messagerie?conversation={cnt(f"SELECT conversation_id FROM conversation_members WHERE user_id = {chr(39)}{r1}{chr(39)}")}' in [x for x in m if x['to'] == f'{r1}@test.test'][0]['html'] and f'{r2}@test.test' not in json.dumps([x for x in m if x['to'] == f'{r1}@test.test']), 'et chacun ne voit que SES conversations')
r = 'e_rf'; user(r, 'Rita', 'Rf'); cids = []
for i in range(12): sx = sender(r, 'f', f'Zoe{chr(97 + i)}'); cids.append(say(sx, r)); age(sx, 20)
res, m = run(); h = m[0]['html'] if m else ''; ok(res['sent'] == 1 and m[0]['subject'] == 'Vous avez 12 messages non lus sur Uluana' and h.count('/messagerie?conversation=') == 10 and '… et 2 autres conversations.' in h and 'Ces 12 conversations vous attendent' in h, '12 conversations : le résumé en nomme 10, puis « … et 2 autres conversations. », et le total est juste')
ok(all(covered(c, r) == last(c) for c in cids), 'les 12 sont pourtant toutes marquées comme signalées')

section("Lundi matin, à l'heure du membre")
s, r = pair('m1'); say(s, r); age(s, 20)
for when, why in (('2026-10-20 09:30:00', 'mardi'), ('2026-10-18 09:30:00', 'dimanche'), ('2026-10-19 06:59:00', 'lundi 8 h 59 à Paris (avant 9 h)'), ('2026-10-19 22:30:00', 'lundi 22 h 30 UTC, c\'est déjà mardi 0 h 30 à Paris')):
    res, m = run(now=when); ok(res['sent'] == 0 and not m and 'not_due' in res, f'{why} : aucun email')
res, m = run(now='2026-10-19 07:00:00'); ok(res['sent'] == 1 and len(m) == 1, 'lundi 9 h 00 à Paris (07:00 UTC, heure d\'été) : le résumé part')
s, r = pair('m5'); say(s, r); age(s, 20); res, m = run(now='2026-10-19 20:00:00'); ok(res['sent'] == 1, 'lundi soir (22 h à Paris) : si le cron a manqué la matinée, le résumé part quand même ce lundi-là')
s, r = pair('m2'); s2, r2 = pair('m3'); say(s, r); say(s2, r2); age(s, 20); age(s2, 20)
sql(f"INSERT INTO notification_settings (user_id, quiet_enabled, quiet_start, quiet_end, timezone) VALUES ('{r}', 0, '22:00', '08:00', 'America/New_York')")
res, m = run(now='2026-10-19 10:00:00'); ok(res['sent'] == 1 and m[0]['to'] == f'{r2}@test.test', 'lundi 10 h UTC : il est 12 h à Paris (le moment) mais 6 h à New York (pas encore) : SEUL le membre parisien reçoit son résumé')
res, m = run(now='2026-10-19 14:00:00'); ok(res['sent'] == 1 and m[0]['to'] == f'{r}@test.test', 'à 14 h UTC il est 10 h à New York : son résumé part (celui de Paris est déjà parti : pas de doublon)')
s, r = pair('m4'); say(s, r); age(s, 20); sql(f"INSERT INTO notification_settings (user_id, quiet_enabled, quiet_start, quiet_end, timezone) VALUES ('{r}', 0, '22:00', '08:00', 'Mars/Phobos')")
res, m = run(now='2026-10-20 09:30:00'); ok(res['sent'] == 0, 'fuseau inconnu en base : traité comme Paris, donc rien le mardi'); res, m = run(now=MON); ok(res['sent'] == 1, 'et le lundi matin il reçoit son résumé à l\'heure de Paris (mieux que de n\'en recevoir jamais)')
setting('unread_email_hour', 0); s, r = pair('m8'); say(s, r); age(s, 20)
res, m = run(now='2026-10-19 22:30:00'); ok(res['sent'] == 0 and not m, 'heure réglée à 0 : lundi 22 h 30 UTC est déjà MARDI (0 h 30) à Paris : ce n\'est plus lundi, rien')
res, m = run(now='2026-10-18 22:30:00'); ok(res['sent'] == 1, 'et dimanche 22 h 30 UTC est déjà LUNDI (0 h 30) à Paris : le résumé part — le jour se compte à l\'heure du membre, pas en UTC'); unset('unread_email_hour')
s, r = pair('m7'); say(s, r); age(s, 20); res, m = run(now='2026-10-26 07:30:00'); ok(res['sent'] == 0, 'après le changement d\'heure (Paris = UTC+1) : 07:30 UTC = 8 h 30 à Paris, pas encore'); res, m = run(now='2026-10-26 08:00:00'); ok(res['sent'] == 1, '08:00 UTC = 9 h à Paris : le résumé part (le décalage de l\'heure d\'hiver est géré)')
s, r = pair('d'); cid = say(s, r); age(s, 20); res, m = run(); ok(res['sent'] == 1, 'un résumé le lundi matin'); call('POST', s, f'/conversations/{cid}/messages', {'body': 'Suite'}); age(s, 20)
res, m = run(now='2026-10-19 15:00:00'); ok(res['sent'] == 0 and not m, 'un nouveau message non lu, plus tard le MÊME lundi : PAS de second email (un seul par semaine)')
ok(covered(cid, r) < last(cid), 'et il n\'est pas marqué : il sera dans le résumé de la semaine suivante')
sql(f"UPDATE message_digest_state SET last_sent_at = UTC_TIMESTAMP() - INTERVAL 5 DAY WHERE user_id = '{r}'"); res, m = run(now='2026-10-26 09:30:00'); ok(res['sent'] == 0 and not m, 'garde-fou : un résumé envoyé il y a moins de 6 jours (heure du cron décalée, mauvais jour réglé puis corrigé…) n\'est jamais doublé')
sql(f"UPDATE message_digest_state SET last_sent_at = UTC_TIMESTAMP() - INTERVAL 7 DAY WHERE user_id = '{r}'"); res, m = run(now='2026-10-26 09:30:00'); ok(res['sent'] == 1 and m[0]['subject'] == SUB1 and '1 message' in m[0]['html'], 'le lundi suivant : le résumé part, et ne parle QUE du nouveau message (« 1 message », pas celui déjà signalé)')
ok(cnt(f"SELECT TIMESTAMPDIFF(MINUTE, last_sent_at, UTC_TIMESTAMP()) FROM message_digest_state WHERE user_id = '{r}'") <= 1, 'la date du dernier résumé est remise à jour')
setting('unread_email_weekday', 'wednesday'); setting('unread_email_hour', 14); s, r = pair('w1'); say(s, r); age(s, 20); s2, r2 = pair('w2', lang='en'); say(s2, r2); age(s2, 20)
res, m = run(); ok(res['sent'] == 0, 'jour réglé sur mercredi : le lundi, rien'); res, m = run(now='2026-10-21 11:59:00'); ok(res['sent'] == 0, 'mercredi 13 h 59 à Paris (heure réglée : 14 h) : pas encore'); res, m = run(now='2026-10-21 12:00:00')
ok(res['sent'] == 2 and {x['to'] for x in m} == {f'{r}@test.test', f'{r2}@test.test'}, 'mercredi 14 h 00 à Paris : les résumés partent')
ok(any('le mercredi' in x['html'] for x in m) and any('on Wednesday' in x['html'] for x in m), 'et le pied de page annonce le bon jour (« le mercredi » / « on Wednesday »)'); unset('unread_email_weekday'); unset('unread_email_hour')

section("Qui est compté, qui ne l'est pas")
s, r = pair('g1'); cid = say(s, r); age(s, 20); call('POST', r, f'/conversations/{cid}/read', {}); res, m = run(); ok(res['sent'] == 0 and not m, 'message LU avant le passage : aucun email')
s, r = pair('g2'); cid = say(s, r, 'Question'); call('POST', r, f'/conversations/{cid}/messages', {'body': 'Réponse'}); age(s, 20); age(r, 20); res, m = run(); ok(res['sent'] == 1 and {x['to'] for x in m} == {f'{s}@test.test'}, 'on répond tout de suite : seul l\'expéditeur initial a un message non lu, ses propres messages ne comptent jamais')
s, r = pair('g3'); cid = say(s, r); age(s, 20); call('PUT', r, f'/conversations/{cid}', {'muted': True}); res, m = run(); ok(res['sent'] == 0 and not m, 'conversation en SOURDINE : aucun email')
s, r = pair('g4'); cid = say(s, r); age(s, 20); call('PUT', r, f'/conversations/{cid}', {'archived': True}); res, m = run(); ok(res['sent'] == 0 and not m, 'conversation ARCHIVÉE : aucun email')
s, r = pair('g5'); cid = say(s, r); age(s, 20); call('DELETE', r, f'/conversations/{cid}'); res, m = run(); ok(res['sent'] == 0 and not m, 'conversation SUPPRIMÉE pour soi : aucun email')
s, r = pair('g6'); cid = say(s, r, 'Ancien'); age(s, 30); call('DELETE', r, f'/conversations/{cid}'); call('POST', s, f'/conversations/{cid}/messages', {'body': 'Nouveau'}); age(s, 20); res, m = run(); ok(res['sent'] == 1 and m[0]['subject'] == SUB1, 'supprimée puis un nouveau message : seul le nouveau est compté (l\'historique effacé ne revient pas)')
s, r = pair('g7', direct=False); cid = say(s, r); age(s, 20); res, m = run(); ok(res['sent'] == 0 and not m, 'DEMANDE de message non acceptée : aucun email (un inconnu ne peut pas faire écrire à quelqu\'un par email)')
call('POST', r, f'/conversations/{cid}/request', {'action': 'accept'}); res, m = run(); ok(res['sent'] == 1 and m[0]['to'] == f'{r}@test.test', 'une fois ACCEPTÉE : le message encore non lu est signalé')
s, r = pair('g8'); cid = say(s, r, n=2); age(s, 20); [call('DELETE', s, f'/conversations/{cid}/messages/{x["id"]}') for x in call('GET', s, f'/conversations/{cid}/messages')[1]['items']]; res, m = run(); ok(res['sent'] == 0 and not m, 'messages SUPPRIMÉS par leur auteur : rien à signaler')
s, r = pair('g9'); cid = say(s, r, n=2); age(s, 20); call('DELETE', s, f'/conversations/{cid}/messages/{call("GET", s, f"/conversations/{cid}/messages")[1]["items"][0]["id"]}'); res, m = run(); ok(res['sent'] == 1 and m[0]['subject'] == SUB1, 'un message supprimé sur deux : un seul est compté')

section("Blocage : on écarte ce qui est écarté, on garde le reste")
r = 'e_rh'; user(r, 'Rita', 'Rh'); sa, sb = sender(r, 'h', 'Ana'), sender(r, 'h', 'Bob'); ca = say(sa, r); cb = say(sb, r); age(sa, 20); age(sb, 20); call('POST', r, '/blocks', {'user_id': sb, 'block': True}); res, m = run(); h = m[0]['html'] if m else ''
ok(res['sent'] == 1 and 'Ana' in h and 'Bob' not in h and 'Cette conversation vous attend' in h and m[0]['subject'] == SUB1, 'un expéditeur bloqué parmi deux : le résumé ne nomme QUE l\'autre, et ne compte que son message')
ok(covered(cb, r) == last(cb), 'la conversation avec le membre bloqué est marquée traitée : elle ne reviendra pas')
call('POST', r, '/blocks', {'user_id': sb, 'block': False}); sql(f"UPDATE message_digest_state SET last_sent_at = UTC_TIMESTAMP() - INTERVAL 8 DAY WHERE user_id = '{r}'"); res, m = run(); ok(res['sent'] == 0 and not m, 'débloqué ensuite : les vieux messages ne reviennent pas par email')
r = 'e_ri'; user(r, 'Rita', 'Ri'); sb = sender(r, 'i', 'Bob'); cb = say(sb, r); age(sb, 20); call('POST', r, '/blocks', {'user_id': sb, 'block': True}); res, m = run(); ok(res['sent'] == 0 and not m and state(r) == 0, 'SEULE conversation avec un membre bloqué : aucun email, et le membre n\'est PAS daté')
sd = sender(r, 'i', 'Dan'); say(sd, r); age(sd, 20); res, m = run(); ok(res['sent'] == 1 and 'Dan' in m[0]['html'], 'un vrai message arrivé ensuite part TOUT DE SUITE (pas de semaine d\'attente perdue)')
s, r = pair('j1'); say(s, r); age(s, 20); call('POST', s, '/blocks', {'user_id': r, 'block': True}); res, m = run(); ok(res['sent'] == 0 and not m, 'le blocage dans l\'AUTRE sens (l\'expéditeur a bloqué) : aucun email non plus')
s, r = pair('j2'); say(s, r); age(s, 20); sql(f"DELETE FROM users WHERE id = '{s}'"); res, m = run(); ok(res['sent'] == 0 and not m, 'compte de l\'expéditeur SUPPRIMÉ : aucun email')
for tag, upd, why in (('k1', "active = 0", 'destinataire inactif'), ('k2', "account_status = 'pending'", 'destinataire en attente de validation'), ('k3', "email = 'pas-un-email'", 'adresse invalide'), ('k4', "email = ''", 'adresse vide'), ('k5', "is_test = 1", 'compte de test (hors environnement de développement)')):
    s, r = pair(tag); say(s, r); age(s, 20); sql(f"UPDATE users SET {upd} WHERE id = '{r}'"); res, m = run(); ok(res['sent'] == 0 and not m, f'{why} : aucun email')

section("Préférences, heures calmes, interrupteur général")
s, r = pair('n'); st, d, _ = call('GET', r, '/notification-prefs'); T = {t['type']: {c['channel']: c['enabled'] for c in t['channels']} for t in d['types']}
ok(T['message'] == {'in_app': True, 'push': True, 'email': True} and all('email' not in T[x] for x in ('mention', 'comment', 'like')), 'préférences : le type « message » propose le canal email, ACTIVÉ par défaut ; les autres types n\'en proposent pas')
cid = say(s, r); age(s, 20); call('PUT', r, '/notification-prefs', {'types': {'message': {'email': False}}}); res, m = run(); ok(res['sent'] == 0 and not m and covered(cid, r) == last(cid), 'email désactivé par le membre : aucun email, et ses messages sont marqués traités')
call('PUT', r, '/notification-prefs', {'types': {'message': {'email': True}}}); res, m = run(); ok(res['sent'] == 0 and not m, 'réactivé ensuite : les VIEUX messages ne déclenchent PAS d\'email (pas de courrier périmé)')
call('POST', s, f'/conversations/{cid}/messages', {'body': 'Frais'}); age(s, 20); res, m = run(); ok(res['sent'] == 1, 'un nouveau message après réactivation : le résumé part')
utc = datetime.datetime.now(datetime.timezone.utc); fmt = lambda d: d.strftime('%H:%M')
s, r = pair('o'); cid = say(s, r); age(s, 20); call('PUT', r, '/notification-prefs', {'quiet': {'enabled': True, 'start': fmt(utc - datetime.timedelta(minutes=30)), 'end': fmt(utc + datetime.timedelta(minutes=30)), 'timezone': 'UTC'}}); res, m = run()
ok(res['sent'] == 0 and not m and covered(cid, r) == 0 and state(r) == 0, 'PENDANT les heures calmes : aucun email, rien n\'est marqué, le membre n\'est pas daté (le résumé partira après)')
call('PUT', r, '/notification-prefs', {'quiet': {'enabled': False}}); res, m = run(); ok(res['sent'] == 1, 'heures calmes terminées : le résumé part')
setting('unread_email_enabled', False); s, r = pair('p'); cid = say(s, r); age(s, 20); res, m = run(); ok(res.get('disabled') is True and res['sent'] == 0 and not m and covered(cid, r) == 0, 'interrupteur général coupé dans /control : rien ne part, rien n\'est marqué (ce qui est resté non lu sera signalé au rallumage)')
setting('unread_email_enabled', True); res, m = run(); ok(res['sent'] == 1, 'rallumé : le résumé part')
setting('unread_email_delay_min', 60); s, r = pair('q'); say(s, r); age(s, 30); res, m = run(); ok(res['sent'] == 0, 'délai réglé à 60 minutes : 30 minutes ne suffisent pas'); age(s, 40); res, m = run(); ok(res['sent'] == 1, 'après 70 minutes : le résumé part'); unset('unread_email_delay_min')

section("Langue et sécurité du contenu")
s, r = pair('l1', lang='en'); say(s, r); age(s, 20); res, m = run(); ok(m[0]['subject'] == 'You have 1 unread message on Uluana' and 'This conversation is waiting for you' in m[0]['html'] and 'Open my messages' in m[0]['html'] and 'not reproduced' in m[0]['html'] and 'once a week, on Monday' in m[0]['html'], 'membre anglophone : objet et corps en anglais')
s, r = pair('l2', lang='en'); say(s, r, n=2); age(s, 20); res, m = run(); ok(m[0]['subject'] == 'You have 2 unread messages on Uluana', 'pluriel en anglais : « You have 2 unread messages »')
s, r = pair('x', sfirst='<script>alert(1)'); say(s, r); age(s, 20); res, m = run(); ok(res['sent'] == 1 and '<script>' not in m[0]['html'] and '&lt;script&gt;' in m[0]['html'], 'nom d\'expéditeur piégé : échappé dans le corps de l\'email, aucune balise exécutable')
ok('script' not in m[0]['subject'] and '\n' not in m[0]['subject'] and '\r' not in m[0]['subject'], 'l\'objet ne contient AUCUN nom (que des nombres) : impossible d\'y injecter un en-tête')

section("Panne d'envoi, verrou, passages simultanés")
s, r = pair('z4'); cid = say(s, r); age(s, 20); res, m = run(mail_ok=False); ok(res['sent'] == 0 and res['failed'] == 1 and len(m) == 1, 'l\'envoi échoue : compté en échec, une tentative faite')
ok(covered(cid, r) == 0 and state(r) == 0, 'et RIEN n\'est marqué ni daté : le résumé sera réessayé')
res, m = run(); ok(res['sent'] == 1 and len(m) == 1, 'au passage suivant (envoi rétabli) : le résumé part, une seule fois')
s, r = pair('z5'); say(s, r); age(s, 20); holder = subprocess.Popen(['mariadb', '-uroot', os.environ.get('ULUANA_TEST_DB', 'uluana_test'), '-e', "SELECT GET_LOCK('uluana_unread_emails', 0); SELECT SLEEP(6);"], stdout=subprocess.DEVNULL); time.sleep(1.5)
res, m = run(); ok(res.get('locked') is True and res['sent'] == 0 and not m, 'un autre passage est en cours (verrou pris) : celui-ci ne fait RIEN, aucun doublon possible')
holder.wait(); res, m = run(); ok(res['sent'] == 1, 'verrou libéré : le passage suivant envoie')

section("Script cron")
cron = open(os.path.join(ROOT, 'cron', 'send_unread_message_emails.php'), encoding='utf-8').read()
ok(cron.index("PHP_SAPI !== 'cli'") < cron.index("require_once __DIR__ . '/../api/_core.php'") and 'toutes les heures' in cron, 'le script refuse tout appel web AVANT de charger quoi que ce soit, et recommande un passage horaire')
s, r = pair('z6'); say(s, r); age(s, 20); o = subprocess.run(['php', 'cron/send_unread_message_emails.php'], capture_output=True, text=True, cwd=ROOT, env=dict(os.environ, DIGEST_SINK=SINK))
ok(o.returncode in (0, 1) and 'envoyés=' in o.stdout and 'lien=' in o.stdout, 'lancé en ligne de commande : un compte rendu (envoyés, échecs, ignorés, lien utilisé) et un code de sortie')
setting('unread_email_enabled', False); o = subprocess.run(['php', 'cron/send_unread_message_emails.php'], capture_output=True, text=True, cwd=ROOT); ok('désactivé' in o.stdout and o.returncode == 0, 'interrupteur coupé : « désactivé dans /control », sortie 0'); unset('unread_email_enabled')

wipe(); sql("DELETE FROM users WHERE id LIKE 'e\\_%'")
for k in ('unread_email_enabled', 'unread_email_delay_min', 'unread_email_weekday', 'unread_email_hour', 'messages_per_minute'): unset(k)
summary()
