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 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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()