maildeno 2.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.
- maildeno/__init__.py +47 -0
- maildeno/_async_client.py +243 -0
- maildeno/_cache.py +291 -0
- maildeno/_client.py +276 -0
- maildeno/_error.py +115 -0
- maildeno/_internal.py +96 -0
- maildeno/_minify.py +74 -0
- maildeno/_renderer.py +213 -0
- maildeno/_types.py +144 -0
- maildeno/engine.wasm +0 -0
- maildeno/internal.py +96 -0
- maildeno/py.typed +0 -0
- maildeno-2.0.0.dist-info/METADATA +974 -0
- maildeno-2.0.0.dist-info/RECORD +16 -0
- maildeno-2.0.0.dist-info/WHEEL +4 -0
- maildeno-2.0.0.dist-info/licenses/LICENSE +21 -0
maildeno/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Official Python SDK for the Maildeno render API.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from maildeno import MaildenoClient
|
|
6
|
+
|
|
7
|
+
client = MaildenoClient(api_key="sk_live_...")
|
|
8
|
+
html = client.render_html("550e8400-e29b-41d4-a716-446655440000")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
from ._async_client import AsyncMaildenoClient
|
|
14
|
+
from ._client import MaildenoClient
|
|
15
|
+
from ._error import MaildenoError
|
|
16
|
+
from ._types import (
|
|
17
|
+
CacheConfig,
|
|
18
|
+
ContextValue,
|
|
19
|
+
DynamicData,
|
|
20
|
+
MergeTagGroup,
|
|
21
|
+
RenderResult,
|
|
22
|
+
RenderTarget,
|
|
23
|
+
SdkErrorCode,
|
|
24
|
+
TemplateJson,
|
|
25
|
+
ValidationIssue,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
__version__ = version("maildeno")
|
|
30
|
+
except PackageNotFoundError:
|
|
31
|
+
__version__ = "unknown"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AsyncMaildenoClient",
|
|
35
|
+
"CacheConfig",
|
|
36
|
+
"ContextValue",
|
|
37
|
+
"DynamicData",
|
|
38
|
+
"MaildenoClient",
|
|
39
|
+
"MaildenoError",
|
|
40
|
+
"MergeTagGroup",
|
|
41
|
+
"RenderResult",
|
|
42
|
+
"RenderTarget",
|
|
43
|
+
"SdkErrorCode",
|
|
44
|
+
"TemplateJson",
|
|
45
|
+
"ValidationIssue",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Asynchronous Maildeno client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import warnings
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import List, Optional, Type
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._cache import build_cache
|
|
14
|
+
from ._minify import minify_output
|
|
15
|
+
from ._error import MaildenoError
|
|
16
|
+
from ._internal import (
|
|
17
|
+
DEFAULT_TIMEOUT,
|
|
18
|
+
TEMPLATE_PATH,
|
|
19
|
+
build_headers,
|
|
20
|
+
map_transport_error,
|
|
21
|
+
normalise_base_url,
|
|
22
|
+
normalise_dynamic_data,
|
|
23
|
+
parse_template_response,
|
|
24
|
+
raise_for_response,
|
|
25
|
+
)
|
|
26
|
+
from ._renderer import render_template
|
|
27
|
+
from ._types import CacheConfig, DynamicData, RenderResult, RenderTarget, TemplateJson
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncMaildenoClient:
|
|
31
|
+
"""Asynchronous Maildeno client. Mirrors :class:`MaildenoClient`.
|
|
32
|
+
|
|
33
|
+
Template JSON is fetched asynchronously, cached locally, and rendered
|
|
34
|
+
via the embedded Wasm engine in a thread-pool executor so the event
|
|
35
|
+
loop is never blocked.
|
|
36
|
+
|
|
37
|
+
Example::
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
from maildeno import AsyncMaildenoClient
|
|
41
|
+
|
|
42
|
+
async def main():
|
|
43
|
+
async with AsyncMaildenoClient(api_key="sk_live_...") as client:
|
|
44
|
+
html = await client.render_html("550e8400-...")
|
|
45
|
+
print(html)
|
|
46
|
+
|
|
47
|
+
asyncio.run(main())
|
|
48
|
+
|
|
49
|
+
For FastAPI / Starlette / aiohttp, instantiate one client per process
|
|
50
|
+
at startup and reuse it across all requests.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
api_key: str,
|
|
56
|
+
*,
|
|
57
|
+
base_url: Optional[str] = None,
|
|
58
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
59
|
+
cache: Optional[CacheConfig] = None,
|
|
60
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
if not api_key:
|
|
63
|
+
raise MaildenoError("INVALID_API_KEY", "api_key is required.")
|
|
64
|
+
|
|
65
|
+
self._api_key = api_key
|
|
66
|
+
self._base_url = normalise_base_url(base_url)
|
|
67
|
+
self._timeout = timeout
|
|
68
|
+
self._cache = build_cache(cache) # type: ignore[arg-type]
|
|
69
|
+
|
|
70
|
+
if http_client is not None:
|
|
71
|
+
self._http = http_client
|
|
72
|
+
self._owns_http = False
|
|
73
|
+
else:
|
|
74
|
+
self._http = httpx.AsyncClient(timeout=timeout)
|
|
75
|
+
self._owns_http = True
|
|
76
|
+
|
|
77
|
+
# ── Render ────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async def render(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
template_id: str,
|
|
83
|
+
target: RenderTarget = "html",
|
|
84
|
+
dynamic_data: Optional[DynamicData] = None,
|
|
85
|
+
) -> RenderResult:
|
|
86
|
+
"""Render a template to HTML, React Email TSX, or MJML.
|
|
87
|
+
|
|
88
|
+
The Wasm engine is synchronous; it runs in a thread-pool executor so
|
|
89
|
+
the event loop is never blocked.
|
|
90
|
+
|
|
91
|
+
:param template_id: UUID of the template (required).
|
|
92
|
+
:param target: ``"html"`` | ``"react-email"`` | ``"mjml"``.
|
|
93
|
+
:param dynamic_data: Merge tags + visibility context (fully optional).
|
|
94
|
+
:raises MaildenoError: On any API or render failure.
|
|
95
|
+
"""
|
|
96
|
+
template, from_stale = await self._get_template(template_id, target)
|
|
97
|
+
|
|
98
|
+
norm_data: Optional[DynamicData] = None
|
|
99
|
+
if dynamic_data is not None:
|
|
100
|
+
raw = normalise_dynamic_data(dynamic_data)
|
|
101
|
+
if raw:
|
|
102
|
+
norm_data = raw # type: ignore[assignment]
|
|
103
|
+
|
|
104
|
+
# Run the synchronous Wasm render in a thread to avoid blocking the
|
|
105
|
+
# event loop. functools.partial binds the arguments cleanly.
|
|
106
|
+
loop = asyncio.get_event_loop()
|
|
107
|
+
raw_output: str = await loop.run_in_executor(
|
|
108
|
+
None,
|
|
109
|
+
functools.partial(render_template, template, target, norm_data),
|
|
110
|
+
)
|
|
111
|
+
output = minify_output(target, raw_output)
|
|
112
|
+
|
|
113
|
+
return RenderResult(
|
|
114
|
+
template_id=template_id,
|
|
115
|
+
target=target,
|
|
116
|
+
output=output,
|
|
117
|
+
from_stale_cache=from_stale,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def render_html(
|
|
121
|
+
self,
|
|
122
|
+
template_id: str,
|
|
123
|
+
dynamic_data: Optional[DynamicData] = None,
|
|
124
|
+
) -> str:
|
|
125
|
+
"""Convenience: render to HTML, returning the output string directly."""
|
|
126
|
+
return (
|
|
127
|
+
await self.render(
|
|
128
|
+
template_id=template_id, target="html", dynamic_data=dynamic_data
|
|
129
|
+
)
|
|
130
|
+
).output
|
|
131
|
+
|
|
132
|
+
async def render_react(
|
|
133
|
+
self,
|
|
134
|
+
template_id: str,
|
|
135
|
+
dynamic_data: Optional[DynamicData] = None,
|
|
136
|
+
) -> str:
|
|
137
|
+
"""Convenience: render to React Email TSX."""
|
|
138
|
+
return (
|
|
139
|
+
await self.render(
|
|
140
|
+
template_id=template_id, target="react-email", dynamic_data=dynamic_data
|
|
141
|
+
)
|
|
142
|
+
).output
|
|
143
|
+
|
|
144
|
+
async def render_mjml(
|
|
145
|
+
self,
|
|
146
|
+
template_id: str,
|
|
147
|
+
dynamic_data: Optional[DynamicData] = None,
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Convenience: render to MJML."""
|
|
150
|
+
return (
|
|
151
|
+
await self.render(
|
|
152
|
+
template_id=template_id, target="mjml", dynamic_data=dynamic_data
|
|
153
|
+
)
|
|
154
|
+
).output
|
|
155
|
+
|
|
156
|
+
# ── Cache management ──────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def list_cached(self) -> List[str]:
|
|
159
|
+
"""Return the IDs of all templates currently in the cache."""
|
|
160
|
+
return self._cache.list()
|
|
161
|
+
|
|
162
|
+
def delete_cached(self, template_id: str) -> None:
|
|
163
|
+
"""Remove a single template from the cache."""
|
|
164
|
+
self._cache.invalidate(template_id)
|
|
165
|
+
|
|
166
|
+
def clear_cache(self) -> None:
|
|
167
|
+
"""Remove all templates from the cache."""
|
|
168
|
+
self._cache.clear()
|
|
169
|
+
|
|
170
|
+
def invalidate(self, template_id: str) -> None:
|
|
171
|
+
"""Deprecated — use :meth:`delete_cached` instead."""
|
|
172
|
+
warnings.warn(
|
|
173
|
+
"invalidate() is deprecated; use delete_cached() instead.",
|
|
174
|
+
DeprecationWarning,
|
|
175
|
+
stacklevel=2,
|
|
176
|
+
)
|
|
177
|
+
self.delete_cached(template_id)
|
|
178
|
+
|
|
179
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
async def aclose(self) -> None:
|
|
182
|
+
"""Close the underlying HTTP client.
|
|
183
|
+
|
|
184
|
+
Only closes the client if this :class:`AsyncMaildenoClient` created it.
|
|
185
|
+
"""
|
|
186
|
+
if self._owns_http:
|
|
187
|
+
await self._http.aclose()
|
|
188
|
+
|
|
189
|
+
async def __aenter__(self) -> AsyncMaildenoClient:
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
async def __aexit__(
|
|
193
|
+
self,
|
|
194
|
+
exc_type: Optional[Type[BaseException]],
|
|
195
|
+
exc: Optional[BaseException],
|
|
196
|
+
tb: Optional[TracebackType],
|
|
197
|
+
) -> None:
|
|
198
|
+
await self.aclose()
|
|
199
|
+
|
|
200
|
+
# ── Template fetching ─────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async def _get_template(
|
|
203
|
+
self,
|
|
204
|
+
template_id: str,
|
|
205
|
+
target: RenderTarget,
|
|
206
|
+
) -> tuple[TemplateJson, bool]:
|
|
207
|
+
"""Return (template, from_stale_cache)."""
|
|
208
|
+
fresh = self._cache.get_fresh(template_id)
|
|
209
|
+
if fresh is not None:
|
|
210
|
+
return fresh, False
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
template = await self._fetch_template(template_id, target)
|
|
214
|
+
self._cache.set(template_id, template)
|
|
215
|
+
return template, False
|
|
216
|
+
except MaildenoError:
|
|
217
|
+
stale = self._cache.get_fallback(template_id)
|
|
218
|
+
if stale is not None:
|
|
219
|
+
return stale, True
|
|
220
|
+
raise
|
|
221
|
+
|
|
222
|
+
async def _fetch_template(
|
|
223
|
+
self,
|
|
224
|
+
template_id: str,
|
|
225
|
+
target: RenderTarget,
|
|
226
|
+
) -> TemplateJson:
|
|
227
|
+
url = f"{self._base_url}{TEMPLATE_PATH}/{template_id}?target={target}"
|
|
228
|
+
try:
|
|
229
|
+
response = await self._http.get(
|
|
230
|
+
url,
|
|
231
|
+
headers=build_headers(self._api_key),
|
|
232
|
+
timeout=self._timeout,
|
|
233
|
+
)
|
|
234
|
+
except httpx.HTTPError as exc:
|
|
235
|
+
raise map_transport_error(exc) from exc
|
|
236
|
+
except Exception as exc: # pragma: no cover
|
|
237
|
+
raise map_transport_error(exc) from exc
|
|
238
|
+
|
|
239
|
+
raise_for_response(response)
|
|
240
|
+
return parse_template_response(response.json())
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = ["AsyncMaildenoClient"]
|
maildeno/_cache.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Template cache — memory and disk strategies.
|
|
2
|
+
|
|
3
|
+
Storage layout (disk mode):
|
|
4
|
+
|
|
5
|
+
{cache_dir}/
|
|
6
|
+
a7f4b181-a366-4944-a371-e7b941a3c5ab.json
|
|
7
|
+
9ec0c043-e8a1-4a68-bbb3-92fbef1ea222.json
|
|
8
|
+
|
|
9
|
+
Each file contains::
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"template_id": "...",
|
|
13
|
+
"fetched_at": 1717776000000,
|
|
14
|
+
"ttl": 300000,
|
|
15
|
+
"template": { ...TemplateJson... }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
The file name is the UUID as-is. UUIDs contain only [0-9a-f-] which is safe
|
|
19
|
+
on every modern filesystem. Any other character is replaced with ``_`` as a
|
|
20
|
+
defensive guard — this never fires for real Maildeno template IDs.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
from abc import ABC, abstractmethod
|
|
29
|
+
from typing import Any, Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
from ._types import TemplateJson
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Shared helpers ────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def _now_ms() -> int:
|
|
37
|
+
return int(time.time() * 1000)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_stale(fetched_at: int, ttl: int) -> bool:
|
|
41
|
+
return _now_ms() - fetched_at > ttl
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Strategy interface ────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
class _CacheStore(ABC):
|
|
47
|
+
"""All cache implementations must satisfy this interface."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def get_fresh(self, template_id: str) -> Optional[TemplateJson]:
|
|
51
|
+
"""Return the template only if it is within TTL, else None."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def get_fallback(self, template_id: str) -> Optional[TemplateJson]:
|
|
55
|
+
"""Return the template regardless of staleness, or None if absent."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def set(self, template_id: str, template: TemplateJson) -> None:
|
|
59
|
+
"""Store or overwrite a template entry."""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def invalidate(self, template_id: str) -> None:
|
|
63
|
+
"""Remove a single entry. No-op if absent."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def clear(self) -> None:
|
|
67
|
+
"""Remove all entries."""
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def list(self) -> List[str]:
|
|
71
|
+
"""Return the IDs of all currently cached templates."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Memory store ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
class _MemoryStore(_CacheStore):
|
|
77
|
+
"""In-process dict cache with TTL + stale-on-error fallback.
|
|
78
|
+
|
|
79
|
+
Lost on process restart. Zero I/O. Every read is O(1).
|
|
80
|
+
Oldest entry evicted when ``max_entries`` is reached.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, ttl: int, max_entries: int) -> None:
|
|
84
|
+
self._ttl = ttl
|
|
85
|
+
self._max = max_entries
|
|
86
|
+
# {template_id: {"template": ..., "fetched_at": int, "ttl": int}}
|
|
87
|
+
self._store: Dict[str, Dict[str, Any]] = {}
|
|
88
|
+
|
|
89
|
+
def get_fresh(self, template_id: str) -> Optional[TemplateJson]:
|
|
90
|
+
entry = self._store.get(template_id)
|
|
91
|
+
if entry is None:
|
|
92
|
+
return None
|
|
93
|
+
if _is_stale(entry["fetched_at"], entry["ttl"]):
|
|
94
|
+
return None
|
|
95
|
+
return entry["template"] # type: ignore[return-value]
|
|
96
|
+
|
|
97
|
+
def get_fallback(self, template_id: str) -> Optional[TemplateJson]:
|
|
98
|
+
entry = self._store.get(template_id)
|
|
99
|
+
return entry["template"] if entry else None # type: ignore[return-value]
|
|
100
|
+
|
|
101
|
+
def set(self, template_id: str, template: TemplateJson) -> None:
|
|
102
|
+
if len(self._store) >= self._max and template_id not in self._store:
|
|
103
|
+
# Evict oldest entry
|
|
104
|
+
oldest = min(self._store, key=lambda k: self._store[k]["fetched_at"])
|
|
105
|
+
del self._store[oldest]
|
|
106
|
+
self._store[template_id] = {
|
|
107
|
+
"template": template,
|
|
108
|
+
"fetched_at": _now_ms(),
|
|
109
|
+
"ttl": self._ttl,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def invalidate(self, template_id: str) -> None:
|
|
113
|
+
self._store.pop(template_id, None)
|
|
114
|
+
|
|
115
|
+
def clear(self) -> None:
|
|
116
|
+
self._store.clear()
|
|
117
|
+
|
|
118
|
+
def list(self) -> List[str]:
|
|
119
|
+
return list(self._store.keys())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Disk store ────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
class _DiskStore(_CacheStore):
|
|
125
|
+
"""Persistent JSON-file cache.
|
|
126
|
+
|
|
127
|
+
Each template is stored as a single minified JSON file named after its
|
|
128
|
+
UUID. Survives process restarts. All reads/writes are synchronous — both
|
|
129
|
+
the sync and async clients call this from a thread (the async client uses
|
|
130
|
+
``run_in_executor`` for the whole render pipeline).
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self, cache_dir: str, ttl: int, max_entries: int) -> None:
|
|
134
|
+
self._dir = cache_dir
|
|
135
|
+
self._ttl = ttl
|
|
136
|
+
self._max = max_entries
|
|
137
|
+
|
|
138
|
+
# ── Reads ──────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def get_fresh(self, template_id: str) -> Optional[TemplateJson]:
|
|
141
|
+
entry = self._read(template_id)
|
|
142
|
+
if entry is None:
|
|
143
|
+
return None
|
|
144
|
+
if _is_stale(entry["fetched_at"], entry["ttl"]):
|
|
145
|
+
return None
|
|
146
|
+
return entry["template"] # type: ignore[return-value]
|
|
147
|
+
|
|
148
|
+
def get_fallback(self, template_id: str) -> Optional[TemplateJson]:
|
|
149
|
+
entry = self._read(template_id)
|
|
150
|
+
return entry["template"] if entry else None # type: ignore[return-value]
|
|
151
|
+
|
|
152
|
+
# ── Writes ─────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def set(self, template_id: str, template: TemplateJson) -> None:
|
|
155
|
+
os.makedirs(self._dir, exist_ok=True)
|
|
156
|
+
self._enforce_limit()
|
|
157
|
+
|
|
158
|
+
entry = {
|
|
159
|
+
"template_id": template_id,
|
|
160
|
+
"fetched_at": _now_ms(),
|
|
161
|
+
"ttl": self._ttl,
|
|
162
|
+
"template": template,
|
|
163
|
+
}
|
|
164
|
+
final = self._path(template_id)
|
|
165
|
+
tmp = final + ".tmp"
|
|
166
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
167
|
+
# Minified — no indent. 58 KB → 22 KB.
|
|
168
|
+
json.dump(entry, fh, separators=(",", ":"))
|
|
169
|
+
os.replace(tmp, final) # atomic on POSIX; best-effort on Windows
|
|
170
|
+
|
|
171
|
+
def invalidate(self, template_id: str) -> None:
|
|
172
|
+
try:
|
|
173
|
+
os.unlink(self._path(template_id))
|
|
174
|
+
except FileNotFoundError:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
def clear(self) -> None:
|
|
178
|
+
try:
|
|
179
|
+
files = os.listdir(self._dir)
|
|
180
|
+
except FileNotFoundError:
|
|
181
|
+
return
|
|
182
|
+
for f in files:
|
|
183
|
+
if f.endswith(".json") and not f.endswith(".tmp"):
|
|
184
|
+
try:
|
|
185
|
+
os.unlink(os.path.join(self._dir, f))
|
|
186
|
+
except OSError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def list(self) -> List[str]:
|
|
190
|
+
try:
|
|
191
|
+
files = os.listdir(self._dir)
|
|
192
|
+
except FileNotFoundError:
|
|
193
|
+
return []
|
|
194
|
+
return [
|
|
195
|
+
f[:-5] # strip ".json"
|
|
196
|
+
for f in files
|
|
197
|
+
if f.endswith(".json") and not f.endswith(".tmp")
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
# ── Internals ──────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def _path(self, template_id: str) -> str:
|
|
203
|
+
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in template_id)
|
|
204
|
+
return os.path.join(self._dir, f"{safe}.json")
|
|
205
|
+
|
|
206
|
+
def _read(self, template_id: str) -> Optional[Dict[str, Any]]:
|
|
207
|
+
try:
|
|
208
|
+
with open(self._path(template_id), encoding="utf-8") as fh:
|
|
209
|
+
return json.load(fh) # type: ignore[no-any-return]
|
|
210
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
def _enforce_limit(self) -> None:
|
|
214
|
+
try:
|
|
215
|
+
files = [
|
|
216
|
+
f for f in os.listdir(self._dir)
|
|
217
|
+
if f.endswith(".json") and not f.endswith(".tmp")
|
|
218
|
+
]
|
|
219
|
+
except FileNotFoundError:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
if len(files) < self._max:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Read fetchedAt from each file to find the oldest
|
|
226
|
+
entries = []
|
|
227
|
+
for f in files:
|
|
228
|
+
try:
|
|
229
|
+
with open(os.path.join(self._dir, f), encoding="utf-8") as fh:
|
|
230
|
+
data = json.load(fh)
|
|
231
|
+
entries.append((f, data.get("fetched_at", 0)))
|
|
232
|
+
except (OSError, json.JSONDecodeError):
|
|
233
|
+
entries.append((f, 0))
|
|
234
|
+
|
|
235
|
+
entries.sort(key=lambda x: x[1])
|
|
236
|
+
to_evict = entries[: len(entries) - self._max + 1]
|
|
237
|
+
for fname, _ in to_evict:
|
|
238
|
+
try:
|
|
239
|
+
os.unlink(os.path.join(self._dir, fname))
|
|
240
|
+
except OSError:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ── Public facade ─────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
_DEFAULT_TTL = 300_000 # 5 minutes
|
|
247
|
+
_DEFAULT_MAX_ENTRIES = 50
|
|
248
|
+
_DEFAULT_CACHE_PATH = ".maildeno-cache"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TemplateCache:
|
|
252
|
+
"""Thin facade over the active ``_CacheStore``.
|
|
253
|
+
|
|
254
|
+
Both clients delegate every cache operation here so they never reference
|
|
255
|
+
``_MemoryStore`` or ``_DiskStore`` directly.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def __init__(self, store: _CacheStore) -> None:
|
|
259
|
+
self._store = store
|
|
260
|
+
|
|
261
|
+
def get_fresh(self, template_id: str) -> Optional[TemplateJson]:
|
|
262
|
+
return self._store.get_fresh(template_id)
|
|
263
|
+
|
|
264
|
+
def get_fallback(self, template_id: str) -> Optional[TemplateJson]:
|
|
265
|
+
return self._store.get_fallback(template_id)
|
|
266
|
+
|
|
267
|
+
def set(self, template_id: str, template: TemplateJson) -> None:
|
|
268
|
+
self._store.set(template_id, template)
|
|
269
|
+
|
|
270
|
+
def invalidate(self, template_id: str) -> None:
|
|
271
|
+
self._store.invalidate(template_id)
|
|
272
|
+
|
|
273
|
+
def clear(self) -> None:
|
|
274
|
+
self._store.clear()
|
|
275
|
+
|
|
276
|
+
def list(self) -> List[str]:
|
|
277
|
+
return self._store.list()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def build_cache(config: Optional[Dict[str, Any]]) -> TemplateCache:
|
|
281
|
+
"""Build a :class:`TemplateCache` from a raw config dict or ``None``."""
|
|
282
|
+
cfg = config or {}
|
|
283
|
+
ttl = int(cfg.get("ttl", _DEFAULT_TTL))
|
|
284
|
+
max_entries = int(cfg.get("max_entries", _DEFAULT_MAX_ENTRIES))
|
|
285
|
+
|
|
286
|
+
if cfg.get("type") == "disk":
|
|
287
|
+
raw_path = cfg.get("path", _DEFAULT_CACHE_PATH)
|
|
288
|
+
resolved = os.path.abspath(raw_path)
|
|
289
|
+
return TemplateCache(_DiskStore(resolved, ttl, max_entries))
|
|
290
|
+
|
|
291
|
+
return TemplateCache(_MemoryStore(ttl, max_entries))
|