ermes-cli 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.
ermes_cli/config.py ADDED
@@ -0,0 +1,393 @@
1
+ """Config storage for ermes-cli.
2
+
3
+ Two storage shapes coexist:
4
+
5
+ ~/.ermes/projects.yml (new in v0.3, written by `ermes login`)
6
+ auth0_email: admin@ermes.local
7
+ active_project: 72
8
+ base_url: https://ermes.publinker.com
9
+ projects:
10
+ 72:
11
+ name: martechdm
12
+ organization_id: 1
13
+ role: admin
14
+ api_key: sk_xxxxx
15
+ api_key_id: 42
16
+ api_key_name: cli-managed-<hostname>
17
+
18
+ ~/.ermes/config.yml (legacy, written by `ermes config init`)
19
+ default_profile: martechdm
20
+ profiles:
21
+ martechdm:
22
+ api_key: sk_xxxxx
23
+ base_url: https://ermes.publinker.com
24
+
25
+ `resolve_profile()` prefers projects.yml (see its docstring for the full
26
+ 1.0 order). Per-project keys are provisioned lazily on first use — login
27
+ no longer mints a key on every project.
28
+
29
+ Both files are written chmod 600.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import contextlib
35
+ import os
36
+ import sys
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ import yaml
41
+
42
+ DEFAULT_BASE_URL = "https://ermes.publinker.com"
43
+
44
+
45
+ _warned_profile: list[bool] = []
46
+
47
+
48
+ class ConfigError(Exception):
49
+ pass
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Paths
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _config_dir() -> Path:
58
+ return Path(os.environ.get("ERMES_CONFIG_DIR", Path.home() / ".ermes"))
59
+
60
+
61
+ def _config_path() -> Path:
62
+ return _config_dir() / "config.yml"
63
+
64
+
65
+ def _projects_path() -> Path:
66
+ return _config_dir() / "projects.yml"
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Legacy profile storage (config.yml)
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def load_config() -> dict:
75
+ path = _config_path()
76
+ if not path.exists():
77
+ return {"profiles": {}}
78
+ with open(path) as f:
79
+ data = yaml.safe_load(f)
80
+ return data if data else {"profiles": {}}
81
+
82
+
83
+ @contextlib.contextmanager
84
+ def _open_private(path: Path):
85
+ """Write `path` atomically as a fresh 0600 file.
86
+
87
+ The bytes go to a sibling temp file opened O_EXCL|O_NOFOLLOW (a planted
88
+ symlink or stale temp is never followed), then `os.replace` swaps it in: a
89
+ reader sees the old file or the new one, never a truncated one, and a
90
+ pre-existing wide-mode file or symlink is replaced by a fresh 0600 inode.
91
+ """
92
+ tmp = path.with_name(path.name + ".tmp")
93
+ tmp.unlink(missing_ok=True)
94
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
95
+ try:
96
+ if hasattr(os, "fchmod"): # absent on Windows < 3.13; the 0o600 at open still applies
97
+ os.fchmod(fd, 0o600)
98
+ f = os.fdopen(fd, "w")
99
+ except OSError:
100
+ os.close(fd)
101
+ tmp.unlink(missing_ok=True)
102
+ raise
103
+ try:
104
+ with f:
105
+ yield f
106
+ f.flush()
107
+ os.fsync(f.fileno())
108
+ os.replace(tmp, path)
109
+ except BaseException:
110
+ tmp.unlink(missing_ok=True)
111
+ raise
112
+
113
+
114
+ def validate_api_key(raw: str, source: str = "API key") -> str:
115
+ """A key with whitespace or control chars would make httpx raise a header
116
+ error that repeats the whole key; reject it before it reaches the client."""
117
+ key = raw.strip()
118
+ if not key or any(ch.isspace() or not ch.isprintable() for ch in key):
119
+ raise ConfigError(
120
+ f"{source} contains whitespace or control characters; "
121
+ "use the bare sk_… value (watch for a trailing newline)."
122
+ )
123
+ return key
124
+
125
+
126
+ def save_config(config: dict) -> None:
127
+ path = _config_path()
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ with _open_private(path) as f:
130
+ yaml.dump(config, f, default_flow_style=False)
131
+
132
+
133
+ def add_profile(name: str, api_key: str, base_url: str | None = None) -> None:
134
+ config = load_config()
135
+ if name in config.get("profiles", {}):
136
+ raise ConfigError(f"Profile '{name}' already exists")
137
+ config.setdefault("profiles", {})[name] = {
138
+ "api_key": api_key,
139
+ "base_url": base_url or DEFAULT_BASE_URL,
140
+ }
141
+ if not config.get("default_profile"):
142
+ config["default_profile"] = name
143
+ save_config(config)
144
+
145
+
146
+ def remove_profile(name: str) -> None:
147
+ config = load_config()
148
+ if name not in config.get("profiles", {}):
149
+ raise ConfigError(f"Profile '{name}' not found")
150
+ del config["profiles"][name]
151
+ if config.get("default_profile") == name:
152
+ config["default_profile"] = None
153
+ save_config(config)
154
+
155
+
156
+ def get_profile(name: str) -> dict:
157
+ config = load_config()
158
+ if name not in config.get("profiles", {}):
159
+ raise ConfigError(f"Profile '{name}' not found")
160
+ return config["profiles"][name]
161
+
162
+
163
+ def set_default_profile(name: str) -> None:
164
+ config = load_config()
165
+ if name not in config.get("profiles", {}):
166
+ raise ConfigError(f"Profile '{name}' not found")
167
+ config["default_profile"] = name
168
+ save_config(config)
169
+
170
+
171
+ def mask_key(key: str) -> str:
172
+ if len(key) <= 8:
173
+ return key[:3] + "***"
174
+ return key[:3] + "***" + key[-3:]
175
+
176
+
177
+ def list_profiles() -> list[dict]:
178
+ config = load_config()
179
+ default = config.get("default_profile")
180
+ result = []
181
+ for name, profile in config.get("profiles", {}).items():
182
+ result.append({
183
+ "name": name,
184
+ "api_key": mask_key(profile["api_key"]),
185
+ "base_url": profile.get("base_url", DEFAULT_BASE_URL),
186
+ "is_default": name == default,
187
+ })
188
+ return result
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # New project storage (projects.yml) — populated by `ermes login`
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ def load_projects_file() -> dict[str, Any]:
197
+ path = _projects_path()
198
+ if not path.exists():
199
+ return {}
200
+ with open(path) as f:
201
+ data = yaml.safe_load(f)
202
+ return data if isinstance(data, dict) else {}
203
+
204
+
205
+ def save_projects_file(data: dict[str, Any]) -> None:
206
+ path = _projects_path()
207
+ path.parent.mkdir(parents=True, exist_ok=True)
208
+ with _open_private(path) as f:
209
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
210
+
211
+
212
+ def clear_projects_file() -> None:
213
+ path = _projects_path()
214
+ if path.exists():
215
+ path.unlink()
216
+
217
+
218
+ def get_projects() -> dict[int, dict[str, Any]]:
219
+ data = load_projects_file()
220
+ raw = data.get("projects") or {}
221
+ # YAML keys may come back as strings; normalize to int.
222
+ return {int(k): v for k, v in raw.items()}
223
+
224
+
225
+ def get_active_project_id() -> int | None:
226
+ data = load_projects_file()
227
+ aid = data.get("active_project")
228
+ return int(aid) if aid is not None else None
229
+
230
+
231
+ def set_active_project_id(project_id: int) -> None:
232
+ data = load_projects_file()
233
+ projects = data.get("projects") or {}
234
+ if str(project_id) not in projects and project_id not in projects:
235
+ raise ConfigError(
236
+ f"Project {project_id} not in projects.yml — run `ermes login` to refresh"
237
+ )
238
+ data["active_project"] = int(project_id)
239
+ save_projects_file(data)
240
+
241
+
242
+ def get_active_project() -> dict[str, Any] | None:
243
+ aid = get_active_project_id()
244
+ if aid is None:
245
+ return None
246
+ return get_projects().get(aid)
247
+
248
+
249
+ def get_effective_project(
250
+ profile_name: str | None = None,
251
+ ) -> tuple[int | None, dict[str, Any] | None, str]:
252
+ """The project commands will ACTUALLY use, and where that came from.
253
+
254
+ MUST mirror every tier of `resolve_profile()` — an earlier cut only handled
255
+ ERMES_PROJECT and active_project, so `whoami` printed one project while the
256
+ data commands used a legacy config.yml key. Tiers that resolve through
257
+ config.yml or ERMES_API_KEY carry no project identity and return
258
+ `(None, None, source)`. Returns `(id, project, source)`.
259
+ """
260
+ if profile_name:
261
+ return None, None, f"--profile {profile_name!r} (legacy config.yml, no project identity)"
262
+
263
+ if os.environ.get("ERMES_API_KEY"):
264
+ return None, None, "ERMES_API_KEY (no project identity)"
265
+
266
+ env_project = os.environ.get("ERMES_PROJECT")
267
+ if env_project:
268
+ projects = get_projects()
269
+ if env_project.isdigit():
270
+ pid = int(env_project)
271
+ if pid in projects:
272
+ return pid, projects[pid], "ERMES_PROJECT"
273
+ else:
274
+ for pid, proj in projects.items():
275
+ if proj.get("name", "").lower() == env_project.lower():
276
+ return pid, proj, "ERMES_PROJECT"
277
+ # Unresolvable override: say so rather than silently falling back to the active project,
278
+ # which would display a project the very next command is NOT going to use.
279
+ return None, None, f"ERMES_PROJECT={env_project!r} (NOT FOUND)"
280
+
281
+ active_id, active = get_active_project_id(), get_active_project()
282
+ if active is not None:
283
+ return active_id, active, "projects.yml"
284
+
285
+ env_profile = os.environ.get("ERMES_PROFILE")
286
+ if env_profile:
287
+ return (
288
+ None,
289
+ None,
290
+ f"ERMES_PROFILE={env_profile!r} (legacy config.yml, no project identity)",
291
+ )
292
+ return active_id, active, "projects.yml"
293
+
294
+
295
+ def get_base_url() -> str:
296
+ data = load_projects_file()
297
+ return data.get("base_url") or DEFAULT_BASE_URL
298
+
299
+
300
+ def get_auth0_email() -> str | None:
301
+ data = load_projects_file()
302
+ email = data.get("auth0_email")
303
+ return str(email) if email else None
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Profile resolution — what the CLI / MCP actually consume at runtime
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ def resolve_profile(profile_name: str | None = None) -> dict:
312
+ """Resolve to a `{base_url, api_key}` dict.
313
+
314
+ Resolution order (1.0):
315
+ 1. Explicit `profile_name` arg (always wins; reads legacy config.yml)
316
+ 2. `ERMES_API_KEY` env (+ optional `ERMES_BASE_URL`) — CI / scripts, no login
317
+ 3. `ERMES_PROJECT` env (matches projects.yml by name or id)
318
+ 4. projects.yml `active_project` (the post-`ermes login` happy path)
319
+ 5. `ERMES_PROFILE` env (legacy, deprecated — ranked below the project tiers)
320
+ 6. legacy config.yml `default_profile`
321
+
322
+ A project without a cached key (login no longer mints keys for every project)
323
+ is provisioned on first use via `POST /api/admin/projects/:id/cli-key`.
324
+ Raises `ConfigError` with an actionable message if nothing resolves.
325
+ """
326
+ if profile_name:
327
+ return get_profile(profile_name)
328
+
329
+ env_key = os.environ.get("ERMES_API_KEY")
330
+ if env_key:
331
+ return {
332
+ "base_url": os.environ.get("ERMES_BASE_URL") or get_base_url(),
333
+ "api_key": validate_api_key(env_key, "ERMES_API_KEY"),
334
+ }
335
+
336
+ env_profile = os.environ.get("ERMES_PROFILE")
337
+ if env_profile and not _warned_profile:
338
+ _warned_profile.append(True)
339
+ print(
340
+ "ermes: ERMES_PROFILE is deprecated and now ranks below ERMES_PROJECT and the "
341
+ "active project; use ERMES_PROJECT=<name-or-id> or ERMES_API_KEY.",
342
+ file=sys.stderr,
343
+ )
344
+
345
+ env_project = os.environ.get("ERMES_PROJECT")
346
+ if env_project:
347
+ return _resolve_from_projects(env_project)
348
+
349
+ active_id = get_active_project_id()
350
+ if active_id is not None and active_id in get_projects():
351
+ return {"base_url": get_base_url(), "api_key": _project_key(active_id)}
352
+
353
+ if env_profile:
354
+ return get_profile(env_profile)
355
+
356
+ config = load_config()
357
+ default = config.get("default_profile")
358
+ if default:
359
+ return get_profile(default)
360
+
361
+ raise ConfigError(
362
+ "No active profile. Run `ermes login` to authenticate via Auth0 and "
363
+ "auto-discover your projects, or set ERMES_API_KEY for a scripted session."
364
+ )
365
+
366
+
367
+ def _project_key(project_id: int) -> str:
368
+ """Cached per-admin key for the project, provisioning it on first use."""
369
+ proj = get_projects().get(project_id) or {}
370
+ if proj.get("api_key"):
371
+ return str(proj["api_key"])
372
+ from ermes_cli.commands.auth_cmds import ensure_project_key # lazy: avoids an import cycle
373
+
374
+ return ensure_project_key(project_id)
375
+
376
+
377
+ def _resolve_from_projects(name_or_id: str) -> dict:
378
+ """Look up a project in projects.yml by name OR numeric id."""
379
+ projects = get_projects()
380
+ if name_or_id.isdigit():
381
+ pid = int(name_or_id)
382
+ if pid in projects:
383
+ return {"base_url": get_base_url(), "api_key": _project_key(pid)}
384
+ raise ConfigError(
385
+ f"ERMES_PROJECT={name_or_id}: project id not found in projects.yml"
386
+ )
387
+ for pid, proj in projects.items():
388
+ if proj.get("name", "").lower() == name_or_id.lower():
389
+ return {"base_url": get_base_url(), "api_key": _project_key(pid)}
390
+ raise ConfigError(
391
+ f"ERMES_PROJECT={name_or_id!r} not found in projects.yml — "
392
+ "run `ermes projects refresh`"
393
+ )
ermes_cli/errors.py ADDED
@@ -0,0 +1,34 @@
1
+ """Shared exception hierarchy for ermes-cli.
2
+
3
+ Lives in its own module so `client.py` and `write_mode.py` can both import
4
+ from it without a circular dependency.
5
+
6
+ Hierarchy:
7
+ ErmesAPIError # base — any failure interacting with the API
8
+ WriteBlockedError # client-side guard fired before the request
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ class ErmesAPIError(Exception):
15
+ """Anything that prevents an Ermes API call from succeeding cleanly.
16
+
17
+ `status_code` is the HTTP status when the failure came from the server.
18
+ For pre-flight failures (no network call made) it's None.
19
+ """
20
+
21
+ def __init__(self, message: str, status_code: int | None = None):
22
+ self.status_code = status_code
23
+ super().__init__(message)
24
+
25
+
26
+ class WriteBlockedError(ErmesAPIError):
27
+ """Raised by the write-mode guard before a mutating call hits the network.
28
+
29
+ Inherits from ErmesAPIError so the existing `try/except ErmesAPIError`
30
+ blocks in every command file catch it without modification.
31
+ """
32
+
33
+ def __init__(self, message: str):
34
+ super().__init__(message, status_code=None)
@@ -0,0 +1,263 @@
1
+ # cli/ermes_cli/export_utils.py
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any
7
+
8
+ try:
9
+ from zoneinfo import ZoneInfo
10
+ except ImportError:
11
+ ZoneInfo = None # type: ignore
12
+
13
+ # Pre-compiled patterns
14
+ _LINK_PATTERN = re.compile(r'href=[\'"]?([^\'" >]+)', re.IGNORECASE)
15
+ _HTML_TAG_PATTERN = re.compile(r'<[^>]+>')
16
+ _WHITESPACE_PATTERN = re.compile(r'\s+')
17
+ _EMOJI_PATTERN = re.compile(
18
+ "[" +
19
+ "\U0001F600-\U0001F64F"
20
+ "\U0001F300-\U0001F5FF"
21
+ "\U0001F680-\U0001F6FF"
22
+ "\U0001F900-\U0001F9FF"
23
+ "\U0001FA70-\U0001FAFF"
24
+ "\U00002702-\U000027B0"
25
+ "\U000024C2-\U0001F251"
26
+ "]+",
27
+ flags=re.UNICODE,
28
+ )
29
+
30
+
31
+ def _has_emoji(text: str) -> bool:
32
+ return bool(text and _EMOJI_PATTERN.search(text))
33
+
34
+
35
+ def _count_links(html: str) -> int:
36
+ return len(_LINK_PATTERN.findall(html)) if html else 0
37
+
38
+
39
+ def _word_count(html: str) -> int:
40
+ if not html:
41
+ return 0
42
+ clean = _HTML_TAG_PATTERN.sub(" ", html)
43
+ clean = _WHITESPACE_PATTERN.sub(" ", clean).strip()
44
+ return len(clean.split()) if clean else 0
45
+
46
+
47
+ def _parse_iso_datetime(value: str | None) -> datetime | None:
48
+ """Parse an ISO datetime/date string and normalize naive values to UTC."""
49
+ if not value:
50
+ return None
51
+ try:
52
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
53
+ except (ValueError, TypeError):
54
+ return None
55
+ if dt.tzinfo is None:
56
+ return dt.replace(tzinfo=timezone.utc)
57
+ return dt
58
+
59
+
60
+ def _summarize_lists(items: Any) -> list[dict]:
61
+ if not isinstance(items, list):
62
+ return []
63
+ results = []
64
+ for it in items:
65
+ if not isinstance(it, dict):
66
+ continue
67
+ id_ = it.get("id")
68
+ name = it.get("name")
69
+ type_ = it.get("type")
70
+ if id_ is None or name is None or type_ is None:
71
+ continue
72
+ results.append({"id": id_, "name": name, "type": type_, "users_count": it.get("users_count")})
73
+ return results
74
+
75
+
76
+ def _extract_time_metrics(send_at: str | None, project_timezone: str | None) -> dict | None:
77
+ """Build time metrics from send_at timestamp."""
78
+ dt = _parse_iso_datetime(send_at)
79
+ if dt is None:
80
+ return None
81
+
82
+ metrics_dt = dt
83
+
84
+ if project_timezone and ZoneInfo is not None:
85
+ try:
86
+ local_dt = dt.astimezone(ZoneInfo(project_timezone))
87
+ metrics_dt = local_dt
88
+ except Exception:
89
+ pass
90
+
91
+ metrics: dict[str, Any] = {
92
+ "day_of_week": metrics_dt.strftime("%A"),
93
+ "hour_of_day": dt.hour,
94
+ "month": metrics_dt.strftime("%B"),
95
+ "year": metrics_dt.year,
96
+ }
97
+ if metrics_dt is not dt:
98
+ metrics["local_hour_of_day"] = metrics_dt.hour
99
+ return metrics
100
+
101
+
102
+ def _extract_template_fields(templates: Any) -> dict[str, Any]:
103
+ """Extract locale, from, subject, preheader, mjml, and content_analysis from templates."""
104
+ fields: dict[str, Any] = {}
105
+ if not isinstance(templates, list) or not templates:
106
+ return fields
107
+
108
+ tmpl = templates[0]
109
+ if not isinstance(tmpl, dict):
110
+ return fields
111
+
112
+ locale = tmpl.get("locale")
113
+ if locale:
114
+ fields["locale"] = locale
115
+
116
+ data = tmpl.get("data")
117
+ if not isinstance(data, dict):
118
+ return fields
119
+
120
+ from_data = data.get("from")
121
+ if isinstance(from_data, dict):
122
+ fields["from"] = {"name": from_data.get("name"), "address": from_data.get("address")}
123
+
124
+ subject = data.get("subject", "")
125
+ preheader = data.get("preheader", "")
126
+ fields["subject"] = subject
127
+ fields["preheader"] = preheader
128
+ fields["mjml"] = data.get("mjml", "")
129
+
130
+ html_content = data.get("html", "")
131
+ content_to_analyze = html_content or data.get("mjml", "")
132
+ fields["content_analysis"] = {
133
+ "subject_length": len(subject),
134
+ "preheader_length": len(preheader),
135
+ "subject_has_emoji": _has_emoji(subject),
136
+ "preheader_has_emoji": _has_emoji(preheader),
137
+ "link_count": _count_links(html_content),
138
+ "word_count": _word_count(content_to_analyze),
139
+ }
140
+ return fields
141
+
142
+
143
+ def _summarize_dict(source: Any, keys: list[str]) -> dict | None:
144
+ """Extract a subset of keys from a dict, or return None if source is not a dict."""
145
+ if not isinstance(source, dict):
146
+ return None
147
+ return {k: source.get(k) for k in keys}
148
+
149
+
150
+ def extract_campaign_record(
151
+ campaign: dict, project_timezone: str | None = None
152
+ ) -> dict | None:
153
+ """Extract enriched record from a raw campaign API response.
154
+
155
+ Returns None if the campaign has no delivery data or zero sends.
156
+ """
157
+ delivery = campaign.get("delivery")
158
+ if not isinstance(delivery, dict):
159
+ return None
160
+
161
+ sent = delivery.get("sent", 0)
162
+ if sent <= 0:
163
+ return None
164
+
165
+ opens = delivery.get("opens", 0)
166
+ clicks = delivery.get("clicks", 0)
167
+
168
+ record: dict[str, Any] = {
169
+ "id": campaign.get("id"),
170
+ "name": campaign.get("name"),
171
+ "type": campaign.get("type"),
172
+ "channel": campaign.get("channel"),
173
+ "state": campaign.get("state"),
174
+ "send_at": campaign.get("send_at"),
175
+ "created_at": campaign.get("created_at"),
176
+ "updated_at": campaign.get("updated_at"),
177
+ "deleted_at": campaign.get("deleted_at"),
178
+ "delivery": {"sent": sent, "opens": opens, "clicks": clicks},
179
+ "analytics": {
180
+ "open_rate": round((opens / sent) * 100, 2),
181
+ "click_rate": round((clicks / sent) * 100, 2),
182
+ "click_to_open_rate": round((clicks / opens) * 100, 2) if opens > 0 else 0,
183
+ },
184
+ "tags": campaign.get("tags", []),
185
+ }
186
+
187
+ provider = _summarize_dict(campaign.get("provider"), ["id", "type", "name", "group"])
188
+ if provider:
189
+ record["provider"] = provider
190
+
191
+ subscription = _summarize_dict(campaign.get("subscription"), ["id", "name", "channel"])
192
+ if subscription:
193
+ record["subscription"] = subscription
194
+
195
+ lists_summary = _summarize_lists(campaign.get("lists"))
196
+ if lists_summary:
197
+ record["lists"] = lists_summary
198
+ exclusion_summary = _summarize_lists(campaign.get("exclusion_lists"))
199
+ if exclusion_summary:
200
+ record["exclusion_lists"] = exclusion_summary
201
+
202
+ time_metrics = _extract_time_metrics(campaign.get("send_at"), project_timezone)
203
+ if time_metrics:
204
+ record["time_metrics"] = time_metrics
205
+
206
+ record.update(_extract_template_fields(campaign.get("templates")))
207
+
208
+ return record
209
+
210
+
211
+ def is_within_date_range(
212
+ date_str: str | None,
213
+ *,
214
+ days: int | None = None,
215
+ after: str | None = None,
216
+ before: str | None = None,
217
+ ) -> bool:
218
+ """Check if a date string falls within the given range.
219
+
220
+ Supports three modes:
221
+ - days: last N days from now
222
+ - after/before: explicit ISO date boundaries
223
+ - combined: after + before for a window
224
+ """
225
+ dt = _parse_iso_datetime(date_str)
226
+ if dt is None:
227
+ return False
228
+
229
+ if days is not None:
230
+ threshold = datetime.now(timezone.utc) - timedelta(days=days)
231
+ if dt < threshold:
232
+ return False
233
+
234
+ if after is not None:
235
+ after_dt = _parse_iso_datetime(after)
236
+ if after_dt is None:
237
+ return False
238
+ if dt < after_dt:
239
+ return False
240
+
241
+ if before is not None:
242
+ before_dt = _parse_iso_datetime(before)
243
+ if before_dt is None:
244
+ return False
245
+ if dt > before_dt:
246
+ return False
247
+
248
+ return True
249
+
250
+
251
+ def sanitize_filename(subject: str, date_str: str) -> str:
252
+ """Create a sanitized filename from subject and date."""
253
+ date_obj = _parse_iso_datetime(date_str)
254
+ date_formatted = date_obj.strftime("%Y-%m-%d") if date_obj else "unknown-date"
255
+
256
+ clean = re.sub(r"[^\w\s-]", "", subject)
257
+ clean = re.sub(r"[-\s]+", "-", clean)
258
+ clean = clean.strip("-").lower()
259
+ if len(clean) > 50:
260
+ clean = clean[:50].rstrip("-")
261
+
262
+ return f"blast_{date_formatted}_{clean}.html" if clean else f"blast_{date_formatted}.html"
263
+