listmonk-api 0.6.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.
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env python
2
+
3
+ import importlib
4
+ import inspect
5
+ from typing import Any
6
+ import warnings
7
+
8
+ # Suppress RequestsDependencyWarning due to chardet 6.x / requests 2.32.x mismatch
9
+ # Centralized here to ensure it runs before any sub-package imports
10
+ warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
11
+
12
+ __all__: list[str] = []
13
+
14
+ CORE_MODULES: list[str] = [
15
+ "listmonk_api.api_client",
16
+ "listmonk_api.models",
17
+ ]
18
+
19
+ OPTIONAL_MODULES = {
20
+ "listmonk_api.agent_server": "agent_server",
21
+ "listmonk_api.mcp_server": "mcp_server",
22
+ }
23
+
24
+
25
+ def _expose_members(module):
26
+ """Expose public classes and functions from a module into globals and __all__."""
27
+ for name, obj in inspect.getmembers(module):
28
+ if (inspect.isclass(obj) or inspect.isfunction(obj)) and not name.startswith(
29
+ "_"
30
+ ):
31
+ globals()[name] = obj
32
+ if name not in __all__:
33
+ __all__.append(name)
34
+
35
+
36
+ # Eagerly import core modules (keeps API wrappers fast & light)
37
+ for module_name in CORE_MODULES:
38
+ if module_name:
39
+ module = importlib.import_module(module_name)
40
+ _expose_members(module)
41
+
42
+ # Dynamic/lazy loading of optional modules (agent_server, mcp_server)
43
+ _loaded_optional_modules = {}
44
+
45
+
46
+ def _import_module_safely(module_name: str):
47
+ """Try to import a module and return it, or None if not available."""
48
+ try:
49
+ return importlib.import_module(module_name)
50
+ except ImportError:
51
+ return None
52
+
53
+
54
+ def __getattr__(name: str) -> Any:
55
+ # Handle availability flags dynamically without eager imports
56
+ if name == "_MCP_AVAILABLE":
57
+ mcp_key = next((k for k in OPTIONAL_MODULES if "mcp_server" in k), None)
58
+ if mcp_key:
59
+ return _import_module_safely(mcp_key) is not None
60
+ return False
61
+ if name == "_AGENT_AVAILABLE":
62
+ agent_key = next((k for k in OPTIONAL_MODULES if "agent_server" in k), None)
63
+ if agent_key:
64
+ return _import_module_safely(agent_key) is not None
65
+ return False
66
+
67
+ # Check optional modules
68
+ for module_name in OPTIONAL_MODULES:
69
+ if module_name not in _loaded_optional_modules:
70
+ module = _import_module_safely(module_name)
71
+ if module is not None:
72
+ _loaded_optional_modules[module_name] = module
73
+ _expose_members(module)
74
+
75
+ module = _loaded_optional_modules.get(module_name)
76
+ if module is not None and hasattr(module, name):
77
+ return getattr(module, name)
78
+
79
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
80
+
81
+
82
+ def __dir__() -> list[str]:
83
+ return sorted(list(globals().keys()) + __all__)
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/python
2
+ # coding: utf-8
3
+ from listmonk_api.agent_server import agent_server
4
+
5
+ if __name__ == "__main__":
6
+ agent_server()
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/python
2
+ import logging
3
+ import os
4
+ import sys
5
+ import warnings
6
+
7
+ __version__ = "0.6.0"
8
+
9
+ logging.basicConfig(
10
+ level=logging.INFO,
11
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
12
+ handlers=[logging.StreamHandler()],
13
+ )
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ DEFAULT_AGENT_NAME = None
18
+ DEFAULT_AGENT_DESCRIPTION = None
19
+ DEFAULT_AGENT_SYSTEM_PROMPT = None
20
+
21
+
22
+ def agent_server():
23
+ from agent_utilities import (
24
+ build_system_prompt_from_workspace,
25
+ create_agent_parser,
26
+ create_agent_server,
27
+ initialize_workspace,
28
+ load_identity,
29
+ )
30
+
31
+ global DEFAULT_AGENT_NAME, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_SYSTEM_PROMPT
32
+ initialize_workspace()
33
+ meta = load_identity()
34
+ DEFAULT_AGENT_NAME = os.getenv(
35
+ "DEFAULT_AGENT_NAME", meta.get("name", "Listmonk Api")
36
+ )
37
+ DEFAULT_AGENT_DESCRIPTION = os.getenv(
38
+ "AGENT_DESCRIPTION",
39
+ meta.get(
40
+ "description",
41
+ "AI agent for Listmonk Api management.",
42
+ ),
43
+ )
44
+ DEFAULT_AGENT_SYSTEM_PROMPT = os.getenv(
45
+ "AGENT_SYSTEM_PROMPT",
46
+ meta.get("content") or build_system_prompt_from_workspace(),
47
+ )
48
+
49
+ warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
50
+ warnings.filterwarnings("ignore", category=DeprecationWarning, module="fastmcp")
51
+
52
+ print(f"{DEFAULT_AGENT_NAME} v{__version__}", file=sys.stderr)
53
+ parser = create_agent_parser()
54
+ args = parser.parse_args()
55
+
56
+ if args.debug:
57
+ logging.getLogger().setLevel(logging.DEBUG)
58
+ logger.debug("Debug mode enabled")
59
+
60
+ # Start server using the auto-discovery pattern (from mcp_config.json)
61
+ create_agent_server(
62
+ mcp_url=args.mcp_url,
63
+ mcp_config=args.mcp_config or "mcp_config.json",
64
+ host=args.host,
65
+ port=args.port,
66
+ provider=args.provider,
67
+ model_id=args.model_id,
68
+ router_model=args.model_id,
69
+ agent_model=args.model_id,
70
+ base_url=args.base_url,
71
+ api_key=args.api_key,
72
+ custom_skills_directory=args.custom_skills_directory,
73
+ enable_web_ui=args.web,
74
+ enable_otel=args.otel,
75
+ otel_endpoint=args.otel_endpoint,
76
+ otel_headers=args.otel_headers,
77
+ otel_public_key=args.otel_public_key,
78
+ otel_secret_key=args.otel_secret_key,
79
+ otel_protocol=args.otel_protocol,
80
+ debug=args.debug,
81
+ )
82
+
83
+
84
+ if __name__ == "__main__":
85
+ agent_server()
@@ -0,0 +1,300 @@
1
+ from typing import Optional, List, Dict, Union
2
+ import requests
3
+
4
+ from listmonk_api.models import (
5
+ SubscriberCreateRequest,
6
+ ListCreateRequest,
7
+ ListEditRequest,
8
+ ImportSubscribersRequest,
9
+ CampaignCreateRequest,
10
+ CampaignStatusRequest,
11
+ MediaUploadRequest,
12
+ TransactionalMessageRequest,
13
+ )
14
+
15
+
16
+ class ListmonkAPI:
17
+ """API client for Listmonk."""
18
+
19
+ def __init__(self, url: str, token: str):
20
+ self.base_url = url
21
+ self._session = requests.Session()
22
+ self._session.headers.update(
23
+ {
24
+ "Authorization": f"Bearer {token}",
25
+ "Content-Type": "application/json",
26
+ }
27
+ )
28
+
29
+ def get(self, endpoint: str, **kwargs):
30
+ url = self.base_url.rstrip("/") + "/" + endpoint.lstrip("/")
31
+ response = self._session.get(url, **kwargs)
32
+ response.raise_for_status()
33
+ return response
34
+
35
+ def post(self, endpoint: str, **kwargs):
36
+ url = self.base_url.rstrip("/") + "/" + endpoint.lstrip("/")
37
+ response = self._session.post(url, **kwargs)
38
+ response.raise_for_status()
39
+ return response
40
+
41
+ def put(self, endpoint: str, **kwargs):
42
+ url = self.base_url.rstrip("/") + "/" + endpoint.lstrip("/")
43
+ response = self._session.put(url, **kwargs)
44
+ response.raise_for_status()
45
+ return response
46
+
47
+ def delete(self, endpoint: str, **kwargs):
48
+ url = self.base_url.rstrip("/") + "/" + endpoint.lstrip("/")
49
+ response = self._session.delete(url, **kwargs)
50
+ response.raise_for_status()
51
+ return response
52
+
53
+ # ------------------------------------------------------------------------------------------------------------------
54
+ # Subscribers API
55
+ # ------------------------------------------------------------------------------------------------------------------
56
+ def get_subscribers(
57
+ self,
58
+ query: Optional[Dict] = None,
59
+ list_id: Optional[Union[int, List[int]]] = None,
60
+ max_pages: int = 0,
61
+ per_page: int = 100,
62
+ ):
63
+ # Fetch the first page to get X-Total-Pages
64
+ response = self.get(f"/subscribers?per_page={per_page}")
65
+ # BUG FIX 1: Safely get X-Total-Pages, defaulting to 1 if not present
66
+ total_pages = int(response.headers.get("X-Total-Pages", 1))
67
+
68
+ results = []
69
+ subscriber_filter = f"?per_page={per_page}"
70
+
71
+ if list_id:
72
+ if isinstance(list_id, list):
73
+ for single_list_id in list_id:
74
+ subscriber_filter += f"&list_id={single_list_id}"
75
+ else:
76
+ subscriber_filter += f"&list_id={list_id}"
77
+
78
+ if max_pages == 0 or max_pages > total_pages:
79
+ max_pages = total_pages
80
+
81
+ data = None
82
+ if query:
83
+ data = query
84
+
85
+ for page in range(1, max_pages + 1):
86
+ # Listmonk usually uses 1-indexed pages for its endpoints like `?page=1`
87
+ response_page = self.get(
88
+ f"/subscribers{subscriber_filter}&page={page}", json=data
89
+ )
90
+ response_json = response_page.json()
91
+ if isinstance(response_json, dict) and "data" in response_json:
92
+ results.extend(response_json["data"].get("results", []))
93
+ elif isinstance(response_json, list):
94
+ results.extend(response_json)
95
+ else:
96
+ results.append(response_json)
97
+
98
+ return results
99
+
100
+ def get_subscriber(self, subscriber_id: int):
101
+ return self.get(f"/subscribers/{subscriber_id}").json()
102
+
103
+ def get_subscribers_from_list(self, list_id: int):
104
+ return self.get(f"/subscribers/lists/{list_id}").json()
105
+
106
+ def create_subscriber(self, data: SubscriberCreateRequest):
107
+ return self.post("/subscribers", json=data.model_dump(exclude_none=True)).json()
108
+
109
+ # ------------------------------------------------------------------------------------------------------------------
110
+ # Lists API
111
+ # ------------------------------------------------------------------------------------------------------------------
112
+ def get_lists(
113
+ self,
114
+ query: Optional[Dict] = None,
115
+ order_by: Optional[str] = None,
116
+ order: Optional[str] = None,
117
+ max_pages: int = 0,
118
+ per_page: int = 100,
119
+ ):
120
+ response = self.get(f"/lists?per_page={per_page}")
121
+ total_pages = int(response.headers.get("X-Total-Pages", 1))
122
+
123
+ results = []
124
+ list_filter = f"?per_page={per_page}"
125
+
126
+ if order_by and order_by in ["name", "status", "created_at", "updated_at"]:
127
+ list_filter += f"&order_by={order_by}"
128
+ if order and order.upper() in ["ASC", "DESC"]:
129
+ list_filter += f"&order={order}"
130
+
131
+ if max_pages == 0 or max_pages > total_pages:
132
+ max_pages = total_pages
133
+
134
+ data = None
135
+ if query:
136
+ data = query
137
+
138
+ for page in range(1, max_pages + 1):
139
+ response_page = self.get(f"/lists{list_filter}&page={page}", json=data)
140
+ response_json = response_page.json()
141
+ if isinstance(response_json, dict) and "data" in response_json:
142
+ results.extend(response_json["data"].get("results", []))
143
+ elif isinstance(response_json, list):
144
+ results.extend(response_json)
145
+ else:
146
+ results.append(response_json)
147
+
148
+ return results
149
+
150
+ def get_list(self, list_id: int):
151
+ return self.get(f"/lists/{list_id}").json()
152
+
153
+ def create_list(self, data: ListCreateRequest):
154
+ return self.post("/lists", json=data.model_dump(exclude_none=True)).json()
155
+
156
+ def edit_list(self, list_id: int, data: ListEditRequest):
157
+ return self.put(
158
+ f"/lists/{list_id}", json=data.model_dump(exclude_none=True)
159
+ ).json()
160
+
161
+ # ------------------------------------------------------------------------------------------------------------------
162
+ # Import API
163
+ # ------------------------------------------------------------------------------------------------------------------
164
+ def get_subscriber_import_status(self):
165
+ return self.get("/import/subscribers").json()
166
+
167
+ def get_subscriber_import_logs(self):
168
+ return self.get("/import/subscribers/logs").json()
169
+
170
+ def import_subscribers(self, data: ImportSubscribersRequest):
171
+ # NOTE: Using self.post instead of self.get for importing as it was incorrect in the old wrapper.
172
+ # Actually listmonk expects a POST for import
173
+ return self.post(
174
+ "/import/subscribers", json=data.model_dump(exclude_none=True)
175
+ ).json()
176
+
177
+ def delete_subscriber_import(self):
178
+ return self.delete("/import/subscribers/logs").json()
179
+
180
+ # ------------------------------------------------------------------------------------------------------------------
181
+ # Campaigns API
182
+ # ------------------------------------------------------------------------------------------------------------------
183
+ def get_campaigns(
184
+ self,
185
+ query: Optional[Dict] = None,
186
+ order_by: Optional[str] = None,
187
+ order: Optional[str] = None,
188
+ max_pages: int = 0,
189
+ per_page: int = 100,
190
+ ):
191
+ response = self.get(f"/campaigns?per_page={per_page}")
192
+ total_pages = int(response.headers.get("X-Total-Pages", 1))
193
+
194
+ results = []
195
+ campaign_filter = f"?per_page={per_page}"
196
+
197
+ if order_by and order_by in ["name", "status", "created_at", "updated_at"]:
198
+ campaign_filter += f"&order_by={order_by}"
199
+ if order and order.upper() in ["ASC", "DESC"]:
200
+ campaign_filter += f"&order={order}"
201
+
202
+ if max_pages == 0 or max_pages > total_pages:
203
+ max_pages = total_pages
204
+
205
+ data = None
206
+ if query:
207
+ data = query
208
+
209
+ for page in range(1, max_pages + 1):
210
+ response_page = self.get(
211
+ f"/campaigns{campaign_filter}&page={page}", json=data
212
+ )
213
+ response_json = response_page.json()
214
+ if isinstance(response_json, dict) and "data" in response_json:
215
+ results.extend(response_json["data"].get("results", []))
216
+ elif isinstance(response_json, list):
217
+ results.extend(response_json)
218
+ else:
219
+ results.append(response_json)
220
+
221
+ return results
222
+
223
+ def get_campaign(self, campaign_id: int):
224
+ return self.get(f"/campaigns/{campaign_id}").json()
225
+
226
+ def get_campaign_preview(self, campaign_id: int):
227
+ return self.get(f"/campaigns/{campaign_id}/preview").json()
228
+
229
+ def get_campaign_stats(self, campaign_id: int):
230
+ return self.get(f"/campaigns/{campaign_id}/running/stats").json()
231
+
232
+ def create_campaign(self, data: CampaignCreateRequest):
233
+ # BUG FIX 2 & 3: Included attachments in data model and mapped send_type to type via pydantic alias (by_alias=True)
234
+ return self.post(
235
+ "/campaigns", json=data.model_dump(by_alias=True, exclude_none=True)
236
+ ).json()
237
+
238
+ def set_campaign_status(self, campaign_id: int, data: CampaignStatusRequest):
239
+ return self.put(
240
+ f"/campaigns/{campaign_id}/status", json=data.model_dump(exclude_none=True)
241
+ ).json()
242
+
243
+ def delete_campaign(self, campaign_id: int):
244
+ return self.delete(f"/campaigns/{campaign_id}").json()
245
+
246
+ # ------------------------------------------------------------------------------------------------------------------
247
+ # Media API
248
+ # ------------------------------------------------------------------------------------------------------------------
249
+ def get_media(self, media_id: int):
250
+ return self.get("/media").json()
251
+
252
+ def upload_media(self, data: MediaUploadRequest):
253
+ return self.post("/media", json=data.model_dump(exclude_none=True)).json()
254
+
255
+ def delete_media(self, media_id: int):
256
+ return self.delete(f"/media/{media_id}").json()
257
+
258
+ # ------------------------------------------------------------------------------------------------------------------
259
+ # Templates API
260
+ # ------------------------------------------------------------------------------------------------------------------
261
+ def get_templates(self, max_pages: int = 0, per_page: int = 100):
262
+ response = self.get(f"/templates?per_page={per_page}")
263
+ total_pages = int(response.headers.get("X-Total-Pages", 1))
264
+
265
+ results = []
266
+ template_filter = f"?per_page={per_page}"
267
+
268
+ if max_pages == 0 or max_pages > total_pages:
269
+ max_pages = total_pages
270
+
271
+ for page in range(1, max_pages + 1):
272
+ response_page = self.get(f"/templates{template_filter}&page={page}")
273
+ response_json = response_page.json()
274
+ if isinstance(response_json, dict) and "data" in response_json:
275
+ results.extend(response_json["data"].get("results", []))
276
+ elif isinstance(response_json, list):
277
+ results.extend(response_json)
278
+ else:
279
+ results.append(response_json)
280
+
281
+ return results
282
+
283
+ def get_template(self, template_id: int):
284
+ return self.get(f"/templates/{template_id}").json()
285
+
286
+ def get_template_preview(self, template_id: int):
287
+ return self.get(f"/templates/{template_id}/preview").json()
288
+
289
+ def set_default_template(self, template_id: int):
290
+ return self.put(f"/templates/{template_id}/default").json()
291
+
292
+ def delete_template(self, template_id: int):
293
+ return self.delete(f"/templates/{template_id}").json()
294
+
295
+ # ------------------------------------------------------------------------------------------------------------------
296
+ # Transactional API
297
+ # ------------------------------------------------------------------------------------------------------------------
298
+ def transactional_message(self, data: TransactionalMessageRequest):
299
+ # BUG FIX 4: Included attachments in data model and excluded None
300
+ return self.post("/tx", json=data.model_dump(exclude_none=True)).json()
listmonk_api/auth.py ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/python
2
+ # coding: utf-8
3
+
4
+ import os
5
+ import urllib3
6
+
7
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
8
+
9
+ from agent_utilities.core.exceptions import AuthError, UnauthorizedError
10
+
11
+ from listmonk_api.api_client import ListmonkAPI
12
+
13
+ _client = None
14
+
15
+
16
+ def get_client():
17
+ """Get or create a singleton API client instance."""
18
+ global _client
19
+ if _client is None:
20
+ base_url = os.getenv("LISTMONK_URL", "http://localhost:8080")
21
+ token = os.getenv("LISTMONK_TOKEN", "")
22
+
23
+ try:
24
+ _client = ListmonkAPI(
25
+ url=base_url,
26
+ token=token,
27
+ )
28
+
29
+ except (AuthError, UnauthorizedError) as e:
30
+ raise RuntimeError(
31
+ f"AUTHENTICATION ERROR: The credentials provided are not valid for '{base_url}'. "
32
+ f"Please check your LISTMONK_TOKEN and LISTMONK_URL environment variables. "
33
+ f"Error details: {str(e)}"
34
+ ) from e
35
+
36
+ return _client