mail-notify 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ccmail/__init__.py +1 -0
- ccmail/__main__.py +13 -0
- ccmail/ack.py +178 -0
- ccmail/activity.py +136 -0
- ccmail/auth.py +67 -0
- ccmail/autostart.py +142 -0
- ccmail/chats.py +238 -0
- ccmail/cli.py +536 -0
- ccmail/config.py +161 -0
- ccmail/credstore.py +274 -0
- ccmail/daemon.py +107 -0
- ccmail/db.py +245 -0
- ccmail/diagnostics.py +49 -0
- ccmail/heartbeat.py +105 -0
- ccmail/hookclient.py +112 -0
- ccmail/htmlmail.py +341 -0
- ccmail/i18n.py +80 -0
- ccmail/identity.py +35 -0
- ccmail/inbound.py +453 -0
- ccmail/installer.py +171 -0
- ccmail/launcher.py +73 -0
- ccmail/mailer.py +69 -0
- ccmail/paths.py +111 -0
- ccmail/policy.py +179 -0
- ccmail/projroot.py +84 -0
- ccmail/providers.py +52 -0
- ccmail/redact.py +20 -0
- ccmail/render.py +233 -0
- ccmail/server.py +450 -0
- ccmail/skill/SKILL.md +66 -0
- ccmail/skillinstall.py +58 -0
- ccmail/suggest.py +181 -0
- ccmail/summarize.py +181 -0
- ccmail/tracer.py +82 -0
- ccmail/wakeclient.py +63 -0
- ccmail/web/app.css +581 -0
- ccmail/web/app.js +1909 -0
- ccmail/web/favicon.svg +6 -0
- ccmail/web/index.html +471 -0
- ccmail/web/lang-en-app.js +436 -0
- ccmail/web/lang-en.js +194 -0
- ccmail/web/lang.js +127 -0
- ccmail/worker.py +82 -0
- mail_notify-1.0.0.dist-info/METADATA +388 -0
- mail_notify-1.0.0.dist-info/RECORD +49 -0
- mail_notify-1.0.0.dist-info/WHEEL +5 -0
- mail_notify-1.0.0.dist-info/entry_points.txt +3 -0
- mail_notify-1.0.0.dist-info/licenses/LICENSE +21 -0
- mail_notify-1.0.0.dist-info/top_level.txt +1 -0
ccmail/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
ccmail/__main__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
if len(sys.argv) > 1 and sys.argv[1] == "hook":
|
|
4
|
+
# fast path: avoid importing the CLI machinery on every hook call
|
|
5
|
+
from .hookclient import main as _main
|
|
6
|
+
elif len(sys.argv) > 1 and sys.argv[1] == "wake":
|
|
7
|
+
from .wakeclient import main as _main
|
|
8
|
+
elif len(sys.argv) > 1 and sys.argv[1] == "trace-hook":
|
|
9
|
+
from .tracer import main as _main
|
|
10
|
+
else:
|
|
11
|
+
from .cli import main as _main
|
|
12
|
+
|
|
13
|
+
sys.exit(_main())
|
ccmail/ack.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Instant acknowledgement mail for an accepted e-mail reply: what was received, where it is queued and an
|
|
2
|
+
indicative time. The estimate comes from the observed length of earlier turns of the same chat (median of
|
|
3
|
+
the last ones) and from the session's state; it is an indication, not a promise."""
|
|
4
|
+
import statistics
|
|
5
|
+
|
|
6
|
+
from . import render
|
|
7
|
+
from .i18n import pick
|
|
8
|
+
|
|
9
|
+
LISTEN_PERIOD_S = 3 * 3600 # keep in step with server.LISTEN_PERIOD_S: how long an idle chat is listening
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _fmt(sec: float, settings=None) -> str:
|
|
13
|
+
T = lambda it, en: pick(settings, it, en) # noqa: E731
|
|
14
|
+
sec = max(0, int(sec))
|
|
15
|
+
if sec < 90:
|
|
16
|
+
return T("meno di 2 minuti", "less than 2 minutes") if sec >= 30 else T("pochi secondi", "a few seconds")
|
|
17
|
+
m = round(sec / 60)
|
|
18
|
+
if m < 60:
|
|
19
|
+
return T(f"circa {m} min", f"about {m} min")
|
|
20
|
+
h, rem = divmod(m, 60)
|
|
21
|
+
return T(f"circa {h} h", f"about {h} h") + (f" {rem:02d} min" if rem else "")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def typical_turn_s(db, session_id, project_dir=None):
|
|
25
|
+
"""Median duration of the last completed turns of this chat (or project), or None if unknown."""
|
|
26
|
+
def durations(rows):
|
|
27
|
+
out, start = [], None
|
|
28
|
+
for r in rows:
|
|
29
|
+
if r["event"] == "UserPromptSubmit":
|
|
30
|
+
start = r["received_at"]
|
|
31
|
+
elif r["event"] == "Stop" and start is not None:
|
|
32
|
+
d = r["received_at"] - start
|
|
33
|
+
if 5 <= d <= 4 * 3600:
|
|
34
|
+
out.append(d)
|
|
35
|
+
start = None
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
rows = db.q("SELECT event, received_at FROM events WHERE session_id=? AND event IN ('UserPromptSubmit','Stop') "
|
|
39
|
+
"ORDER BY id DESC LIMIT 60", (session_id,))
|
|
40
|
+
d = durations(list(reversed(rows)))[-8:]
|
|
41
|
+
if len(d) < 2 and project_dir:
|
|
42
|
+
rows = db.q("SELECT e.event, e.received_at FROM events e JOIN sessions s ON s.session_id=e.session_id "
|
|
43
|
+
"WHERE s.project_dir=? AND e.event IN ('UserPromptSubmit','Stop') ORDER BY e.id DESC LIMIT 80",
|
|
44
|
+
(project_dir,))
|
|
45
|
+
d = durations(list(reversed(rows)))[-8:]
|
|
46
|
+
return statistics.median(d) if d else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def eta(db, session_id, now, parked=None, settings=None) -> dict:
|
|
50
|
+
"""{'state': str, 'when': str, 'typical': str|None} describing where a message for `session_id` will land.
|
|
51
|
+
|
|
52
|
+
`parked`: True/False if the daemon knows whether that chat's wake hook is really waiting (so a delivery
|
|
53
|
+
"within seconds" is only promised when it is true); None when unknown."""
|
|
54
|
+
T = lambda it, en: pick(settings, it, en) # noqa: E731
|
|
55
|
+
s = db.one("SELECT * FROM sessions WHERE session_id=?", (session_id,))
|
|
56
|
+
typical = typical_turn_s(db, session_id, (s or {}).get("project_dir"))
|
|
57
|
+
typ = (T(f"Di solito i turni di questa chat durano {_fmt(typical, settings)}.",
|
|
58
|
+
f"Turns in this chat usually take {_fmt(typical, settings)}.") if typical else None)
|
|
59
|
+
if not s:
|
|
60
|
+
return {"state": T("sessione non più nota", "session no longer known"), "typical": typ,
|
|
61
|
+
"when": T("Resta in coda (fino a 24 ore) e arriva al prossimo evento di quella chat.",
|
|
62
|
+
"It stays queued (up to 24 hours) and arrives at that chat's next event.")}
|
|
63
|
+
state, last = s.get("state"), s.get("last_event_at") or now
|
|
64
|
+
if state == "ended":
|
|
65
|
+
return {"state": T("la sessione è chiusa", "the session is closed"), "typical": typ,
|
|
66
|
+
"when": T("Resta in coda (fino a 24 ore) e arriva quando la chat riparte.",
|
|
67
|
+
"It stays queued (up to 24 hours) and arrives when the chat starts again.")}
|
|
68
|
+
if state == "active":
|
|
69
|
+
started = s.get("turn_start_at") or last
|
|
70
|
+
el = max(0, now - started)
|
|
71
|
+
# in-turn delivery: it rides on Claude's next tool call, it does not wait for the end of the turn
|
|
72
|
+
when = T("Claude lo legge al suo prossimo passo (dopo il prossimo strumento che usa): di solito entro pochi "
|
|
73
|
+
"secondi; se sta eseguendo un comando lungo, quando quel comando finisce.",
|
|
74
|
+
"Claude reads it at its next step (after the next tool it uses): usually within a few seconds; "
|
|
75
|
+
"if it is running a long command, when that command ends.")
|
|
76
|
+
if typical and el >= typical:
|
|
77
|
+
when += T(" Il turno è già più lungo del solito.", " This turn is already longer than usual.")
|
|
78
|
+
return {"state": T(f"Claude sta lavorando da {_fmt(el, settings)}",
|
|
79
|
+
f"Claude has been working for {_fmt(el, settings)}"), "when": when, "typical": typ}
|
|
80
|
+
# idle: the wake hook is parked for it
|
|
81
|
+
if now - last > LISTEN_PERIOD_S:
|
|
82
|
+
return {"state": T(f"la chat è ferma da {_fmt(now - last, settings)}",
|
|
83
|
+
f"the chat has been idle for {_fmt(now - last, settings)}"), "typical": typ,
|
|
84
|
+
"when": T("Arriva al prossimo evento della chat (la sua attesa automatica era già scaduta).",
|
|
85
|
+
"It arrives at the chat's next event (its automatic wait had already expired).")}
|
|
86
|
+
st = (T("Claude è in attesa di processi in background", "Claude is waiting for background processes")
|
|
87
|
+
if s.get("bg_tasks") else T("Claude è fermo", "Claude is idle"))
|
|
88
|
+
if parked is False:
|
|
89
|
+
# honest: the message cannot be pushed because nothing is waiting for it in that chat
|
|
90
|
+
return {"state": st + T(", ma la chat non è in ascolto", ", but the chat is not listening"), "typical": typ,
|
|
91
|
+
"when": T("Resta in coda e arriva al prossimo evento di quella chat (appena scrivi lì o Claude si "
|
|
92
|
+
"ferma). Il suo hook di risveglio non risulta attivo: succede se la chat è stata aperta "
|
|
93
|
+
"prima dell'installazione degli hook o dopo un riavvio del demone.",
|
|
94
|
+
"It stays queued and arrives at that chat's next event (as soon as you write there or "
|
|
95
|
+
"Claude stops). Its wake-up hook is not active: this happens when the chat was opened "
|
|
96
|
+
"before the hooks were installed or after a daemon restart.")}
|
|
97
|
+
if parked is None:
|
|
98
|
+
return {"state": st, "typical": typ,
|
|
99
|
+
"when": T("Consegna immediata se la chat è in ascolto; altrimenti al prossimo evento della chat.",
|
|
100
|
+
"Immediate delivery if the chat is listening; otherwise at the chat's next event.")}
|
|
101
|
+
return {"state": st + T(" e in ascolto", " and listening"), "typical": typ,
|
|
102
|
+
"when": T("Consegnato entro pochi secondi: Claude riparte da solo e poi risponde.",
|
|
103
|
+
"Delivered within a few seconds: Claude restarts by itself and then answers.")}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_claude_ack(db, settings, session_id, text, now, project="", chat_title="", parked=None, ref=None):
|
|
107
|
+
T = lambda it, en: pick(settings, it, en) # noqa: E731
|
|
108
|
+
e = eta(db, session_id, now, parked, settings)
|
|
109
|
+
lines = render.header(project, settings, session_id, chat_title)
|
|
110
|
+
parts = ["\n".join(lines),
|
|
111
|
+
render._section(T("Ricevuto", "Received"),
|
|
112
|
+
T("La tua risposta è stata verificata e messa in coda per Claude:",
|
|
113
|
+
"Your reply was verified and queued for Claude:"),
|
|
114
|
+
" «" + render.clean_line(text, 160) + "»"),
|
|
115
|
+
render._section(T("Quando arriva", "When it arrives"),
|
|
116
|
+
T(f"Stato: {e['state']}.", f"State: {e['state']}."), "", e["when"])]
|
|
117
|
+
if e["typical"]:
|
|
118
|
+
parts[-1] += "\n\n" + e["typical"]
|
|
119
|
+
parts.append(render._section(T("Poi", "Next"),
|
|
120
|
+
T("Quando Claude finisce, ricevi la sua email di fine turno (se supera le "
|
|
121
|
+
"soglie impostate).",
|
|
122
|
+
"When Claude finishes you receive its end-of-turn e-mail (if it passes the "
|
|
123
|
+
"thresholds you set).")))
|
|
124
|
+
if ref: # replying to this confirmation works exactly like replying to the original notification
|
|
125
|
+
parts.append(render.manage_section(ref, bool(settings.get("inbound_to_claude")), settings))
|
|
126
|
+
return (T(f"[Claude Code] {render.clean_line(project or '?', 60)}: risposta ricevuta e messa in coda",
|
|
127
|
+
f"[Claude Code] {render.clean_line(project or '?', 60)}: reply received and queued"),
|
|
128
|
+
"\n\n".join(parts) + "\n")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_rejection(settings, reason: str, typed: str, project_name: str = "", ref=None):
|
|
132
|
+
"""Mail in the usual layout whose OUTCOME says that a verified reply was discarded, and why."""
|
|
133
|
+
from .redact import redact
|
|
134
|
+
T = lambda it, en: pick(settings, it, en) # noqa: E731
|
|
135
|
+
why, fix = {
|
|
136
|
+
"no_session": (T("non so a quale chat inoltrarla: la email a cui hai risposto non è legata a una chat "
|
|
137
|
+
"(o la chat non c'è più).",
|
|
138
|
+
"I do not know which chat to forward it to: the e-mail you replied to is not tied to a "
|
|
139
|
+
"chat (or the chat no longer exists)."),
|
|
140
|
+
T("Rispondi a una notifica recente della chat, oppure scrivi direttamente in chat.",
|
|
141
|
+
"Reply to a recent notification of the chat, or write in the chat directly.")),
|
|
142
|
+
"no_ref": (T("la email non contiene un riferimento «Rif: ccm-…» valido, oppure è più vecchio di 14 giorni.",
|
|
143
|
+
"the e-mail has no valid «Rif: ccm-…» reference, or it is older than 14 days."),
|
|
144
|
+
T("Rispondi a una email recente di ccmail senza cancellare la riga «Rif:».",
|
|
145
|
+
"Reply to a recent ccmail e-mail without deleting the «Rif:» line.")),
|
|
146
|
+
"to_claude_off": (T("la consegna delle risposte a Claude è disattivata nelle impostazioni.",
|
|
147
|
+
"delivering replies to Claude is switched off in the settings."),
|
|
148
|
+
T("Attiva «Le risposte arrivano a Claude» nell'app web, oppure usa OFF / ON / PAUSA.",
|
|
149
|
+
"Turn on «Replies reach Claude» in the web app, or use OFF / ON / PAUSE.")),
|
|
150
|
+
}.get(reason, (reason, ""))
|
|
151
|
+
name = render.clean_line(project_name or "ccmail", 60)
|
|
152
|
+
ctx = [T(f"Progetto: {name}", f"Project: {name}"), f"Account: {render.account_label(settings)}"]
|
|
153
|
+
outcome = [T("Risposta scartata: ", "Reply discarded: ") + why]
|
|
154
|
+
if typed:
|
|
155
|
+
outcome += ["", T("Il tuo testo:", "Your text:"), "«" + render.clean_line(redact(typed), 300) + "»"]
|
|
156
|
+
parts = ["\n".join(ctx), render._section(T("Esito", "Outcome"), *outcome),
|
|
157
|
+
render._section(T("Cosa fare", "What to do"), fix)]
|
|
158
|
+
if ref:
|
|
159
|
+
parts.append(render.manage_section(ref, bool(settings.get("inbound_to_claude")), settings))
|
|
160
|
+
return (T(f"[Claude Code] {name}: risposta scartata", f"[Claude Code] {name}: reply discarded"),
|
|
161
|
+
"\n\n".join(parts) + "\n")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_command_ack(settings, command: str, project_name: str, detail: str, ref=None):
|
|
165
|
+
T = lambda it, en: pick(settings, it, en) # noqa: E731
|
|
166
|
+
revert = {"off": T("Per riaccenderlo rispondi ON a una email di ccmail, oppure usa la webapp.",
|
|
167
|
+
"To turn it back on reply ON to a ccmail e-mail, or use the web app."),
|
|
168
|
+
"on": T("Le notifiche di questo progetto sono di nuovo attive.",
|
|
169
|
+
"Notifications for this project are active again."),
|
|
170
|
+
"pause": T("Passato il tempo indicato le notifiche riprendono da sole.",
|
|
171
|
+
"After the indicated time notifications resume by themselves.")}.get(command, "")
|
|
172
|
+
name = render.clean_line(project_name or "ccmail", 60)
|
|
173
|
+
parts = ["\n".join([T(f"Progetto: {name}", f"Project: {name}"), f"Account: {render.account_label(settings)}"]),
|
|
174
|
+
render._section(T("Comando eseguito", "Command executed"), detail, "", revert)]
|
|
175
|
+
if ref:
|
|
176
|
+
parts.append(render.manage_section(ref, bool(settings.get("inbound_to_claude")), settings))
|
|
177
|
+
return T(f"[Claude Code] {name}: comando eseguito", f"[Claude Code] {name}: command executed"), \
|
|
178
|
+
"\n\n".join(parts) + "\n"
|
ccmail/activity.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Read model for the Attività view: projects -> chats -> latest session, with an honest state label.
|
|
2
|
+
|
|
3
|
+
States never claim more than the evidence supports (a stale 'idle' is not "waiting for you").
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .db import leaf_name, norm_dir
|
|
9
|
+
|
|
10
|
+
WORKING_QUIET_S = 5 * 60 # working but no event for this long: say so
|
|
11
|
+
WORKING_STALE_S = 30 * 60 # 'active' without any event for this long: no longer trust it
|
|
12
|
+
QUIET_AFTER_S = 6 * 3600 # idle for this long: "inattiva", not "in attesa di te"
|
|
13
|
+
APP_AHEAD_S = 120 # the app saw activity this much newer than our last hook event
|
|
14
|
+
PSEUDO_CHAT_WINDOW_S = 7 * 86400
|
|
15
|
+
PSEUDO_CHAT_LIMIT = 15
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _mins(sec) -> str:
|
|
19
|
+
m = int(sec // 60)
|
|
20
|
+
return f"{m} min" if m < 60 else f"{m // 60} h {m % 60:02d} min"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def display_state(sess, chat, now):
|
|
24
|
+
"""-> (code, label, detail). `sess` is the chat's latest session row or None."""
|
|
25
|
+
if chat and chat.get("archived"):
|
|
26
|
+
return "archived", "Archiviata", "La chat è archiviata nell'app."
|
|
27
|
+
if not sess:
|
|
28
|
+
return "none", "Nessun evento ricevuto", "ccmail non ha ancora visto eventi di questa chat."
|
|
29
|
+
last = sess.get("last_event_at") or 0
|
|
30
|
+
age = now - last
|
|
31
|
+
app_last = (chat or {}).get("last_activity") or 0
|
|
32
|
+
st = sess.get("state")
|
|
33
|
+
if st == "ended":
|
|
34
|
+
return "ended", "Terminata", "La sessione è stata chiusa."
|
|
35
|
+
if st == "active":
|
|
36
|
+
if age > WORKING_STALE_S:
|
|
37
|
+
return "stale", "Nessun segnale", f"Risultava al lavoro ma non arriva nulla da {_mins(age)}."
|
|
38
|
+
if age > WORKING_QUIET_S:
|
|
39
|
+
return "working", "Al lavoro", f"Nessun evento da {_mins(age)}: forse sta eseguendo un comando lungo."
|
|
40
|
+
return "working", "Al lavoro", "Claude sta lavorando."
|
|
41
|
+
# idle: Claude finished its turn
|
|
42
|
+
if app_last - last > APP_AHEAD_S:
|
|
43
|
+
return ("unknown", "Stato non certo",
|
|
44
|
+
"L'app ha registrato attività più recente di quella vista dagli hook (hook non ricevuti?).")
|
|
45
|
+
if sess.get("heartbeat_pending"):
|
|
46
|
+
return "checking", "Controllo periodico", "Claude è stato risvegliato per controllare i processi."
|
|
47
|
+
if sess.get("bg_tasks") and age > QUIET_AFTER_S: # old knowledge: do not claim the tasks still run
|
|
48
|
+
return ("quiet", "Inattiva", f"Ferma da {_mins(age)}; l'ultimo stato noto indicava "
|
|
49
|
+
f"{sess['bg_tasks']} processo/i in background.")
|
|
50
|
+
if sess.get("bg_tasks"):
|
|
51
|
+
n = sess["bg_tasks"]
|
|
52
|
+
return ("bg", "Attende i processi in background",
|
|
53
|
+
("1 processo in corso" if n == 1 else f"{n} processi in corso")
|
|
54
|
+
+ ": Claude non aspetta una tua risposta.")
|
|
55
|
+
if age > QUIET_AFTER_S:
|
|
56
|
+
return "quiet", "Inattiva", f"Ferma da {_mins(age)}."
|
|
57
|
+
return "waiting", "In attesa di te", "Claude ha finito il turno."
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _session_view(s):
|
|
61
|
+
return {
|
|
62
|
+
"session_id": s["session_id"], "short": s["session_id"][:8], "first_seen": s["first_seen"],
|
|
63
|
+
"last_event_at": s["last_event_at"], "last_sent_at": s["last_sent_at"],
|
|
64
|
+
"turn_start_at": s["turn_start_at"], "bg_tasks": s["bg_tasks"],
|
|
65
|
+
"bg_ids": json.loads(s.get("bg_ids") or "[]"),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
DEFAULT_CHAT_LIMIT = 5
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def projects_tree(db, now=None, app_available=False, chat_limit=DEFAULT_CHAT_LIMIT) -> dict:
|
|
73
|
+
"""chat_limit: how many (non-archived) chats to return per project, newest first; 0 = all.
|
|
74
|
+
Archived chats are returned separately in `archived` and never count towards the limit."""
|
|
75
|
+
now = now or time.time()
|
|
76
|
+
projects, order = {}, []
|
|
77
|
+
|
|
78
|
+
def proj(key, name):
|
|
79
|
+
if key not in projects:
|
|
80
|
+
row = db.one("SELECT enabled FROM projects WHERE project_dir=?", (key,))
|
|
81
|
+
projects[key] = {"project_dir": key, "name": name or leaf_name(key), "enabled": True if row is None
|
|
82
|
+
else bool(row["enabled"]), "last_activity": None, "chats": []}
|
|
83
|
+
order.append(key)
|
|
84
|
+
return projects[key]
|
|
85
|
+
|
|
86
|
+
for r in db.q("SELECT * FROM projects"):
|
|
87
|
+
p = proj(r["project_dir"], r["name"])
|
|
88
|
+
p["enabled"] = bool(r["enabled"])
|
|
89
|
+
sessions = db.q("SELECT * FROM sessions ORDER BY last_event_at DESC")
|
|
90
|
+
by_chat = {}
|
|
91
|
+
for s in sessions:
|
|
92
|
+
if s.get("chat_id"):
|
|
93
|
+
by_chat.setdefault(s["chat_id"], []).append(s)
|
|
94
|
+
for c in db.q("SELECT * FROM chats"):
|
|
95
|
+
p = proj(c["project_dir"], leaf_name(c["cwd"]))
|
|
96
|
+
ss = by_chat.get(c["chat_id"], [])
|
|
97
|
+
latest = ss[0] if ss else None
|
|
98
|
+
code, label, detail = display_state(latest, c, now)
|
|
99
|
+
act = max([x for x in (c["last_activity"], latest and latest["last_event_at"]) if x] or [0]) or None
|
|
100
|
+
p["chats"].append({"chat_id": c["chat_id"], "title": c["title"] or "(senza titolo)",
|
|
101
|
+
"archived": bool(c["archived"]), "last_activity": act, "state": code,
|
|
102
|
+
"state_label": label, "state_detail": detail,
|
|
103
|
+
"session": _session_view(latest) if latest else None, "sessions_seen": len(ss)})
|
|
104
|
+
# sessions that do not belong to any app chat (plain CLI): one pseudo-chat each, recent ones only
|
|
105
|
+
seen = {}
|
|
106
|
+
for s in sessions:
|
|
107
|
+
if s.get("chat_id") or now - (s["last_event_at"] or 0) > PSEUDO_CHAT_WINDOW_S:
|
|
108
|
+
continue
|
|
109
|
+
key = s.get("project_dir") or norm_dir(s.get("cwd"))
|
|
110
|
+
if not key:
|
|
111
|
+
continue
|
|
112
|
+
p = proj(key, s.get("project"))
|
|
113
|
+
if seen.get(key, 0) >= PSEUDO_CHAT_LIMIT:
|
|
114
|
+
continue
|
|
115
|
+
seen[key] = seen.get(key, 0) + 1
|
|
116
|
+
code, label, detail = display_state(s, None, now)
|
|
117
|
+
p["chats"].append({"chat_id": None, "title": f"Sessione {s['session_id'][:8]} (fuori dall'app)",
|
|
118
|
+
"archived": False, "last_activity": s["last_event_at"], "state": code,
|
|
119
|
+
"state_label": label, "state_detail": detail, "session": _session_view(s),
|
|
120
|
+
"sessions_seen": 1})
|
|
121
|
+
out = []
|
|
122
|
+
for key in order:
|
|
123
|
+
p = projects[key]
|
|
124
|
+
everything = sorted(p["chats"], key=lambda c: -(c["last_activity"] or 0))
|
|
125
|
+
live = [c for c in everything if not c["archived"]]
|
|
126
|
+
archived = [c for c in everything if c["archived"]]
|
|
127
|
+
if not everything:
|
|
128
|
+
continue
|
|
129
|
+
p["last_activity"] = max([c["last_activity"] for c in (live or archived) if c["last_activity"]] or [0]) or None
|
|
130
|
+
p["chats_total"] = len(live)
|
|
131
|
+
p["chats"] = live[:chat_limit] if chat_limit else live
|
|
132
|
+
p["chats_hidden"] = len(live) - len(p["chats"])
|
|
133
|
+
p["archived"] = archived
|
|
134
|
+
out.append(p)
|
|
135
|
+
out.sort(key=lambda p: -(p["last_activity"] or 0))
|
|
136
|
+
return {"projects": out, "app_metadata": app_available, "chat_limit": chat_limit, "generated_at": now}
|
ccmail/auth.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Local auth: a file token for hooks/CLI (Bearer), and cookie sessions + CSRF for the web app."""
|
|
2
|
+
import hmac
|
|
3
|
+
import secrets
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from . import paths
|
|
8
|
+
|
|
9
|
+
SESSION_TTL = 8 * 3600
|
|
10
|
+
NONCE_TTL = 60
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_or_create_token() -> str:
|
|
14
|
+
p = paths.token_path()
|
|
15
|
+
if p.exists():
|
|
16
|
+
t = p.read_text(encoding="utf-8").strip()
|
|
17
|
+
if len(t) >= 32:
|
|
18
|
+
return t
|
|
19
|
+
t = secrets.token_urlsafe(32)
|
|
20
|
+
p.write_text(t, encoding="utf-8")
|
|
21
|
+
paths.restrict_acl(p)
|
|
22
|
+
return t
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_token():
|
|
26
|
+
try:
|
|
27
|
+
return paths.token_path().read_text(encoding="utf-8").strip() or None
|
|
28
|
+
except OSError:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Auth:
|
|
33
|
+
def __init__(self, token: str):
|
|
34
|
+
self.token = token
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
self._nonces = {}
|
|
37
|
+
self._sessions = {}
|
|
38
|
+
|
|
39
|
+
def check_bearer(self, header) -> bool:
|
|
40
|
+
if not header or not header.startswith("Bearer "):
|
|
41
|
+
return False
|
|
42
|
+
return hmac.compare_digest(header[7:].strip().encode(), self.token.encode())
|
|
43
|
+
|
|
44
|
+
def new_nonce(self) -> str:
|
|
45
|
+
n = secrets.token_urlsafe(24)
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._nonces = {k: v for k, v in self._nonces.items() if v > time.time()}
|
|
48
|
+
self._nonces[n] = time.time() + NONCE_TTL
|
|
49
|
+
return n
|
|
50
|
+
|
|
51
|
+
def redeem_nonce(self, n: str):
|
|
52
|
+
"""Single use. Returns (session_id, csrf) or None."""
|
|
53
|
+
with self._lock:
|
|
54
|
+
exp = self._nonces.pop(n, 0)
|
|
55
|
+
if exp < time.time():
|
|
56
|
+
return None
|
|
57
|
+
sid, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(24)
|
|
58
|
+
self._sessions[sid] = (time.time() + SESSION_TTL, csrf)
|
|
59
|
+
return sid, csrf
|
|
60
|
+
|
|
61
|
+
def session_csrf(self, sid):
|
|
62
|
+
with self._lock:
|
|
63
|
+
exp, csrf = self._sessions.get(sid or "", (0, None))
|
|
64
|
+
if exp < time.time():
|
|
65
|
+
self._sessions.pop(sid or "", None)
|
|
66
|
+
return None
|
|
67
|
+
return csrf
|
ccmail/autostart.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Start the daemon at logon, per operating system, for the current user only (never elevated).
|
|
2
|
+
|
|
3
|
+
Windows Task Scheduler (schtasks, ONLOGON, limited)
|
|
4
|
+
macOS LaunchAgent in ~/Library/LaunchAgents
|
|
5
|
+
Linux systemd user unit; falls back to an XDG autostart .desktop entry when systemd --user is not usable
|
|
6
|
+
|
|
7
|
+
The text generators are pure functions so they can be tested on any machine.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
TASK_NAME = "ccmail"
|
|
16
|
+
LABEL = "com.ccmail.daemon"
|
|
17
|
+
UNIT = "ccmail.service"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def daemon_argv() -> list:
|
|
21
|
+
exe = sys.executable
|
|
22
|
+
if os.name == "nt":
|
|
23
|
+
w = os.path.join(os.path.dirname(exe), "pythonw.exe")
|
|
24
|
+
if os.path.exists(w):
|
|
25
|
+
exe = w
|
|
26
|
+
return [exe, "-m", "ccmail", "daemon"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def launchd_plist(argv: list) -> str:
|
|
30
|
+
items = "\n".join(f" <string>{_xml(a)}</string>" for a in argv)
|
|
31
|
+
return ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
32
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
|
|
33
|
+
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
34
|
+
'<plist version="1.0">\n<dict>\n'
|
|
35
|
+
f" <key>Label</key>\n <string>{LABEL}</string>\n"
|
|
36
|
+
f" <key>ProgramArguments</key>\n <array>\n{items}\n </array>\n"
|
|
37
|
+
" <key>RunAtLoad</key>\n <true/>\n"
|
|
38
|
+
" <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n"
|
|
39
|
+
"</dict>\n</plist>\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def systemd_unit(argv: list) -> str:
|
|
43
|
+
cmd = " ".join(_sd_quote(a) for a in argv)
|
|
44
|
+
return ("[Unit]\nDescription=ccmail - Claude Code e-mail notifications\n\n"
|
|
45
|
+
f"[Service]\nExecStart={cmd}\nRestart=on-failure\nRestartSec=5\n\n"
|
|
46
|
+
"[Install]\nWantedBy=default.target\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def desktop_entry(argv: list) -> str:
|
|
50
|
+
cmd = " ".join(_sd_quote(a) for a in argv)
|
|
51
|
+
return ("[Desktop Entry]\nType=Application\nName=ccmail\nComment=Claude Code e-mail notifications\n"
|
|
52
|
+
f"Exec={cmd}\nX-GNOME-Autostart-enabled=true\nNoDisplay=true\n")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _xml(s: str) -> str:
|
|
56
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sd_quote(s: str) -> str:
|
|
60
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' if (" " in s or '"' in s) else s
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def plist_path() -> Path:
|
|
64
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unit_path() -> Path:
|
|
68
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
69
|
+
return base / "systemd" / "user" / UNIT
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def desktop_path() -> Path:
|
|
73
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
74
|
+
return base / "autostart" / "ccmail.desktop"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run(cmd) -> bool:
|
|
78
|
+
try:
|
|
79
|
+
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
80
|
+
return subprocess.run(cmd, capture_output=True, timeout=30, creationflags=flags).returncode == 0
|
|
81
|
+
except (OSError, subprocess.SubprocessError):
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _systemd_usable() -> bool:
|
|
86
|
+
return bool(shutil.which("systemctl")) and _run(["systemctl", "--user", "show-environment"])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def enable() -> str:
|
|
90
|
+
"""Returns the mechanism used ('task-scheduler', 'launchd', 'systemd', 'xdg-autostart') or '' on failure."""
|
|
91
|
+
argv = daemon_argv()
|
|
92
|
+
if os.name == "nt":
|
|
93
|
+
cmd = ["schtasks", "/Create", "/TN", TASK_NAME, "/SC", "ONLOGON", "/RL", "LIMITED", "/F",
|
|
94
|
+
"/TR", f'"{argv[0]}" -m ccmail daemon']
|
|
95
|
+
return "task-scheduler" if _run(cmd) else ""
|
|
96
|
+
if sys.platform == "darwin":
|
|
97
|
+
p = plist_path()
|
|
98
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
p.write_text(launchd_plist(argv), encoding="utf-8")
|
|
100
|
+
uid = str(os.getuid())
|
|
101
|
+
_run(["launchctl", "bootout", f"gui/{uid}", str(p)])
|
|
102
|
+
return "launchd" if _run(["launchctl", "bootstrap", f"gui/{uid}", str(p)]) else ""
|
|
103
|
+
if _systemd_usable():
|
|
104
|
+
u = unit_path()
|
|
105
|
+
u.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
u.write_text(systemd_unit(argv), encoding="utf-8")
|
|
107
|
+
if _run(["systemctl", "--user", "daemon-reload"]) and _run(["systemctl", "--user", "enable", "--now", UNIT]):
|
|
108
|
+
return "systemd"
|
|
109
|
+
d = desktop_path()
|
|
110
|
+
d.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
d.write_text(desktop_entry(argv), encoding="utf-8")
|
|
112
|
+
return "xdg-autostart"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def disable() -> bool:
|
|
116
|
+
ok = False
|
|
117
|
+
if os.name == "nt":
|
|
118
|
+
return _run(["schtasks", "/Delete", "/TN", TASK_NAME, "/F"])
|
|
119
|
+
if sys.platform == "darwin":
|
|
120
|
+
p = plist_path()
|
|
121
|
+
if p.exists():
|
|
122
|
+
_run(["launchctl", "bootout", f"gui/{os.getuid()}", str(p)])
|
|
123
|
+
p.unlink()
|
|
124
|
+
ok = True
|
|
125
|
+
return ok
|
|
126
|
+
if unit_path().exists():
|
|
127
|
+
_run(["systemctl", "--user", "disable", "--now", UNIT])
|
|
128
|
+
unit_path().unlink()
|
|
129
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
130
|
+
ok = True
|
|
131
|
+
if desktop_path().exists():
|
|
132
|
+
desktop_path().unlink()
|
|
133
|
+
ok = True
|
|
134
|
+
return ok
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_enabled() -> bool:
|
|
138
|
+
if os.name == "nt":
|
|
139
|
+
return _run(["schtasks", "/Query", "/TN", TASK_NAME])
|
|
140
|
+
if sys.platform == "darwin":
|
|
141
|
+
return plist_path().exists()
|
|
142
|
+
return unit_path().exists() or desktop_path().exists()
|