HAHAHA97 commited on
Commit
7f35843
·
1 Parent(s): 4442a81

Auto email to Brevo

Browse files
OPERATIONAL_AUXILIARIES.md CHANGED
@@ -3,7 +3,7 @@
3
  ## Scope and status
4
 
5
  - Stage 1: implemented. Consent applies only to newly submitted private tokens; public submissions are unchanged.
6
- - Stage 2: implemented, disabled by default. Frontend-only Resend notifications.
7
  - Stage 3: **deferred at the size/complexity gate**. No production exporter, GitHub client, backup thread or GitHub configuration has been added. Do not treat the synthetic example as a backup.
8
  - No Worker changes, production reads/writes, real credentials, GPU execution, live emails, GitHub uploads, deployment or commits were used.
9
 
@@ -43,18 +43,24 @@ Set these in **Frontend**, not Worker:
43
  | Variable | Type | Value |
44
  | --- | --- | --- |
45
  | `EVALUATOR_ALERTS_ENABLED` | Variable | `true`; absent/other values disable |
 
46
  | `EVALUATOR_SPACE_ID` | Existing Variable | the existing managed Worker `owner/space` |
47
- | `EVALUATOR_ALERT_RESEND_API_KEY` | Secret | Resend sending credential |
48
  | `EVALUATOR_ALERT_FROM` | Secret | one authorized sender email, without display-name syntax |
49
  | `EVALUATOR_ALERT_TO` | Secret | one recipient email |
50
 
51
- All values must be present and addresses valid for the helper to start. Incomplete configuration disables it. Use an authorized Resend sender/domain. Existing `httpx` suffices; no dependency changes. Provider billing/account limits are external; no live delivery is claimed. The provider is fixed to `https://api.resend.com/emails`, with redirects disabled. [Resend idempotency documentation](https://resend.com/changelog/idempotency-keys).
 
 
52
 
53
  ### Exact files/functions and boundaries
54
 
55
  `src/submission/lifecycle_alerts.py`:
56
 
 
 
57
  - `_config`, `start`, `stop`: opt-in lifecycle for a single daemon sender per Frontend process.
 
58
  - `observe`: copies only Space ID, allowlisted runtime stage, active-work boolean, restart count/exhaustion flag and observation timestamp into a bounded in-memory queue. No record, token, error text or submission content is passed.
59
  - `Notifications.consume`: categorizes/deduplicates observations and persists a small outbox/incident latch.
60
  - `Notifications.deliver`: best-effort email via a separate HTTP client. Connect timeout 10s, read/write/pool 20s each; these are phase timeouts, not a universal wall-clock guarantee.
@@ -74,13 +80,44 @@ The helper uses only observations from existing controller calls, not an indepen
74
 
75
  ### Persistence, retry and rollback
76
 
77
- Only `/data/auxiliary/lifecycle-alerts/notifications.json` and `sender.lock` are added when enabled. No authoritative business file is changed. The JSON contains version, Space ID, three category latches and an outbox of allowlisted notification fields/receipts; no API key, sender/recipient address or submission data. Writes use temp file, fsync and replace. Invalid state fails closed without overwriting it.
 
 
 
 
78
 
79
- Delivery is persisted as `unknown` **before** calling Resend. If a timeout, crash or uncertain response occurs, it is not automatically resent: this trades possible missed email for duplicate avoidance. A definite 429/5xx response gets at most three total attempts using the same provider idempotency key (60s then 300s); older than 23h is no longer resent. Other non-success status codes are marked failed. HTTP 2xx means provider acceptance, not proven delivery to the inbox. Receipt-write failure after acceptance leaves the durable unknown receipt, also preventing resend.
80
 
81
  The sender processes at most one due delivery per poll. Pending receipts survive restart. An in-flight email may finish after shutdown; shutdown does not wait on it or delay existing controller shutdown. Receipts are capped at 1000 entries; completed/unknown/failed entries can be pruned on a new incident while category latches remain. If 1000 pending entries accumulate, notifications fail independently. Read size is capped at 2MB. Provider and FUSE failures remain possible; optional alerts are not a guaranteed external monitoring system.
82
 
83
- Disable with `EVALUATOR_ALERTS_ENABLED=false` (or remove configuration) and redeploy Frontend when convenient. Do not restart Worker. Keep the auxiliary state to preserve incident deduplication if re-enabled. Rollback to previous Frontend simply ignores the sidecar; never reset evaluator restart budget or delete Queue files as part of rollback.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
  ## Stage 3 — gate results and explicit deferral
86
 
@@ -175,7 +212,7 @@ Restore must remain separately authorized: validate offline, reconstruct per-sou
175
  ## Offline verification
176
 
177
  - `token_consent_regression.py`: 8/8 (strict consent, public/private paths, identity/Write role, encryption/recovery, all terminal token stripping, exception non-disclosure, UI input/output forwarding and reset).
178
- - `lifecycle_alerts_regression.py`: 14/14 (three events, normal-state suppression, continuity/restart, deduplication/new-budget episodes, bounded/ambiguous HTTP failure, invalid sidecar, persist-before-send, sanitized payload and controller isolation).
179
  - Existing `lifecycle_regression.py`: 49/49; `identity_history_regression.py`: 21/21; `stable_identity_regression.py`: 27/27; `publication_order_regression.py`: 25/25.
180
  - Existing `staging_regression.py`: 15/16; the previously known Future Prediction primary metric mismatch remains (`Worker manifest pmf` vs `Frontend pmf_novel`). It was not introduced or changed by this task.
181
  - Existing `traceability_regression.py`: 33/34 in the full suite. `test_multiple_entries_keep_individual_scores_and_metadata` passes alone (1/1), but the full suite leaves `Model B` presentation metadata from the preceding metadata test in its shared fixture; the subsequent test expects `Method 0123456789`. Its setup does not clear presentation metadata. The involved publication/metadata readers and tests are unchanged; this fixture-isolation issue was not fixed as part of this task.
 
3
  ## Scope and status
4
 
5
  - Stage 1: implemented. Consent applies only to newly submitted private tokens; public submissions are unchanged.
6
+ - Stage 2: implemented, disabled by default. Frontend-only Brevo notifications and an independently gated operator-shell test command.
7
  - Stage 3: **deferred at the size/complexity gate**. No production exporter, GitHub client, backup thread or GitHub configuration has been added. Do not treat the synthetic example as a backup.
8
  - No Worker changes, production reads/writes, real credentials, GPU execution, live emails, GitHub uploads, deployment or commits were used.
9
 
 
43
  | Variable | Type | Value |
44
  | --- | --- | --- |
45
  | `EVALUATOR_ALERTS_ENABLED` | Variable | `true`; absent/other values disable |
46
+ | `EVALUATOR_ALERT_TEST_ENABLED` | Variable | default `false`; `true` enables the operator-shell test only |
47
  | `EVALUATOR_SPACE_ID` | Existing Variable | the existing managed Worker `owner/space` |
48
+ | `EVALUATOR_ALERT_BREVO_API_KEY` | Secret | Brevo Transactional Email API key, not an SMTP password |
49
  | `EVALUATOR_ALERT_FROM` | Secret | one authorized sender email, without display-name syntax |
50
  | `EVALUATOR_ALERT_TO` | Secret | one recipient email |
51
 
52
+ All mail configuration values must be present and addresses valid for the helper to start; the two feature switches are independent and default off. Incomplete/invalid configuration disables sending. Use a sender registered and verified in Brevo; the operator must configure that verified address and Brevo enforces sender acceptance. No extra sender lookup API call is added. Existing `httpx` suffices; no dependency changes. The old `EVALUATOR_ALERT_RESEND_API_KEY` is ignored: there is no Resend fallback or dual send. Provider billing/account limits are external; no live delivery is claimed.
53
+
54
+ Transport is fixed to `POST https://api.brevo.com/v3/smtp/email`, with `api-key` authentication, JSON `sender.email`, `to[].email`, `subject`, and `textContent`. Redirects are disabled. HTTP **201** is treated as API acceptance; a validated, non-secret `messageId` is retained where provided. Missing/malformed IDs are not logged or treated as a reason to resend accepted mail. API acceptance is **not** inbox delivery. See [Brevo send-email API](https://developers.brevo.com/reference/send-transac-email) and [plain-text request guide](https://developers.brevo.com/docs/send-a-transactional-email).
55
 
56
  ### Exact files/functions and boundaries
57
 
58
  `src/submission/lifecycle_alerts.py`:
59
 
60
+ For the Brevo migration specifically, only `_config`, `Notifications.load`, `Notifications.consume` (receipt metadata only) and `Notifications.deliver` change. Added functions are `_mail_config`, `_message_id`, `_send_brevo`, `_save_test_record`, `send_test_email` and `main`. Lifecycle categorization/continuity, `_run`, auxiliary locking, `observe`, `start` and `stop` remain unchanged. The other two changed files are `lifecycle_alerts_regression.py` and this document.
61
+
62
  - `_config`, `start`, `stop`: opt-in lifecycle for a single daemon sender per Frontend process.
63
+ - `_mail_config`, `_send_brevo`, `_message_id`: shared finite-timeout transport/configuration and filtered provider receipt, used by production alerts and the operator test.
64
  - `observe`: copies only Space ID, allowlisted runtime stage, active-work boolean, restart count/exhaustion flag and observation timestamp into a bounded in-memory queue. No record, token, error text or submission content is passed.
65
  - `Notifications.consume`: categorizes/deduplicates observations and persists a small outbox/incident latch.
66
  - `Notifications.deliver`: best-effort email via a separate HTTP client. Connect timeout 10s, read/write/pool 20s each; these are phase timeouts, not a universal wall-clock guarantee.
 
80
 
81
  ### Persistence, retry and rollback
82
 
83
+ Production alerts use only `/data/auxiliary/lifecycle-alerts/notifications.json` and `sender.lock` when enabled. No authoritative business file is changed. The JSON contains version, Space ID, three category latches and an outbox of allowlisted notification fields/receipts, including provider and optional filtered message ID; no API key, sender/recipient address or submission data. Writes use temp file, fsync and replace. Invalid state fails closed without overwriting it. Operator tests use the separate directory described below.
84
+
85
+ Delivery is persisted as `unknown` **before** calling Brevo. If a timeout, crash, **5xx**, or unexpected non-201 2xx occurs, it is not automatically resent: this trades possible missed email for duplicate avoidance. Definite **429** rate-limit rejection gets at most three total attempts (60s then 300s); incidents older than 23h are no longer sent/retried. This existing 23h bound is a notification-freshness rule, not a Brevo idempotency guarantee. Other failures are marked failed. Receipt-write failure after acceptance leaves the durable unknown receipt, also preventing resend.
86
+
87
+ The verified Brevo documentation describes `headers.idempotencyKey` for batch emails, not Resend's HTTP `Idempotency-Key`. This implementation sends a single plain-text message and **does not assume batch idempotency applies**, invent an idempotency header, or automatically retry ambiguous 5xx. The rate-limit retry cap/delays are unchanged; the more conservative 5xx treatment is the intentional provider-migration difference. See [Brevo batch idempotency scope](https://developers.brevo.com/docs/heterogenous-versions-batch-emails).
88
 
89
+ The reader accepts existing **v1 Resend state** and new **v2 state**. V2 adds `provider` and optional filtered `message_id` to each receipt. Migration preserves all IDs, latches, categories, delivery values, attempt counts and timestamps. Merely loading v1 does not rewrite it; conversion is persisted on a normal subsequent save. A v1 pending receipt with zero attempts may be sent once through Brevo. Any previously attempted or processed receipt is attributed to Resend; sent/unknown/failed receipts are not resent, and attempted pending Resend receipts are **parked**, not retried through Brevo. Parked pending receipts need separate operator review, not automatic replay. Normal pre-existing bounded-outbox pruning policy remains unchanged; migration itself does not delete/reset historical state.
90
 
91
  The sender processes at most one due delivery per poll. Pending receipts survive restart. An in-flight email may finish after shutdown; shutdown does not wait on it or delay existing controller shutdown. Receipts are capped at 1000 entries; completed/unknown/failed entries can be pruned on a new incident while category latches remain. If 1000 pending entries accumulate, notifications fail independently. Read size is capped at 2MB. Provider and FUSE failures remain possible; optional alerts are not a guaranteed external monitoring system.
92
 
93
+ Disable production alerts with `EVALUATOR_ALERTS_ENABLED=false` (or remove configuration) and redeploy Frontend when convenient. Do not restart Worker. Keep auxiliary state to preserve incident deduplication if re-enabled. For rollback, disable both flags first and follow the v2 compatibility precautions below; never reset evaluator restart budget or delete Queue files as part of rollback.
94
+
95
+ ### Operator-only test email
96
+
97
+ No established, server-authorized administrator UI was found. The publication subject allowlist is **not** an administrator role. Consequently no button, public HTTP endpoint, Gradio handler or new authentication system was added, and `app.py`/`evaluator_space.py` were not changed by this transport migration.
98
+
99
+ The entry point is `python -m src.submission.lifecycle_alerts send-test`, executed **only through an already authorized Frontend container terminal/SSH/operator execution route**. Authorization is the platform/OS permission to execute code in that container, not a caller-supplied username or `--admin` flag. Ordinary/unauthenticated website researchers have no registered route to this command. Anyone with container execution and server Secret access is already a trusted operator; the CLI does not authenticate an HF session itself. If your deployment has no restricted operator terminal/SSH route, this test capability is unavailable until that platform access is approved—do not expose the function via an unauthenticated endpoint.
100
+
101
+ Set `EVALUATOR_ALERT_TEST_ENABLED=true` in Frontend to enable the test; default is false. It uses the same Brevo key/from/to/Worker Space configuration, but production `EVALUATOR_ALERTS_ENABLED` can remain false while testing. There are no recipient/subject/key CLI overrides. Its subject is `[TEST] PhysInOne Leaderboard Email Alert`, and the body identifies a test, UTC timestamp and absence of any real GPU/Worker failure.
102
+
103
+ The command sends at most one API request per invocation. A separate nonblocking lock and atomic receipt under `/data/auxiliary/lifecycle-alert-tests/{test-send.lock,test-send.json}` enforce **one test attempt per 60 seconds across all operators** (stronger than a per-administrator limit), surviving process restart. Failures and ambiguous outcomes consume the cooldown; test sends do not use the production outbox, incident latches or restart state and are never automatically retried. The receipt contains only version, last attempt time, outcome and optional safe message ID. Invalid/unwritable test state fails closed. A concurrent command may report unavailable rather than waiting; it cannot send a second request.
104
+
105
+ Return values are sanitized: `api_accepted`, `unknown`, `failed`, `disabled`, `unconfigured`, `cooldown`, or `unavailable`. Exit code is zero only for API acceptance. A timeout/save failure after submission can leave outcome unknown; inspect Brevo/inbox before issuing another deliberate manual test. No receipt contains the API key or raw HTTP response.
106
+
107
+ **After-deployment operator procedure (not executed during development):**
108
+
109
+ 1. Configure the Brevo API key as Frontend `EVALUATOR_ALERT_BREVO_API_KEY` Secret and set the existing sender/recipient Secrets. Use the already verified personal sender; keep keys out of commands and logs.
110
+ 2. Explicitly enable `EVALUATOR_ALERT_TEST_ENABLED=true`. Deploy Frontend only when ready; no Worker deployment/restart is required. Production alert enablement is independent.
111
+ 3. Enter the authorized **Frontend** container terminal, change to `/app`, and execute once:
112
+
113
+ ```bash
114
+ python -m src.submission.lifecycle_alerts send-test
115
+ ```
116
+
117
+ 4. If the output reports `api_accepted`, check Brevo transactional activity and the configured recipient inbox/spam folder. Do not claim delivery based on the API result alone. For uncertain results, check activity before retrying, and wait at least 60 seconds.
118
+ 5. Disable `EVALUATOR_ALERT_TEST_ENABLED` after testing. Changing the configured flag requires the normal Frontend configuration refresh/deployment; do not modify Bucket cooldown files to bypass it.
119
+
120
+ **Minimal deployment/rollback:** deploy the three changed Frontend files (alert module, offline tests and this document) and replace the key Secret; no new dependencies or Worker changes. Keep both optional flags false until deliberately enabled. The old provider key may be retained securely for a future reviewed rollback but is never read by this version. For immediate rollback, disable both features before reverting Frontend. Old Resend-only code cannot read persisted v2 receipts and fails closed; do not delete state or blindly restore an older snapshot, which could replay already processed incidents. Re-enabling Resend after rollback requires separately reviewed state compatibility handling; evaluation remains unaffected with notifications disabled.
121
 
122
  ## Stage 3 — gate results and explicit deferral
123
 
 
212
  ## Offline verification
213
 
214
  - `token_consent_regression.py`: 8/8 (strict consent, public/private paths, identity/Write role, encryption/recovery, all terminal token stripping, exception non-disclosure, UI input/output forwarding and reset).
215
+ - `lifecycle_alerts_regression.py`: 26/26 after the Brevo migration (three events, continuity/restart/deduplication, Brevo payload/message ID, configuration validation, conservative 429/5xx/timeouts, v1 compatibility, independent operator-test cooldown/configuration/persistence failure and no public test registration).
216
  - Existing `lifecycle_regression.py`: 49/49; `identity_history_regression.py`: 21/21; `stable_identity_regression.py`: 27/27; `publication_order_regression.py`: 25/25.
217
  - Existing `staging_regression.py`: 15/16; the previously known Future Prediction primary metric mismatch remains (`Worker manifest pmf` vs `Frontend pmf_novel`). It was not introduced or changed by this task.
218
  - Existing `traceability_regression.py`: 33/34 in the full suite. `test_multiple_entries_keep_individual_scores_and_metadata` passes alone (1/1), but the full suite leaves `Model B` presentation metadata from the preceding metadata test in its shared fixture; the subsequent test expects `Method 0123456789`. Its setup does not clear presentation metadata. The involved publication/metadata readers and tests are unchanged; this fixture-isolation issue was not fixed as part of this task.
lifecycle_alerts_regression.py CHANGED
@@ -1,5 +1,7 @@
1
  """Offline email observations, durable deduplication and isolated delivery."""
2
  import json
 
 
3
  import os
4
  from pathlib import Path
5
  import socket
@@ -113,13 +115,20 @@ class AlertChecks(unittest.TestCase):
113
  requests.append(request)
114
  self.assertTrue(controller._mutex.acquire(blocking=False))
115
  controller._mutex.release()
116
- return httpx.Response(200, json={"id": "synthetic"})
117
  self.deliver(respond)
118
  self.deliver(respond)
119
  self.assertEqual(len(requests), 1)
120
  payload = json.loads(requests[0].content)
121
  self.assertNotIn(self.config["key"], json.dumps(payload) + (self.directory / "notifications.json").read_text())
122
- self.assertIn("process crash is not inferred", payload["text"])
 
 
 
 
 
 
 
123
  self.assertEqual(requests[0].extensions["timeout"], dict(connect=10, read=20, write=20, pool=20))
124
 
125
  def test_ambiguous_timeout_not_resent_after_restart(self):
@@ -133,12 +142,12 @@ class AlertChecks(unittest.TestCase):
133
  self.deliver(lambda request: self.fail("Ambiguous delivery must not resend"))
134
  self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "unknown")
135
 
136
- def test_known_transient_failure_bounded_and_idempotent(self):
137
  self.sample("RUNTIME_ERROR")
138
  keys = []
139
  def fail(request):
140
- keys.append(request.headers["Idempotency-Key"])
141
- return httpx.Response(503)
142
  self.deliver(fail)
143
  self.deliver(fail)
144
  self.assertEqual(len(keys), 1)
@@ -149,7 +158,7 @@ class AlertChecks(unittest.TestCase):
149
  self.assertEqual(len(set(keys)), 1)
150
  self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "failed")
151
 
152
- def test_expired_retry_not_sent_with_expired_provider_key(self):
153
  self.sample("RUNTIME_ERROR")
154
  self.now += 24 * 3600
155
  self.deliver(lambda request: self.fail("Expired retry must not send"))
@@ -179,6 +188,174 @@ class AlertChecks(unittest.TestCase):
179
  api.return_value.restart_space.assert_not_called()
180
  api.return_value.set_space_sleep_time.assert_not_called()
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
  if __name__ == "__main__":
184
  with tempfile.TemporaryDirectory(prefix="physinone-alerts-import-") as root:
 
1
  """Offline email observations, durable deduplication and isolated delivery."""
2
  import json
3
+ import ast
4
+ import copy
5
  import os
6
  from pathlib import Path
7
  import socket
 
115
  requests.append(request)
116
  self.assertTrue(controller._mutex.acquire(blocking=False))
117
  controller._mutex.release()
118
+ return httpx.Response(201, json={"messageId": "<synthetic@relay.example.org>"})
119
  self.deliver(respond)
120
  self.deliver(respond)
121
  self.assertEqual(len(requests), 1)
122
  payload = json.loads(requests[0].content)
123
  self.assertNotIn(self.config["key"], json.dumps(payload) + (self.directory / "notifications.json").read_text())
124
+ self.assertIn("process crash is not inferred", payload["textContent"])
125
+ self.assertEqual(str(requests[0].url), "https://api.brevo.com/v3/smtp/email")
126
+ self.assertEqual(requests[0].headers["api-key"], self.config["key"])
127
+ self.assertNotIn("Authorization", requests[0].headers)
128
+ self.assertNotIn("Idempotency-Key", requests[0].headers)
129
+ self.assertEqual(payload["sender"], {"email": self.config["sender"]})
130
+ self.assertEqual(payload["to"], [{"email": self.config["recipient"]}])
131
+ self.assertEqual(self.notifications.state["outbox"][0]["message_id"], "<synthetic@relay.example.org>")
132
  self.assertEqual(requests[0].extensions["timeout"], dict(connect=10, read=20, write=20, pool=20))
133
 
134
  def test_ambiguous_timeout_not_resent_after_restart(self):
 
142
  self.deliver(lambda request: self.fail("Ambiguous delivery must not resend"))
143
  self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "unknown")
144
 
145
+ def test_definite_rate_limit_failure_bounded(self):
146
  self.sample("RUNTIME_ERROR")
147
  keys = []
148
  def fail(request):
149
+ keys.append(request.headers["api-key"])
150
+ return httpx.Response(429)
151
  self.deliver(fail)
152
  self.deliver(fail)
153
  self.assertEqual(len(keys), 1)
 
158
  self.assertEqual(len(set(keys)), 1)
159
  self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "failed")
160
 
161
+ def test_expired_notification_not_sent(self):
162
  self.sample("RUNTIME_ERROR")
163
  self.now += 24 * 3600
164
  self.deliver(lambda request: self.fail("Expired retry must not send"))
 
188
  api.return_value.restart_space.assert_not_called()
189
  api.return_value.set_space_sleep_time.assert_not_called()
190
 
191
+ def mail_environment(self, **changes):
192
+ values = {"EVALUATOR_SPACE_ID": "fixture/worker", "EVALUATOR_ALERT_BREVO_API_KEY": self.config["key"],
193
+ "EVALUATOR_ALERT_FROM": self.config["sender"], "EVALUATOR_ALERT_TO": self.config["recipient"],
194
+ "EVALUATOR_ALERTS_ENABLED": "true", "EVALUATOR_ALERT_TEST_ENABLED": "true"}
195
+ values.update(changes)
196
+ return patch.dict(os.environ, values, clear=True)
197
+
198
+ def test_brevo_configuration_validation_and_no_resend_fallback(self):
199
+ with self.mail_environment():
200
+ self.assertEqual(alerts._config(), self.config)
201
+ for variable, value in (("EVALUATOR_ALERT_BREVO_API_KEY", ""), ("EVALUATOR_ALERT_BREVO_API_KEY", "bad\nkey"),
202
+ ("EVALUATOR_ALERT_FROM", "bad@example"), ("EVALUATOR_ALERT_TO", "a@example.org,b@example.org"),
203
+ ("EVALUATOR_ALERT_TO", "a@example.org\nBcc:other@example.org"), ("EVALUATOR_ALERTS_ENABLED", "false")):
204
+ with patch.dict(os.environ, {variable: value}):
205
+ self.assertIsNone(alerts._config())
206
+ with self.mail_environment(EVALUATOR_ALERT_BREVO_API_KEY="", EVALUATOR_ALERT_RESEND_API_KEY="synthetic-old-key"):
207
+ self.assertIsNone(alerts._config())
208
+
209
+ def test_5xx_is_ambiguous_not_automatically_retried(self):
210
+ self.sample("RUNTIME_ERROR")
211
+ self.deliver(lambda request: httpx.Response(503, json={"secret": self.config["key"]}))
212
+ self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "unknown")
213
+ self.notifications = alerts.Notifications(self.directory, "fixture/worker")
214
+ self.now += 300
215
+ self.deliver(lambda request: self.fail("Ambiguous 5xx must not resend"))
216
+ self.assertNotIn(self.config["key"], (self.directory / "notifications.json").read_text())
217
+
218
+ def test_201_without_trustworthy_message_id_is_not_resent(self):
219
+ self.sample("RUNTIME_ERROR")
220
+ self.deliver(lambda request: httpx.Response(201, json={"messageId": f"<{self.config['key']}@relay.example.org>"}))
221
+ item = self.notifications.state["outbox"][0]
222
+ self.assertEqual(item["delivery"], "sent")
223
+ self.assertIsNone(item["message_id"])
224
+ self.deliver(lambda request: self.fail("Accepted mail must not resend"))
225
+ self.assertNotIn(self.config["key"], (self.directory / "notifications.json").read_text())
226
+
227
+ def test_legacy_resend_processed_receipts_preserved_and_parked(self):
228
+ self.sample("RUNTIME_ERROR")
229
+ legacy = copy.deepcopy(self.notifications.state)
230
+ legacy["version"] = 1
231
+ template = legacy["outbox"][0]
232
+ template.pop("provider")
233
+ template.pop("message_id")
234
+ legacy["outbox"] = [dict(template, id=f"{index:032x}", delivery=delivery, tries=1)
235
+ for index, delivery in enumerate(("sent", "unknown", "failed", "pending"), 1)]
236
+ path = self.directory / "notifications.json"
237
+ path.write_text(json.dumps(legacy))
238
+ before = path.read_bytes()
239
+ self.notifications = alerts.Notifications(self.directory, "fixture/worker")
240
+ self.deliver(lambda request: self.fail("Migration must not replay processed incidents"))
241
+ self.assertEqual(path.read_bytes(), before)
242
+ self.assertEqual(self.notifications.state["latched"], legacy["latched"])
243
+ self.assertEqual([item["delivery"] for item in self.notifications.state["outbox"]], [item["delivery"] for item in legacy["outbox"]])
244
+ self.assertTrue(all(item["provider"] == "resend" for item in self.notifications.state["outbox"]))
245
+ self.sample("RUNTIME_ERROR", attempts=1)
246
+ self.assertEqual(len(self.notifications.state["outbox"]), 4)
247
+
248
+ def test_legacy_unattempted_pending_receipt_can_use_brevo_once(self):
249
+ self.sample("RUNTIME_ERROR")
250
+ legacy = copy.deepcopy(self.notifications.state)
251
+ legacy["version"] = 1
252
+ legacy["outbox"][0].pop("provider")
253
+ legacy["outbox"][0].pop("message_id")
254
+ (self.directory / "notifications.json").write_text(json.dumps(legacy))
255
+ self.notifications = alerts.Notifications(self.directory, "fixture/worker")
256
+ self.deliver(lambda request: httpx.Response(201, json={"messageId": "<synthetic@relay.example.org>"}))
257
+ item = self.notifications.state["outbox"][0]
258
+ self.assertEqual(item["id"], legacy["outbox"][0]["id"])
259
+ self.assertEqual(item["provider"], "brevo")
260
+ self.deliver(lambda request: self.fail("No duplicate after migration"))
261
+
262
+ def test_operator_test_disabled_or_unconfigured_does_not_write(self):
263
+ with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(EVALUATOR_ALERT_TEST_ENABLED="false"):
264
+ with patch.object(alerts, "_send_brevo") as transport:
265
+ self.assertEqual(alerts.send_test_email()["status"], "disabled")
266
+ transport.assert_not_called()
267
+ with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(EVALUATOR_ALERT_BREVO_API_KEY=""):
268
+ self.assertEqual(alerts.send_test_email()["status"], "unconfigured")
269
+ self.assertEqual(list(self.directory.iterdir()), [])
270
+
271
+ def test_operator_test_fixed_recipient_cooldown_and_incident_isolation(self):
272
+ self.sample("RUNTIME_ERROR")
273
+ incident = (self.directory / "notifications.json").read_bytes()
274
+ budget = self.directory / "queue/evaluator-restart.json"
275
+ budget.parent.mkdir()
276
+ budget.write_text('{"attempts":6,"blocked":"exhausted"}')
277
+ before = budget.read_bytes()
278
+ requests = []
279
+ client = httpx.Client
280
+ def respond(request):
281
+ requests.append(request)
282
+ payload = json.loads(request.content)
283
+ self.assertEqual(payload["subject"], "[TEST] PhysInOne Leaderboard Email Alert")
284
+ self.assertEqual(payload["to"], [{"email": self.config["recipient"]}])
285
+ self.assertIn("No real GPU or Worker failure occurred", payload["textContent"])
286
+ return httpx.Response(201, json={"messageId": "<test@relay.example.org>"})
287
+ with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(EVALUATOR_ALERTS_ENABLED="false"), \
288
+ patch.object(alerts.time, "time", side_effect=lambda: self.now), \
289
+ patch.object(alerts.httpx, "Client", side_effect=lambda **options: client(transport=httpx.MockTransport(respond), **options)):
290
+ self.assertEqual(alerts.send_test_email()["status"], "api_accepted")
291
+ self.assertEqual(alerts.send_test_email()["status"], "cooldown")
292
+ self.now += 60
293
+ self.assertEqual(alerts.send_test_email()["status"], "api_accepted")
294
+ self.assertEqual(len(requests), 2)
295
+ self.assertEqual(budget.read_bytes(), before)
296
+ self.assertEqual((self.directory / "notifications.json").read_bytes(), incident)
297
+ receipt = json.loads((self.directory / "auxiliary/lifecycle-alert-tests/test-send.json").read_bytes())
298
+ self.assertEqual(receipt["message_id"], "<test@relay.example.org>")
299
+ self.assertNotIn(self.config["key"], json.dumps(receipt))
300
+
301
+ def test_failed_operator_send_single_attempt_and_consumes_cooldown(self):
302
+ with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(), \
303
+ patch.object(alerts.time, "time", return_value=self.now), \
304
+ patch.object(alerts, "_send_brevo", return_value=("retryable", None)) as transport:
305
+ self.assertEqual(alerts.send_test_email()["status"], "failed")
306
+ self.assertEqual(alerts.send_test_email()["status"], "cooldown")
307
+ transport.assert_called_once()
308
+
309
+ def test_test_persistence_failure_prevents_network_and_is_sanitized(self):
310
+ with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(), \
311
+ patch.object(alerts, "_save_test_record", side_effect=OSError(self.config["key"])), \
312
+ patch.object(alerts, "_send_brevo") as transport:
313
+ with self.assertLogs(alerts.logger, level="WARNING") as logs:
314
+ result = alerts.send_test_email()
315
+ self.assertEqual(result["status"], "unavailable")
316
+ transport.assert_not_called()
317
+ self.assertNotIn(self.config["key"], json.dumps(result) + "".join(logs.output))
318
+
319
+ def test_no_public_web_user_entry_or_arbitrary_recipient_cli_option(self):
320
+ app = (Path(__file__).parent / "app.py").read_text(encoding="utf-8")
321
+ tree = ast.parse(app)
322
+ names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
323
+ attributes = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
324
+ self.assertNotIn("send_test_email", names | attributes)
325
+ self.assertNotIn("send-test", app)
326
+ with patch("sys.argv", ["operator-command", "send-test", "--recipient", "ordinary@example.org"]), \
327
+ patch.object(alerts, "send_test_email") as send, patch("sys.stderr"):
328
+ with self.assertRaises(SystemExit) as result:
329
+ alerts.main()
330
+ self.assertEqual(result.exception.code, 2)
331
+ send.assert_not_called()
332
+
333
+ def test_operator_timeout_receipt_and_cooldown_survive_new_invocation(self):
334
+ client = httpx.Client
335
+ def timeout(request):
336
+ raise httpx.ReadTimeout(self.config["key"], request=request)
337
+ with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(), \
338
+ patch.object(alerts.time, "time", return_value=self.now), \
339
+ patch.object(alerts.httpx, "Client", side_effect=lambda **options: client(transport=httpx.MockTransport(timeout), **options)):
340
+ with self.assertLogs(alerts.logger, level="WARNING") as logs:
341
+ result = alerts.send_test_email()
342
+ self.assertEqual(result["status"], "unknown")
343
+ self.assertEqual(alerts.send_test_email()["status"], "cooldown")
344
+ raw = (self.directory / "auxiliary/lifecycle-alert-tests/test-send.json").read_text()
345
+ self.assertEqual(json.loads(raw)["delivery"], "unknown")
346
+ self.assertNotIn(self.config["key"], raw + json.dumps(result) + "".join(logs.output))
347
+
348
+ def test_malformed_operator_receipt_fails_closed(self):
349
+ path = self.directory / "auxiliary/lifecycle-alert-tests/test-send.json"
350
+ path.parent.mkdir(parents=True)
351
+ path.write_text('{"unexpected":"synthetic-secret"}')
352
+ before = path.read_bytes()
353
+ with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(), \
354
+ patch.object(alerts, "_send_brevo") as transport:
355
+ self.assertEqual(alerts.send_test_email()["status"], "unavailable")
356
+ transport.assert_not_called()
357
+ self.assertEqual(path.read_bytes(), before)
358
+
359
 
360
  if __name__ == "__main__":
361
  with tempfile.TemporaryDirectory(prefix="physinone-alerts-import-") as root:
src/submission/lifecycle_alerts.py CHANGED
@@ -29,22 +29,62 @@ _CATEGORIES = {"restart_budget_exhausted", "starting_30_minutes", "unexpected_st
29
  _MAX_GAP = 45
30
 
31
 
32
- def _config() -> dict | None:
33
- if os.environ.get("EVALUATOR_ALERTS_ENABLED", "").lower() != "true":
34
- return None
35
  config = {name: os.environ.get(variable, "").strip() for name, variable in (
36
- ("space", "EVALUATOR_SPACE_ID"), ("key", "EVALUATOR_ALERT_RESEND_API_KEY"),
37
  ("sender", "EVALUATOR_ALERT_FROM"), ("recipient", "EVALUATOR_ALERT_TO"))}
38
  if not all(config.values()):
39
  return None
40
  if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", config["space"]):
41
  return None
 
 
42
  for name in ("sender", "recipient"):
43
- if not re.fullmatch(r"[^\s<>@,;]+@[^\s<>@,;]+", config[name]) or len(config[name]) > 254:
 
 
 
44
  return None
45
  return config
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def observe(space_id: str, stage: str | None, active: bool | None, state: dict | None) -> None:
49
  try:
50
  config = _config()
@@ -107,14 +147,17 @@ class Notifications:
107
  raise ValueError("Notification state limit")
108
  state = json.loads(raw)
109
  except FileNotFoundError:
110
- state = {"version": 1, "space": self.space, "latched": [], "outbox": []}
111
  if (not isinstance(state, dict) or set(state) != {"version", "space", "latched", "outbox"}
112
- or state["version"] != 1 or state["space"] != self.space
113
  or not isinstance(state["latched"], list) or any(item not in _CATEGORIES for item in state["latched"])
114
  or not isinstance(state["outbox"], list) or len(state["outbox"]) > 1000):
115
  raise ValueError("Invalid notification state")
116
  for item in state["outbox"]:
117
- if (not isinstance(item, dict) or set(item) != {"id", "category", "stage", "timestamp", "attempts", "delivery", "tries", "next_try"}
 
 
 
118
  or not isinstance(item["id"], str) or not re.fullmatch(r"[0-9a-f]{32}", item["id"])
119
  or item["category"] not in _CATEGORIES or item["stage"] not in _STAGES | {"UNKNOWN"}
120
  or not isinstance(item["timestamp"], str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", item["timestamp"])
@@ -123,6 +166,15 @@ class Notifications:
123
  or type(item["tries"]) is not int or not 0 <= item["tries"] <= 3
124
  or type(item["next_try"]) not in {int, float} or not 0 <= item["next_try"] < float("inf")):
125
  raise ValueError("Invalid notification receipt")
 
 
 
 
 
 
 
 
 
126
  self.state, self.loaded = state, True
127
 
128
  def save(self):
@@ -179,7 +231,8 @@ class Notifications:
179
  raise ValueError("Notification outbox full")
180
  self.state["outbox"].append({"id": secrets.token_hex(16), "category": category,
181
  "stage": stage or "UNKNOWN", "timestamp": datetime.fromtimestamp(observed, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
182
- "attempts": sample["attempts"], "delivery": "pending", "tries": 0, "next_try": now})
 
183
  latches.add(category)
184
  self.state["latched"] = sorted(latches)
185
  if json.dumps(self.state, sort_keys=True) != before:
@@ -187,7 +240,8 @@ class Notifications:
187
 
188
  def deliver(self, config: dict, now: float):
189
  self.load()
190
- item = next((item for item in self.state["outbox"] if item["delivery"] == "pending" and item["next_try"] <= now), None)
 
191
  if item is None:
192
  return
193
  created = datetime.fromisoformat(item["timestamp"].replace("Z", "+00:00")).timestamp()
@@ -197,26 +251,18 @@ class Notifications:
197
  return
198
  item["tries"] += 1
199
  item["delivery"] = "unknown"
 
200
  self.save()
201
  diagnostic = {"restart_budget_exhausted": "Automatic restart budget exhausted; operator review required.",
202
  "starting_30_minutes": "Startup continuously observed for 30 minutes; no automated action taken.",
203
  "unexpected_stop_or_runtime_error": "Stop or runtime error observed with active work; process crash is not inferred."}
204
  text = (f"Worker Space: {self.space}\nCategory: {item['category']}\nUTC: {item['timestamp']}\n"
205
  f"Runtime: {item['stage']}\nRestart attempts: {item['attempts']}\n{diagnostic[item['category']]}")
206
- try:
207
- with httpx.Client(timeout=httpx.Timeout(20, connect=10), follow_redirects=False) as client:
208
- response = client.post("https://api.resend.com/emails",
209
- headers={"Authorization": f"Bearer {config['key']}", "Idempotency-Key": f"physinone-{item['id']}"},
210
- json={"from": config["sender"], "to": [config["recipient"]], "subject": f"PhysInOne: {item['category']}", "text": text})
211
- if 200 <= response.status_code < 300:
212
- item["delivery"] = "sent"
213
- elif response.status_code == 429 or response.status_code >= 500:
214
- item["delivery"] = "pending" if item["tries"] < 3 else "failed"
215
- item["next_try"] = now + (60 if item["tries"] == 1 else 300)
216
- else:
217
- item["delivery"] = "failed"
218
- except Exception as error:
219
- logger.warning("Auxiliary email outcome unknown (%s); not automatically resending", type(error).__name__)
220
  self.save()
221
 
222
 
@@ -266,3 +312,76 @@ def start():
266
 
267
  def stop():
268
  _stop.set()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  _MAX_GAP = 45
30
 
31
 
32
+ def _mail_config() -> dict | None:
 
 
33
  config = {name: os.environ.get(variable, "").strip() for name, variable in (
34
+ ("space", "EVALUATOR_SPACE_ID"), ("key", "EVALUATOR_ALERT_BREVO_API_KEY"),
35
  ("sender", "EVALUATOR_ALERT_FROM"), ("recipient", "EVALUATOR_ALERT_TO"))}
36
  if not all(config.values()):
37
  return None
38
  if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", config["space"]):
39
  return None
40
+ if not re.fullmatch(r"[\x21-\x7e]{1,4096}", config["key"]):
41
+ return None
42
  for name in ("sender", "recipient"):
43
+ address = config[name]
44
+ if (not re.fullmatch(r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+", address)
45
+ or len(address) > 254 or len(address.split("@")[0]) > 64
46
+ or address.startswith(".") or ".@" in address or ".." in address):
47
  return None
48
  return config
49
 
50
 
51
+ def _config() -> dict | None:
52
+ if os.environ.get("EVALUATOR_ALERTS_ENABLED", "").lower() != "true":
53
+ return None
54
+ return _mail_config()
55
+
56
+
57
+ def _message_id(value, key="") -> str | None:
58
+ if (not isinstance(value, str) or not re.fullmatch(r"<[A-Za-z0-9._+:-]{1,200}@[A-Za-z0-9.-]{1,200}>", value)
59
+ or key and key in value or re.search(r"(?i)hf_|ghp_|github_pat_|xkeysib-|xsmtpsib-|bearer", value)):
60
+ return None
61
+ return value
62
+
63
+
64
+ def _send_brevo(config: dict, subject: str, text: str) -> tuple[str, str | None]:
65
+ try:
66
+ with httpx.Client(timeout=httpx.Timeout(20, connect=10), follow_redirects=False) as client:
67
+ response = client.post("https://api.brevo.com/v3/smtp/email",
68
+ headers={"api-key": config["key"], "Accept": "application/json"},
69
+ json={"sender": {"email": config["sender"]}, "to": [{"email": config["recipient"]}],
70
+ "subject": subject, "textContent": text})
71
+ if response.status_code == 201:
72
+ try:
73
+ body = response.json()
74
+ identifier = _message_id(body.get("messageId"), config["key"]) if isinstance(body, dict) else None
75
+ except ValueError:
76
+ identifier = None
77
+ return "sent", identifier
78
+ if response.status_code == 429:
79
+ return "retryable", None
80
+ if response.status_code >= 500 or 200 <= response.status_code < 300:
81
+ return "unknown", None
82
+ return "failed", None
83
+ except Exception as error:
84
+ logger.warning("Auxiliary email outcome unknown (%s); not automatically resending", type(error).__name__)
85
+ return "unknown", None
86
+
87
+
88
  def observe(space_id: str, stage: str | None, active: bool | None, state: dict | None) -> None:
89
  try:
90
  config = _config()
 
147
  raise ValueError("Notification state limit")
148
  state = json.loads(raw)
149
  except FileNotFoundError:
150
+ state = {"version": 2, "space": self.space, "latched": [], "outbox": []}
151
  if (not isinstance(state, dict) or set(state) != {"version", "space", "latched", "outbox"}
152
+ or type(state["version"]) is not int or state["version"] not in {1, 2} or state["space"] != self.space
153
  or not isinstance(state["latched"], list) or any(item not in _CATEGORIES for item in state["latched"])
154
  or not isinstance(state["outbox"], list) or len(state["outbox"]) > 1000):
155
  raise ValueError("Invalid notification state")
156
  for item in state["outbox"]:
157
+ fields = {"id", "category", "stage", "timestamp", "attempts", "delivery", "tries", "next_try"}
158
+ if state["version"] == 2:
159
+ fields |= {"provider", "message_id"}
160
+ if (not isinstance(item, dict) or set(item) != fields
161
  or not isinstance(item["id"], str) or not re.fullmatch(r"[0-9a-f]{32}", item["id"])
162
  or item["category"] not in _CATEGORIES or item["stage"] not in _STAGES | {"UNKNOWN"}
163
  or not isinstance(item["timestamp"], str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", item["timestamp"])
 
166
  or type(item["tries"]) is not int or not 0 <= item["tries"] <= 3
167
  or type(item["next_try"]) not in {int, float} or not 0 <= item["next_try"] < float("inf")):
168
  raise ValueError("Invalid notification receipt")
169
+ if state["version"] == 2 and (item["provider"] not in {"brevo", "resend", "unassigned"}
170
+ or item["message_id"] is not None and _message_id(item["message_id"]) != item["message_id"]):
171
+ raise ValueError("Invalid notification provider receipt")
172
+ if state["version"] == 2 and item["provider"] == "unassigned" and (item["tries"] != 0 or item["delivery"] != "pending" or item["message_id"] is not None):
173
+ raise ValueError("Invalid unattempted notification receipt")
174
+ if state["version"] == 1:
175
+ for item in state["outbox"]:
176
+ item.update(provider="resend" if item["tries"] or item["delivery"] != "pending" else "unassigned", message_id=None)
177
+ state["version"] = 2
178
  self.state, self.loaded = state, True
179
 
180
  def save(self):
 
231
  raise ValueError("Notification outbox full")
232
  self.state["outbox"].append({"id": secrets.token_hex(16), "category": category,
233
  "stage": stage or "UNKNOWN", "timestamp": datetime.fromtimestamp(observed, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
234
+ "attempts": sample["attempts"], "delivery": "pending", "tries": 0, "next_try": now,
235
+ "provider": "brevo", "message_id": None})
236
  latches.add(category)
237
  self.state["latched"] = sorted(latches)
238
  if json.dumps(self.state, sort_keys=True) != before:
 
240
 
241
  def deliver(self, config: dict, now: float):
242
  self.load()
243
+ item = next((item for item in self.state["outbox"] if item["delivery"] == "pending"
244
+ and item["provider"] in {"brevo", "unassigned"} and item["next_try"] <= now), None)
245
  if item is None:
246
  return
247
  created = datetime.fromisoformat(item["timestamp"].replace("Z", "+00:00")).timestamp()
 
251
  return
252
  item["tries"] += 1
253
  item["delivery"] = "unknown"
254
+ item["provider"] = "brevo"
255
  self.save()
256
  diagnostic = {"restart_budget_exhausted": "Automatic restart budget exhausted; operator review required.",
257
  "starting_30_minutes": "Startup continuously observed for 30 minutes; no automated action taken.",
258
  "unexpected_stop_or_runtime_error": "Stop or runtime error observed with active work; process crash is not inferred."}
259
  text = (f"Worker Space: {self.space}\nCategory: {item['category']}\nUTC: {item['timestamp']}\n"
260
  f"Runtime: {item['stage']}\nRestart attempts: {item['attempts']}\n{diagnostic[item['category']]}")
261
+ outcome, item["message_id"] = _send_brevo(config, f"PhysInOne: {item['category']}", text)
262
+ item["delivery"] = outcome
263
+ if outcome == "retryable":
264
+ item["delivery"] = "pending" if item["tries"] < 3 else "failed"
265
+ item["next_try"] = now + (60 if item["tries"] == 1 else 300)
 
 
 
 
 
 
 
 
 
266
  self.save()
267
 
268
 
 
312
 
313
  def stop():
314
  _stop.set()
315
+
316
+
317
+ def _save_test_record(directory: Path, record: dict):
318
+ temporary = directory / f".test-send.{secrets.token_hex(8)}.tmp"
319
+ try:
320
+ with temporary.open("x", encoding="utf-8") as handle:
321
+ json.dump(record, handle, allow_nan=False)
322
+ handle.flush()
323
+ os.fsync(handle.fileno())
324
+ os.replace(temporary, directory / "test-send.json")
325
+ finally:
326
+ temporary.unlink(missing_ok=True)
327
+
328
+
329
+ def send_test_email() -> dict:
330
+ """Operator-shell entry only; deliberately not registered with HTTP/Gradio."""
331
+ if os.environ.get("EVALUATOR_ALERT_TEST_ENABLED", "").lower() != "true":
332
+ return {"status": "disabled", "message": "Operator test email is disabled."}
333
+ config = _mail_config()
334
+ if config is None:
335
+ return {"status": "unconfigured", "message": "Brevo email configuration is missing or invalid."}
336
+ directory = Path(DATA_ROOT) / "auxiliary" / "lifecycle-alert-tests"
337
+ try:
338
+ directory.mkdir(parents=True, exist_ok=True)
339
+ with _auxiliary_lock(directory / "test-send.lock"):
340
+ path = directory / "test-send.json"
341
+ try:
342
+ with path.open("rb") as handle:
343
+ raw = handle.read(4097)
344
+ if len(raw) > 4096:
345
+ raise ValueError("Invalid test receipt")
346
+ previous = json.loads(raw)
347
+ if (not isinstance(previous, dict) or set(previous) != {"version", "last_attempt", "delivery", "message_id"}
348
+ or previous["version"] != 1 or type(previous["last_attempt"]) not in {int, float}
349
+ or not 0 <= previous["last_attempt"] < float("inf")
350
+ or previous["delivery"] not in {"sent", "unknown", "failed"}
351
+ or previous["message_id"] is not None and _message_id(previous["message_id"]) != previous["message_id"]):
352
+ raise ValueError("Invalid test receipt")
353
+ except FileNotFoundError:
354
+ previous = None
355
+ now = time.time()
356
+ if previous and now - previous["last_attempt"] < 60:
357
+ return {"status": "cooldown", "message": "Test email cooldown is active; wait at least 60 seconds between attempts."}
358
+ record = {"version": 1, "last_attempt": now, "delivery": "unknown", "message_id": None}
359
+ _save_test_record(directory, record)
360
+ text = ("This is a test email from the PhysInOne Leaderboard monitoring system. "
361
+ "No real GPU or Worker failure occurred. API acceptance does not confirm inbox delivery.\n"
362
+ f"UTC: {datetime.fromtimestamp(now, timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}")
363
+ outcome, record["message_id"] = _send_brevo(config, "[TEST] PhysInOne Leaderboard Email Alert", text)
364
+ record["delivery"] = "failed" if outcome == "retryable" else outcome
365
+ _save_test_record(directory, record)
366
+ if outcome == "sent":
367
+ return {"status": "api_accepted", "message": "Brevo API accepted the test email. Confirm delivery separately in Brevo or the configured recipient inbox."}
368
+ if outcome == "unknown":
369
+ return {"status": "unknown", "message": "Test email outcome is uncertain; check Brevo or the inbox before another manual attempt."}
370
+ return {"status": "failed", "message": "Brevo did not accept the test email; review the configured credentials, verified sender and provider limits."}
371
+ except Exception as error:
372
+ logger.warning("Operator email test unavailable (%s)", type(error).__name__)
373
+ return {"status": "unavailable", "message": "Operator email test is unavailable; no evaluation or incident state was changed."}
374
+
375
+
376
+ def main():
377
+ import argparse
378
+ parser = argparse.ArgumentParser(description="Operator-only email test; use an authorized Frontend container terminal.")
379
+ parser.add_argument("command", choices=["send-test"])
380
+ parser.parse_args()
381
+ result = send_test_email()
382
+ print(json.dumps(result))
383
+ return 0 if result["status"] == "api_accepted" else 1
384
+
385
+
386
+ if __name__ == "__main__":
387
+ raise SystemExit(main())