├── requirements.txt ├── .github └── FUNDING.yml ├── listmonk ├── errors │ └── __init__.py ├── urls.py ├── __init__.py ├── models │ └── __init__.py └── impl │ └── __init__.py ├── example_client ├── settings-template.json └── client.py ├── ruff.toml ├── LICENSE ├── change-log.md ├── pyproject.toml ├── CLAUDE.md ├── WARP.md ├── .gitignore └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | httpx 2 | pydantic 3 | strenum 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mikeckennedy] 4 | -------------------------------------------------------------------------------- /listmonk/errors/__init__.py: -------------------------------------------------------------------------------- 1 | class ValidationError(Exception): 2 | pass 3 | 4 | 5 | class OperationNotAllowedError(ValidationError): 6 | pass 7 | 8 | 9 | class ListmonkFileNotFoundError(FileNotFoundError): 10 | pass 11 | -------------------------------------------------------------------------------- /listmonk/urls.py: -------------------------------------------------------------------------------- 1 | health = '/api/health' 2 | lists = '/api/lists' 3 | lst = '/api/lists/{list_id}' 4 | subscriber = '/api/subscribers/{subscriber_id}' 5 | subscribers = '/api/subscribers' 6 | opt_in = '/subscription/optin/{subscriber_uuid}' 7 | send_tx = '/api/tx' 8 | campaigns = '/api/campaigns' 9 | campaign_id = '/api/campaigns/{campaign_id}' 10 | campaign_id_preview = '/api/campaigns/{campaign_id}/preview' 11 | templates = '/api/templates' 12 | template_id = '/api/templates/{template_id}' 13 | template_id_preview = '/api/templates/{template_id}/preview' 14 | template_id_default = '/api/templates/{template_id}/default' 15 | -------------------------------------------------------------------------------- /example_client/settings-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "base_url": "Where your instance hosted? e.g. https://mail.yoursite.com", 3 | "NOTE": "It's important in v4.0 that these credentials are an API account/key, not regular user", 4 | "username_pw_note": "As of the latest Listmonk, you must use an API user, not your actual credentials.", 5 | "username": "user_at_listmonk_instance", 6 | "password": "password_at_listmonk_instance", 7 | "test_list_id": 5, 8 | "test_list info": "Pick a list with some subscribers for the example client to pull", 9 | "ACTION": "COPY TO settings.json (excluded from git) and enter your info THERE (NOT HERE)" 10 | } -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # [ruff] 2 | line-length = 120 3 | format.quote-style = "single" 4 | 5 | # Enable Pyflakes `E` and `F` codes by default. 6 | lint.select = ["E", "F", "I"] 7 | lint.ignore = [] 8 | 9 | # Exclude a variety of commonly ignored directories. 10 | exclude = [ 11 | ".bzr", 12 | ".direnv", 13 | ".eggs", 14 | ".git", 15 | ".hg", 16 | ".mypy_cache", 17 | ".nox", 18 | ".pants.d", 19 | ".ruff_cache", 20 | ".svn", 21 | ".tox", 22 | "__pypackages__", 23 | "_build", 24 | "buck-out", 25 | "build", 26 | "dist", 27 | "node_modules", 28 | ".env", 29 | ".venv", 30 | "venv", 31 | "typings/**/*.pyi", 32 | ] 33 | lint.per-file-ignores = { } 34 | 35 | # Allow unused variables when underscore-prefixed. 36 | # dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 37 | 38 | # Assume Python 3.13. 39 | target-version = "py313" 40 | 41 | #[tool.ruff.mccabe] 42 | ## Unlike Flake8, default to a complexity level of 10. 43 | lint.mccabe.max-complexity = 10 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Michael Kennedy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /change-log.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | ### Changed 13 | 14 | ### Deprecated 15 | 16 | ### Removed 17 | 18 | ### Fixed 19 | 20 | ### Security 21 | 22 | ## [0.3.8] - 2025-10-22 23 | 24 | ### Added 25 | - Support for subject parameter in transactional templates (PR #24) 26 | - FAQ section to README.md (PR #22) 27 | - Four new API methods to the public interface 28 | - CLAUDE.md with project overview, development commands, code style, and key patterns 29 | - WARP.md documentation 30 | - Ruff formatting configuration 31 | 32 | ### Changed 33 | - Extensive type annotation improvements for better IDE support (PyLance/VS Code) 34 | - HTTP response validation and parsing refactored for better error handling 35 | - Headers type fix: now properly uses string values instead of list of dictionaries 36 | - Code formatting with ruff throughout the codebase 37 | - Documentation improvements in README.md 38 | 39 | ### Fixed 40 | - Custom exception renamed from `FileNotFoundError` to `ListmonkFileNotFoundError` to avoid builtin conflict 41 | - Non-string to string types in `__all__` exports 42 | - Type inference error in code fragments 43 | 44 | [unreleased]: https://github.com/mikeckennedy/listmonk/compare/v0.3.8...HEAD 45 | [0.3.8]: https://github.com/mikeckennedy/listmonk/releases/tag/v0.3.8 46 | 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "listmonk" 3 | version = "0.3.8" 4 | description = "Listmonk Email API Client for Python" 5 | readme = "README.md" 6 | license = "MIT" 7 | requires-python = ">=3.10" 8 | keywords = [ 9 | "email", 10 | "newsletters", 11 | "marketing", 12 | "api-client", 13 | ] 14 | authors = [ 15 | { name = "Michael Kennedy", email = "michael@talkpython.fm" }, 16 | ] 17 | classifiers = [ 18 | 'Development Status :: 5 - Production/Stable', 19 | 'License :: OSI Approved :: MIT License', 20 | 'Programming Language :: Python', 21 | 'Programming Language :: Python :: 3', 22 | 'Programming Language :: Python :: 3.10', 23 | 'Programming Language :: Python :: 3.11', 24 | 'Programming Language :: Python :: 3.12', 25 | 'Programming Language :: Python :: 3.13', 26 | 'Programming Language :: Python :: 3.14', 27 | ] 28 | dependencies = [ 29 | "httpx", 30 | "pydantic", 31 | "strenum" 32 | ] 33 | 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/mikeckennedy/listmonk" 37 | Tracker = "https://github.com/mikeckennedy/listmonk/issues" 38 | Source = "https://github.com/mikeckennedy/listmonk" 39 | 40 | [build-system] 41 | requires = ["hatchling>=1.27.0", "hatch-vcs>=0.3.0"] 42 | build-backend = "hatchling.build" 43 | 44 | 45 | [tool.hatch.build.targets.sdist] 46 | exclude = [ 47 | "/.github", 48 | "/tests", 49 | "/example_client", 50 | "settings.json", 51 | ] 52 | 53 | [tool.hatch.build.targets.wheel] 54 | packages = ["listmonk"] 55 | exclude = [ 56 | "/.github", 57 | "/tests", 58 | "/example_client", 59 | "settings.json", 60 | ] -------------------------------------------------------------------------------- /listmonk/__init__.py: -------------------------------------------------------------------------------- 1 | from listmonk import ( 2 | impl, 3 | models, 4 | ) 5 | from listmonk.impl import ( 6 | block_subscriber, 7 | campaign_by_id, 8 | campaign_preview_by_id, 9 | campaigns, 10 | confirm_optin, 11 | create_campaign, 12 | create_list, 13 | create_subscriber, 14 | create_template, 15 | delete_campaign, 16 | delete_list, 17 | delete_subscriber, 18 | delete_template, 19 | disable_subscriber, 20 | enable_subscriber, 21 | get_base_url, 22 | is_healthy, 23 | list_by_id, 24 | lists, 25 | login, 26 | send_transactional_email, 27 | set_default_template, 28 | set_url_base, 29 | subscriber_by_email, 30 | subscriber_by_id, 31 | subscriber_by_uuid, 32 | subscribers, 33 | template_by_id, 34 | template_preview_by_id, 35 | templates, 36 | update_campaign, 37 | update_subscriber, 38 | update_template, 39 | verify_login, 40 | ) 41 | 42 | __author__: str = 'Michael Kennedy ' 43 | __version__: str = impl.__version__ 44 | user_agent: str = impl.user_agent 45 | 46 | __all__ = [ 47 | 'models', 48 | 'login', 49 | 'verify_login', 50 | 'set_url_base', 51 | 'get_base_url', 52 | 'lists', 53 | 'list_by_id', 54 | 'subscribers', 55 | 'subscriber_by_email', 56 | 'subscriber_by_id', 57 | 'subscriber_by_uuid', 58 | 'create_subscriber', 59 | 'delete_subscriber', 60 | 'update_subscriber', 61 | 'disable_subscriber', 62 | 'enable_subscriber', 63 | 'block_subscriber', 64 | 'send_transactional_email', 65 | 'confirm_optin', 66 | 'is_healthy', 67 | 'campaigns', 68 | 'campaign_by_id', 69 | 'campaign_preview_by_id', 70 | 'create_campaign', 71 | 'delete_campaign', 72 | 'update_campaign', 73 | 'templates', 74 | 'create_template', 75 | 'template_by_id', 76 | 'template_preview_by_id', 77 | 'delete_template', 78 | 'update_template', 79 | 'set_default_template', 80 | 'create_list', 81 | 'delete_list', 82 | ] 83 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is a Python client library for the Listmonk email platform. The library provides a simplified interface to the Listmonk API, focusing on subscriber management, campaign operations, template handling, and transactional emails. 8 | 9 | ### Architecture 10 | 11 | - **`listmonk/__init__.py`**: Main module entry point with public API exports 12 | - **`listmonk/impl/__init__.py`**: Core implementation containing all API functions 13 | - **`listmonk/models/__init__.py`**: Pydantic models for request/response data 14 | - **`listmonk/urls.py`**: API endpoint URL constants 15 | - **`listmonk/errors/__init__.py`**: Custom exception classes 16 | 17 | The library uses a simple global state pattern for authentication (username/password stored globally) and builds on httpx for HTTP operations and Pydantic for data validation. 18 | 19 | ## Development Commands 20 | 21 | ### Linting and Code Quality 22 | ```bash 23 | ruff check 24 | ruff format 25 | ``` 26 | 27 | ### Running the Example Client 28 | ```bash 29 | python example_client/client.py 30 | ``` 31 | 32 | ## Code Style and Conventions 33 | 34 | - **Line length**: 120 characters (configured in ruff.toml) 35 | - **Quote style**: Single quotes 36 | - **Python version**: Supports 3.10+, targets 3.13 37 | - **Dependencies**: httpx, pydantic, strenum 38 | - **Import organization**: Group imports by stdlib, third-party, local with proper spacing 39 | 40 | ### Key Patterns 41 | 42 | 1. **Global State Management**: Authentication credentials are stored in module-level variables 43 | 2. **Pydantic Models**: All API data structures use Pydantic BaseModel for validation 44 | 3. **Error Handling**: Custom exceptions in `listmonk.errors` for validation and operation errors 45 | 4. **URL Constants**: All API endpoints defined in `urls.py` with format string placeholders 46 | 5. **Optional Timeouts**: All network operations accept optional `httpx.Timeout` configuration 47 | 48 | ### Function Naming Convention 49 | 50 | - Functions follow snake_case 51 | - Functions that fetch single items: `{resource}_by_{identifier}` (e.g., `subscriber_by_email`) 52 | - Functions that fetch collections: `{resources}` (e.g., `subscribers`, `campaigns`) 53 | - CRUD operations: `create_{resource}`, `update_{resource}`, `delete_{resource}` 54 | 55 | ## Testing and Examples 56 | 57 | No formal test suite exists. The `example_client/client.py` serves as both documentation and integration testing, demonstrating all major API operations against a real Listmonk instance. -------------------------------------------------------------------------------- /WARP.md: -------------------------------------------------------------------------------- 1 | # WARP.md 2 | 3 | This file provides guidance to WARP (warp.dev) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is a Python client library for the Listmonk email platform. The library provides a simplified interface to the Listmonk API, focusing on subscriber management, campaign operations, template handling, and transactional emails. 8 | 9 | **Note**: This library covers a subset of the Listmonk API focused on common SaaS actions (subscribe, unsubscribe, segmentation). It doesn't cover all endpoints like creating lists programmatically or editing HTML templates for campaigns. 10 | 11 | ## Development Commands 12 | 13 | ### Install for Development 14 | ```bash 15 | pip install -e . 16 | ``` 17 | 18 | ### Code Quality 19 | ```bash 20 | ruff check # Lint with E, F, I rules 21 | ruff format # Format with 120 char limit, single quotes 22 | ``` 23 | 24 | ### Testing 25 | ```bash 26 | python example_client/client.py 27 | ``` 28 | 29 | The example client serves as both documentation and integration testing. Copy `example_client/settings-template.json` to `example_client/settings.json` and configure your Listmonk instance details. 30 | 31 | ## Architecture 32 | 33 | ### Module Structure 34 | - **`listmonk/__init__.py`**: Main module entry point with public API exports 35 | - **`listmonk/impl/__init__.py`**: Core implementation containing all API functions 36 | - **`listmonk/models/__init__.py`**: Pydantic models for request/response data 37 | - **`listmonk/urls.py`**: API endpoint URL constants with format placeholders 38 | - **`listmonk/errors/__init__.py`**: Custom exception classes 39 | 40 | ### Key Patterns 41 | 42 | 1. **Global State Management**: Authentication credentials and base URL are stored in module-level variables in `impl/__init__.py`. Call `set_url_base()` and `login()` before using other functions. 43 | 44 | 2. **Function Naming Conventions**: 45 | - Single items: `{resource}_by_{identifier}` (e.g., `subscriber_by_email`) 46 | - Collections: `{resources}` (e.g., `subscribers`, `campaigns`) 47 | - CRUD: `create_{resource}`, `update_{resource}`, `delete_{resource}` 48 | 49 | 3. **HTTP & Validation**: All API calls use httpx with BasicAuth. Responses are validated and parsed through `_validate_and_parse_json_response()`. All network operations accept optional `httpx.Timeout` configuration. 50 | 51 | 4. **Pydantic Models**: All data structures use Pydantic BaseModel for validation. Models handle serialization of datetime fields and list conversions. 52 | 53 | 5. **Error Handling**: Custom exceptions in `listmonk.errors` for validation (`ValidationError`) and operation errors (`OperationNotAllowedError`). 54 | 55 | ## Configuration 56 | 57 | - **Python Version**: Minimum 3.10, targets 3.13 58 | - **Dependencies**: httpx, pydantic, strenum 59 | - **Code Style**: 120 character lines, single quotes, ruff formatting 60 | - **Ruff Rules**: E (pycodestyle errors), F (pyflakes), I (import sorting) 61 | 62 | ## Development Notes 63 | 64 | ### Authentication 65 | As of Listmonk v4.0+, you must use API credentials (not regular user accounts). Configure these in your test settings. 66 | 67 | ### Testing Philosophy 68 | No formal test suite exists. The `example_client/client.py` demonstrates all major operations and serves as integration testing. When adding new features, extend this example client to validate functionality. 69 | 70 | ### Global State 71 | Functions depend on module-level authentication state. Always call `set_url_base()` and `login()` before other operations. Use `verify_login()` to check authentication status. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | settings.json 162 | /.claude/settings.local.json 163 | 164 | .ruff_cache/ 165 | /.ruff_cache 166 | -------------------------------------------------------------------------------- /example_client/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import listmonk 5 | 6 | file = Path(__file__).parent / 'settings.json' 7 | 8 | settings = {} 9 | if file.exists(): 10 | settings = json.loads(file.read_text()) 11 | 12 | url = settings.get('base_url', '').strip('/') or input('Enter the base URL for your instance: ') 13 | user = settings.get('username') or input('Enter the username for Umami: ') 14 | password = settings.get('password') or input('Enter the password for Umami: ') 15 | test_list_id = settings.get('test_list_id') or input('Enter the ID for a test list with subscribers: ') 16 | 17 | listmonk.set_url_base(url) 18 | print(f'Base url: {listmonk.get_base_url()}', flush=True) 19 | 20 | print(f'Logged in? {listmonk.login(user, password)}', flush=True) 21 | print(f'API Healthy?: {listmonk.is_healthy()}', flush=True) 22 | print(f'Verify login: {listmonk.verify_login()}', flush=True) 23 | print() 24 | 25 | lists = listmonk.lists() 26 | for lst in lists: 27 | print(f'{lst.name} list: {lst}', flush=True) 28 | 29 | the_list = listmonk.list_by_id(test_list_id) 30 | print(f'List by ID: {the_list}') 31 | 32 | print() 33 | subscribers = listmonk.subscribers(list_id=test_list_id) 34 | print(f'{len(subscribers):,} subscribers returned', flush=True) 35 | 36 | # id=208495 created_at=datetime.datetime(2024, 1, 19, 20, 29, 8, 477242, tzinfo=TzInfo(UTC)) 37 | # updated_at=datetime.datetime(2024, 1, 19, 20, 29, 8, 477242, tzinfo=TzInfo(UTC)) 38 | # uuid='9a6b65de-c73d-4b9d-8e88-9bdd9439aff2' email='testuser_mkennedy@mkennedy.domain' 39 | # name='Michael Test Listmonk' attribs={'email': 'testuser_mkennedy@mkennedy.domain', 'first_name': '', 40 | # 'groups': ['friendsofthetalkpython'], 'last_changed': '', 'last_name': '', 41 | # 'location': {'country_code': '', 'region': '', 'timezone': ''}, 42 | # 'optin': {'confirm_ip_address': '1.2.3.4', 'confirm_time': '2023-01-19 15:31:42', 43 | # 'latitude': '', 'longitude': '', 'optin_ip_address': '', 'optin_time': '2022-04-26 15:31:42'}, 'rating': 1} 44 | 45 | custom_data = { 46 | 'email': 'newemail@some.domain', 47 | 'rating': 1, 48 | } 49 | 50 | email = 'deletable_user@mkennedy.domain' 51 | if subscriber := listmonk.subscriber_by_email(email): 52 | listmonk.delete_subscriber(subscriber.email) 53 | 54 | subscriber = listmonk.create_subscriber( 55 | email, 'Deletable Mkennedy', {test_list_id}, pre_confirm=True, attribs=custom_data 56 | ) 57 | print(f'Created subscriber: {subscriber}', flush=True) 58 | 59 | subscriber = listmonk.subscriber_by_email(email) 60 | print(f'Subscriber by email: {subscriber}', flush=True) 61 | 62 | subscriber = listmonk.subscriber_by_id(subscriber.id) 63 | print(f'Subscriber by id: {subscriber}', flush=True) 64 | 65 | subscriber = listmonk.subscriber_by_uuid(subscriber.uuid) 66 | print(f'Subscriber by uuid: {subscriber}', flush=True) 67 | 68 | subscriber.name = 'Mr. ' + subscriber.name.upper() 69 | subscriber.attribs['rating'] = 7 70 | 71 | query = f"subscribers.email = '{email}'" 72 | print('Searching for user with query: ', query, flush=True) 73 | sub2 = listmonk.subscribers(query) 74 | print(f'Found {len(sub2):,} users with query.', flush=True) 75 | print(f'Found {sub2[0].name} with email {sub2[0].email}', flush=True) 76 | 77 | 78 | # TODO: Choose list IDs from your instance (can be seen in the UI or from the listing above) 79 | to_add = {the_list.id} # Add all the lists here: {1, 7, 11} 80 | remove_from = set() # Same as above 81 | updated_subscriber = listmonk.update_subscriber(subscriber, to_add, remove_from) 82 | print(f'Updated subscriber: {updated_subscriber}', flush=True) 83 | 84 | print(f'Subscriber confirmed?: {listmonk.confirm_optin(subscriber.uuid, the_list.uuid)}', flush=True) 85 | 86 | updated_subscriber.attribs['subscription_note'] = ( 87 | 'They asked to be unsubscribed so we disabled their account, but no block-listing yet.' 88 | ) 89 | 90 | disabled_subscriber = listmonk.disable_subscriber(updated_subscriber) 91 | print('Disabled: ', disabled_subscriber, flush=True) 92 | 93 | disabled_subscriber.attribs['blocklist_note'] = 'They needed to be blocked!' 94 | 95 | listmonk.block_subscriber(disabled_subscriber) 96 | 97 | re_enabled_subscriber = listmonk.enable_subscriber(disabled_subscriber) 98 | print('Re-enabled: ', re_enabled_subscriber, flush=True) 99 | 100 | listmonk.delete_subscriber(subscriber.email) 101 | 102 | to_email = 'SUBSCRIBER_EMAIL_ON_YOUR_LIST' 103 | from_email = 'APPROVED_OUTBOUND_EMAIL_ON_DOMAIN' # Optional 104 | template_id = 3 # *Transactional* template ID from your listmonk instance. 105 | template_data = {'order_id': 1772, 'shipping_date': 'Next week'} 106 | if to_email != 'SUBSCRIBER_EMAIL_ON_YOUR_LIST': 107 | status = listmonk.send_transactional_email( 108 | to_email, template_id, from_email=from_email, template_data=template_data, content_type='html' 109 | ) 110 | print(f'Result of sending a tx email: {status}.', flush=True) 111 | else: 112 | print('Set email values to send transactional emails.', flush=True) 113 | -------------------------------------------------------------------------------- /listmonk/models/__init__.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any, Optional 3 | 4 | import pydantic 5 | from pydantic import BaseModel, field_serializer, field_validator 6 | from strenum import LowercaseStrEnum 7 | 8 | 9 | class SubscriberStatuses(LowercaseStrEnum): 10 | enabled = 'enabled' 11 | disabled = 'disabled' 12 | blocklisted = 'blocklisted' 13 | 14 | 15 | class SubscriberStatus(BaseModel): 16 | unconfirmed_count: Optional[int] = pydantic.Field(alias='unconfirmed', default=None) 17 | 18 | 19 | class MailingList(BaseModel): 20 | id: int 21 | created_at: datetime.datetime 22 | updated_at: Optional[datetime.datetime] = None 23 | uuid: str 24 | name: Optional[str] = None 25 | type: Optional[str] = None 26 | optin: Optional[str] = None 27 | tags: list[str] 28 | description: Optional[str] = None 29 | subscriber_count: Optional[int] = None 30 | subscriber_statuses: Optional[SubscriberStatus] = None 31 | 32 | 33 | class Subscriber(BaseModel): 34 | id: int 35 | email: str 36 | name: str 37 | created_at: datetime.datetime 38 | updated_at: Optional[datetime.datetime] = None 39 | uuid: Optional[str] = None 40 | lists: list[dict[str, Any]] = pydantic.Field(default_factory=list) 41 | attribs: dict[str, Any] = pydantic.Field(default_factory=dict) 42 | status: Optional[str] = None 43 | 44 | @field_serializer('created_at', 'updated_at') 45 | def serialize_date_times(self, fld: datetime.datetime, _info: Any) -> str: 46 | formatted_string = fld.strftime('%Y-%m-%dT%H:%M:%S.%fZ') 47 | return formatted_string 48 | 49 | @field_serializer('lists') 50 | def serialize_lists(self, fld: list[int] | set[int], _info: Any) -> list[int]: 51 | return [int(i) for i in fld] 52 | 53 | 54 | class CreateSubscriberModel(BaseModel): 55 | email: str 56 | name: str 57 | status: str 58 | lists: list[int] = pydantic.Field(default_factory=list) 59 | preconfirm_subscriptions: bool 60 | attribs: dict[str, Any] = pydantic.Field(default_factory=dict) 61 | 62 | 63 | class Campaign(BaseModel): 64 | id: int 65 | created_at: datetime.datetime 66 | updated_at: Optional[datetime.datetime] = None 67 | views: int 68 | clicks: int 69 | lists: list[dict[str, Any]] = pydantic.Field(default_factory=list) 70 | started_at: Optional[datetime.datetime] = None 71 | to_send: int 72 | sent: int 73 | uuid: str 74 | name: Optional[str] = None 75 | type: Optional[str] = None 76 | subject: Optional[str] = None 77 | from_email: Optional[str] = None 78 | body: Optional[str] = None 79 | altbody: Optional[str] = None 80 | send_at: Optional[datetime.datetime] = None 81 | status: Optional[str] = None 82 | content_type: Optional[str] = None 83 | tags: list[str] = pydantic.Field(default_factory=list) 84 | template_id: int 85 | messenger: Optional[str] = None 86 | headers: dict[str, Optional[str]] = pydantic.Field(default_factory=dict) 87 | 88 | 89 | class CreateCampaignModel(BaseModel): 90 | name: Optional[str] = None 91 | subject: Optional[str] = None 92 | lists: list[int] = pydantic.Field(default_factory=list) 93 | from_email: Optional[str] = None 94 | type: Optional[str] = None 95 | content_type: Optional[str] = None 96 | body: Optional[str] = None 97 | altbody: Optional[str] = None 98 | send_at: Optional[datetime.datetime] = None 99 | messenger: Optional[str] = None 100 | template_id: Optional[int] 101 | tags: list[str] = pydantic.Field(default_factory=list) 102 | headers: dict[str, Optional[str]] = pydantic.Field(default_factory=dict) 103 | 104 | @field_serializer('send_at') 105 | def serialize_date_times(self, fld: datetime.datetime, _info: Any) -> Optional[str]: 106 | if fld: 107 | formatted_string = fld.astimezone().isoformat() 108 | return formatted_string 109 | return None 110 | 111 | 112 | class UpdateCampaignModel(CreateCampaignModel): 113 | # noinspection PyMethodParameters 114 | @field_validator('send_at', mode='before') 115 | def serialize_send_at(cls, fld: datetime.datetime) -> Optional[datetime.datetime]: 116 | """ 117 | 118 | Serialize the provided datetime field to prepare for sending, considering the specified send_at time. 119 | If send_at is in the past then the update will fail, so we check if it is in the past and if it is we turn off 120 | the campaign scheduled send time. 121 | Parameters: 122 | fld (datetime.datetime): The datetime field to be serialized. 123 | 124 | Returns: 125 | datetime.datetime: Returns the serialized datetime field or None if the provided field is in the past. 126 | 127 | """ 128 | if isinstance(fld, datetime.datetime): # type: ignore 129 | now = datetime.datetime.now(datetime.timezone.utc) 130 | if fld < now: 131 | return None 132 | return fld 133 | 134 | 135 | class CampaignPreview(BaseModel): 136 | preview: Optional[str] = None 137 | 138 | 139 | class Template(BaseModel): 140 | id: int 141 | created_at: datetime.datetime 142 | updated_at: Optional[datetime.datetime] = None 143 | name: Optional[str] = None 144 | subject: Optional[str] = None 145 | body: Optional[str] = None 146 | type: Optional[str] = None 147 | is_default: Optional[bool] = None 148 | 149 | 150 | class CreateTemplateModel(BaseModel): 151 | name: Optional[str] = None 152 | subject: Optional[str] = None 153 | body: Optional[str] = None 154 | type: Optional[str] = None 155 | is_default: Optional[bool] = False 156 | 157 | 158 | class TemplatePreview(BaseModel): 159 | preview: Optional[str] = None 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Listmonk Email API Client for Python 2 | 3 | Client for the for open source, self-hosted [Listmonk email platform](https://listmonk.app) based on 4 | [httpx](https://www.python-httpx.org) and [pydantic](https://pydantic.dev). 5 | 6 | `listmonk` is intended for integrating your Listmonk instance into your web app. The [Listmonk API is extensive](https://listmonk.app/docs/apis/apis/) but this only covers the subset that most developers will need for common SaaS actions such as subscribe, unsubscribe, and segmentate users (into separate lists). 7 | 8 | So while it doesn't currently cover every endpoint (for example you cannot create a list programatically nor can you edit HTML templates for campaigns over APIs) it will likely work for you. That said, PRs are weclome. 9 | 10 | 🔀 Async is currently planned but not yet implemented. With the httpx-base, it should be trivial if needed. 11 | 12 | ## Core Features 13 | 14 | - ➕**Add a subscriber** to your subscribed users. 15 | - 🙎 Get **subscriber details** by email, ID, UUID, and more. 16 | - 📝 **Modify subscriber details** (including custom attribute collection). 17 | - 🔍 **Search** your users based on app and custom attributes. 18 | - 🏥 Check the **health and connectivity** of your instance. 19 | - 👥 Retrieve your **segmentation lists**, list details, and subscribers. 20 | - 🙅 Unsubscribe and block users who don't want to be contacted further. 21 | - 💥 Completely delete a subscriber from your instance. 22 | - 📧 Send transactional email with template data (e.g. password reset emails). 23 | - 📨 Manage campaign (bulk) emails from the API. 24 | - 🎨 Edit and create templates to control the over all look and feel of campaigns. 25 | - 📝 Create and delete lists. 26 | 27 | ## Installation 28 | 29 | Just `pip install listmonk` 30 | 31 | ## Usage 32 | 33 | ```python 34 | 35 | import pathlib 36 | import listmonk 37 | from typing import Optional 38 | 39 | listmonk.set_url_base('https://yourlistmonkurl.com') 40 | 41 | listmonk.login('sammy_z', '1234') 42 | valid: bool = listmonk.verify_login() 43 | 44 | # Is it alive and working? 45 | up: bool = listmonk.is_healthy() 46 | 47 | # Create a new list 48 | new_list = listmonk.create_list(list_name="my_new_list") 49 | 50 | # Read data about your lists 51 | lists: list[] = listmonk.lists() 52 | the_list: MailingList = listmonk.list_by_id(list_id=7) 53 | 54 | # Various ways to access existing subscribers 55 | subscribers: list[] = listmonk.subscribers(list_id=9) 56 | 57 | subscriber: Subscriber = listmonk.subscriber_by_email('testuser@some.domain') 58 | subscriber: Subscriber = listmonk.subscriber_by_id(2001) 59 | subscriber: Subscriber = listmonk.subscriber_by_uuid('f6668cf0-1c...') 60 | 61 | # Create a new subscriber 62 | new_subscriber = listmonk.create_subscriber( 63 | 'testuser@some.domain', 'Jane Doe', 64 | {1, 7, 9}, pre_confirm=True, attribs={...}) 65 | 66 | # Change the email, custom rating, and add to lists 4 & 6, remove from 5. 67 | subscriber.email = 'newemail@some.domain' 68 | subscriber.attribs['rating'] = 7 69 | subscriber = listmonk.update_subscriber(subscriber, {4, 6}, {5}) 70 | 71 | # Confirm single-opt-ins via the API (e.g. for when you manage that on your platform) 72 | listmonk.confirm_optin(subscriber.uuid, the_list.uuid) 73 | 74 | # Disable then re-enable a subscriber 75 | subscriber = listmonk.disable_subscriber(subscriber) 76 | subscriber = listmonk.enable_subscriber(subscriber) 77 | 78 | # Block (unsubscribe) them 79 | listmonk.block_subscriber(subscriber) 80 | 81 | # Fully delete them from your system 82 | listmonk.delete_subscriber(subscriber.email) 83 | 84 | # Send an individual, transactional email (e.g. password reset) 85 | to_email = 'testuser@some.domain' 86 | from_email = 'app@your.domain' 87 | template_id = 3 # *TX* template ID from listmonk 88 | template_data = {'full_name': 'Test User', 'reset_code': 'abc123'} 89 | 90 | status: bool = listmonk.send_transactional_email( 91 | to_email, template_id, from_email=from_email, 92 | template_data=template_data, content_type='html') 93 | 94 | # You can also add one or multiple attachments with transactional mails 95 | attachments = [ 96 | pathlib.Path("/path/to/your/file1.pdf"), 97 | pathlib.Path("/path/to/your/file2.png") 98 | ] 99 | 100 | status: bool = listmonk.send_transactional_email( 101 | to_email, 102 | template_id, 103 | from_email=from_email, 104 | template_data=template_data, 105 | attachments=attachments, 106 | content_type='html' 107 | ) 108 | 109 | # Access existing campaigns 110 | from listmonk.models import Campaign 111 | from datetime import datetime, timedelta 112 | 113 | campaigns: list[Campaign] = listmonk.campaigns() 114 | campaign: Campaign = listmonk.campaign_by_id(15) 115 | 116 | # Create a new Campaign 117 | listmonk.create_campaign(name='This is my Great Campaign!', 118 | subject="You won't believe this!", 119 | body='

Some Insane HTML!

', # Optional 120 | alt_body='Some Insane TXT!', # Optional 121 | send_at=datetime.now() + timedelta(hours=1), # Optional 122 | template_id=5, # Optional Defaults to 1 123 | list_ids={1, 2}, # Optional Defaults to 1 124 | tags=['good', 'better', 'best'] # Optional 125 | ) 126 | 127 | # Update A Campaign 128 | campaign_to_update: Optional[Campaign] = listmonk.campaign_by_id(15) 129 | campaign_to_update.name = "More Elegant Name" 130 | campaign_to_update.subject = "Even More Clickbait!!" 131 | campaign_to_update.body = "

There's a lot more we need to say so we're updating this programmatically!" 132 | campaign_to_update.altbody = "There's a lot more we need to say so we're updating this programmatically!" 133 | campaign_to_update.lists = [3, 4] 134 | 135 | listmonk.update_campaign(campaign_to_update) 136 | 137 | # Delete a Campaign 138 | campaign_to_delete: Optional[Campaign] = listmonk.campaign_by_id(15) 139 | listmonk.delete_campaign(campaign_to_delete) 140 | 141 | # Preview Campaign 142 | preview_html = listmonk.campaign_preview_by_id(15) 143 | print(preview_html) 144 | 145 | # Access existing Templates 146 | from listmonk.models import Template 147 | templates: list[Template] = listmonk.templates() 148 | template: Template = listmonk.template_by_id(2) 149 | 150 | # Create a new Template for Campaigns 151 | new_template = listmonk.create_template( 152 | name='NEW TEMPLATE', 153 | body='

Some Insane HTML! {{ template "content" . }}

', 154 | type='campaign', 155 | ) 156 | 157 | # Update A Template 158 | new_template.name = "Bob's Great Template" 159 | listmonk.update_template(new_template) 160 | 161 | # Delete a Template 162 | listmonk.delete_template(3) 163 | 164 | # Preview Template 165 | preview_html = listmonk.template_preview_by_id(3) 166 | print(preview_html) 167 | 168 | # Create a new template for Transactional Emails 169 | new_tx_template = listmonk.create_template( 170 | name='NEW TX TEMPLATE', 171 | subject='Your Transactional Email Subject', 172 | body='

Some Insane HTML! {{ .Subscriber.FirstName }}

', 173 | type='tx', 174 | ) 175 | ``` 176 | 177 | ## F.A.Q. 178 | 179 | ### I got httpx.HTTPStatusError: Client error '403 Forbidden' 180 | 181 | If you encounter an error like this in your console: 182 | 183 | ```text 184 | httpx.HTTPStatusError: Client error '403 Forbidden' for url 'https://yoursite.local/api/subscribers?page=1&per_page=100&query=subscribers.email='john@example.com'' 185 | ``` 186 | 187 | It means the authenticated user doesn’t have sufficient permissions to run SQL queries on subscriber data. 188 | 189 | **Solution:** Check the role assigned to your user. It must include the `subscribers:sql_query` permission to allow executing SQL queries on subscriber data. You can review and update user roles in your system’s admin panel. [[Reference](https://listmonk.app/docs/roles-and-permissions/#user-roles)] 190 | 191 | ## Want to contribute? 192 | 193 | PRs are welcome. But please open an issue first to see if the proposed feature fits with the direction of this library. 194 | 195 | Enjoy. 196 | -------------------------------------------------------------------------------- /listmonk/impl/__init__.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import sys 4 | import urllib.parse 5 | from importlib.metadata import version 6 | from pathlib import Path 7 | from typing import Any, Optional, Tuple 8 | 9 | import httpx 10 | 11 | from listmonk import models, urls 12 | 13 | __version__ = version('listmonk') 14 | 15 | from listmonk.errors import ListmonkFileNotFoundError, OperationNotAllowedError, ValidationError 16 | from listmonk.models import SubscriberStatuses 17 | 18 | # region global vars 19 | url_base: Optional[str] = None 20 | username: Optional[str] = None 21 | password: Optional[str] = None 22 | has_logged_in: bool = False 23 | 24 | user_agent: str = ( 25 | f'Listmonk-Client v{__version__} / ' 26 | f'Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} / ' 27 | f'{sys.platform.capitalize()}' 28 | ) 29 | 30 | core_headers: dict[str, str] = { 31 | 'Content-Type': 'application/json', 32 | 'User-Agent': user_agent, 33 | } 34 | 35 | 36 | # endregion 37 | 38 | 39 | def _validate_and_parse_json_response(resp: httpx.Response) -> dict[str, Any]: 40 | """ 41 | Internal helper to validate HTTP response and parse JSON with proper error handling. 42 | Args: 43 | resp: The httpx Response object 44 | Returns: 45 | Parsed JSON data as dictionary 46 | Raises: 47 | ValidationError: If response is empty or contains invalid JSON 48 | """ 49 | resp.raise_for_status() 50 | 51 | if not resp.content: 52 | raise ValidationError('Empty response from server') 53 | 54 | try: 55 | return resp.json() 56 | except (ValueError, json.JSONDecodeError) as e: 57 | raise ValidationError(f'Invalid JSON response from server: {e}') 58 | 59 | 60 | # region def get_base_url() -> Optional[str] 61 | 62 | 63 | def get_base_url() -> Optional[str]: 64 | """ 65 | Each Listmonk instance lives somewhere. This is where yours lives. 66 | For example, https://listmonk.somedomain.tech. 67 | 68 | Returns: The base URL of your instance. 69 | """ 70 | return url_base 71 | 72 | 73 | # endregion 74 | 75 | 76 | # region def set_url_base(url: str) 77 | 78 | 79 | def set_url_base(url: str): 80 | """ 81 | Each Listmonk instance lives somewhere. This is where yours lives. 82 | For example, https://listmonk.somedomain.tech. 83 | Args: 84 | url: The base URL of your instance without /api. 85 | """ 86 | if not url or not url.strip(): 87 | raise ValidationError('URL must not be empty.') 88 | 89 | # noinspection HttpUrlsUsage 90 | if not url.startswith('http://') and not url.startswith('https://'): 91 | # noinspection HttpUrlsUsage 92 | raise ValidationError('The url must start with the HTTP scheme (http:// or https://).') 93 | 94 | if url.endswith('/'): 95 | url = url.rstrip('/') 96 | 97 | global url_base 98 | url_base = url.strip() 99 | 100 | 101 | # endregion 102 | 103 | 104 | # region def login(user_name: str, pw: str, timeout_config: Optional[httpx.Timeout] = None) 105 | 106 | 107 | def login(user_name: str, pw: str, timeout_config: Optional[httpx.Timeout] = None) -> bool: 108 | """ 109 | Logs into Listmonk and stores that authentication for the life of your app. 110 | Args: 111 | user_name: Your Listmonk username 112 | pw: Your Listmonk password 113 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 114 | 115 | Returns: Returns a boolean indicating whether the login was successful. 116 | """ 117 | 118 | global has_logged_in, username, password 119 | 120 | if not url_base or not url_base.strip(): 121 | raise OperationNotAllowedError('base_url must be set before you can call login.') 122 | 123 | validate_login(user_name, pw) 124 | username = user_name 125 | password = pw 126 | 127 | has_logged_in = test_user_pw_on_server(timeout_config) 128 | 129 | return has_logged_in 130 | 131 | 132 | # endregion 133 | 134 | # region def lists() -> list[models.MailingList] 135 | 136 | 137 | def lists(timeout_config: Optional[httpx.Timeout] = None) -> list[models.MailingList]: 138 | """ 139 | Get mailing lists on the server. 140 | Args: 141 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 142 | Returns: List of MailingList objects with the full details of that list. 143 | """ 144 | # noinspection DuplicatedCode 145 | validate_state(url=True) 146 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 147 | 148 | url = f'{url_base}{urls.lists}?page=1&per_page=1000000' 149 | resp = httpx.get( 150 | url, 151 | auth=httpx.BasicAuth(username or '', password or ''), 152 | headers=core_headers, 153 | follow_redirects=True, 154 | timeout=timeout_config, 155 | ) 156 | data = _validate_and_parse_json_response(resp) 157 | list_of_lists = [models.MailingList(**d) for d in data.get('data', {}).get('results', [])] 158 | return list_of_lists 159 | 160 | 161 | # endregion 162 | 163 | 164 | # region def list_by_id(list_id: int) -> Optional[models.MailingList] 165 | 166 | 167 | def list_by_id(list_id: int, timeout_config: Optional[httpx.Timeout] = None) -> Optional[models.MailingList]: 168 | """ 169 | Get the full details of a list with the given ID. 170 | Args: 171 | list_id: A list to get the details about, e.g. 7. 172 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 173 | Returns: MailingList object with the full details of a list. 174 | """ 175 | # noinspection DuplicatedCode 176 | global core_headers 177 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 178 | validate_state(url=True) 179 | 180 | url = f'{url_base}{urls.lst}' 181 | url = url.format(list_id=list_id) 182 | 183 | resp = httpx.get( 184 | url, 185 | auth=httpx.BasicAuth(username or '', password or ''), 186 | headers=core_headers, 187 | follow_redirects=True, 188 | timeout=timeout_config, 189 | ) 190 | data = _validate_and_parse_json_response(resp) 191 | lst_data = data.get('data', {}) 192 | 193 | # This seems to be a bug, and we'll just work around it until listmonk fixes it 194 | # See https://github.com/knadh/listmonk/issues/2117 195 | results: list[models.MailingList] = lst_data.get('results', []) 196 | if results: 197 | found = False 198 | lst: models.MailingList 199 | for lst in results: 200 | if lst.id == list_id: 201 | lst_data = lst 202 | found = True 203 | break 204 | 205 | if not found: 206 | raise Exception(f'List with ID {list_id} not found.') 207 | 208 | return models.MailingList(**lst_data) # type: ignore 209 | 210 | 211 | # endregion 212 | 213 | # region def subscribers(query_text: Optional[str] = None, list_id: Optional[int] = None) -> list[models.Subscriber] 214 | 215 | 216 | def subscribers( 217 | query_text: Optional[str] = None, list_id: Optional[int] = None, timeout_config: Optional[httpx.Timeout] = None 218 | ) -> list[models.Subscriber]: 219 | """ 220 | Get a list of subscribers matching the criteria provided. If none, then all subscribers are returned. 221 | Args: 222 | query_text: Custom query text such as "subscribers.attribs->>'city' = 'Portland'". See the full documentation at https://listmonk.app/docs/querying-and-segmentation/ 223 | list_id: Pass a list ID and get the subscribers, matching the query, from that list. 224 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 225 | Returns: A list of subscribers matching the criteria provided. If none, then all subscribers are returned. 226 | """ # noqa 227 | global core_headers 228 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 229 | validate_state(url=True) 230 | 231 | raw_results = [] 232 | page_num = 1 233 | partial_results, more = _fragment_of_subscribers(page_num, list_id, query_text, timeout_config) 234 | raw_results.extend(partial_results) # type: ignore 235 | # Logging someday: print(f"subscribers(): Got {len(raw_results)} so far, more? {more}") 236 | while more: 237 | page_num += 1 238 | partial_results, more = _fragment_of_subscribers(page_num, list_id, query_text, timeout_config) 239 | raw_results.extend(partial_results) # type: ignore 240 | # Logging someday: print(f"subscribers(): Got {len(raw_results)} so far on page {page_num}, more? {more}") 241 | 242 | subscriber_list = [models.Subscriber(**d) for d in raw_results] # type: ignore 243 | 244 | return subscriber_list 245 | 246 | 247 | # endregion 248 | 249 | # region def _fragment_of_subscribers(page_num: int, list_id: Optional[int], query_text: Optional[str]) 250 | 251 | 252 | def _fragment_of_subscribers( 253 | page_num: int, list_id: Optional[int], query_text: Optional[str], timeout_config: Optional[httpx.Timeout] = None 254 | ) -> Tuple[list[dict[str, Any]], bool]: 255 | """ 256 | Internal use only. 257 | Returns: 258 | Tuple of partial_results, more_to_retrieve 259 | """ 260 | per_page = 500 261 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 262 | 263 | url = f'{url_base}{urls.subscribers}?page={page_num}&per_page={per_page}&order_by=updated_at&order=DESC' 264 | 265 | if list_id: 266 | url += f'&list_id={list_id}' 267 | 268 | if query_text: 269 | url += f'&{urllib.parse.urlencode({"query": query_text})}' 270 | 271 | resp = httpx.get( 272 | url, 273 | auth=httpx.BasicAuth(username or '', password or ''), 274 | headers=core_headers, 275 | follow_redirects=True, 276 | timeout=timeout_config, 277 | ) 278 | # For paging: 279 | # data: {"total":55712,"per_page":10,"page":1, ...} 280 | raw_data = _validate_and_parse_json_response(resp) 281 | data = raw_data['data'] 282 | 283 | total = data.get('total', 0) 284 | retrieved = per_page * page_num 285 | more = retrieved < total 286 | 287 | local_results = data.get('results', []) 288 | return local_results, more 289 | 290 | 291 | # endregion 292 | 293 | # region def subscriber_by_email(email: str) -> Optional[models.Subscriber] 294 | 295 | 296 | def subscriber_by_email(email: str, timeout_config: Optional[httpx.Timeout] = None) -> Optional[models.Subscriber]: 297 | """ 298 | Retrieves the subscribe by email (e.g. "some_user@talkpython.fm") 299 | Args: 300 | email: Email of the subscriber (e.g. "some_user@talkpython.fm") 301 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 302 | Returns: The subscribe if found, None otherwise. 303 | """ 304 | global core_headers 305 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 306 | validate_state(url=True) 307 | 308 | encoded_email = email.replace('+', '%2b') 309 | # noinspection DuplicatedCode 310 | url = f"{url_base}{urls.subscribers}?page=1&per_page=100&query=subscribers.email='{encoded_email}'" 311 | 312 | resp = httpx.get( 313 | url, 314 | auth=httpx.BasicAuth(username or '', password or ''), 315 | headers=core_headers, 316 | follow_redirects=True, 317 | timeout=timeout_config, 318 | ) 319 | raw_data = _validate_and_parse_json_response(resp) 320 | results: list[dict[str, Any]] = raw_data['data']['results'] 321 | 322 | if not results: 323 | return None 324 | 325 | return models.Subscriber(**results[0]) # type: ignore 326 | 327 | 328 | # endregion 329 | 330 | # region def subscriber_by_id(subscriber_id: int) -> Optional[models.Subscriber] 331 | 332 | 333 | def subscriber_by_id(subscriber_id: int, timeout_config: Optional[httpx.Timeout] = None) -> Optional[models.Subscriber]: 334 | """ 335 | Retrieves the subscribe by id (e.g. 201) 336 | Args: 337 | subscriber_id: ID of the subscriber (e.g. 201) 338 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 339 | Returns: The subscribe if found, None otherwise. 340 | """ 341 | global core_headers 342 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 343 | validate_state(url=True) 344 | 345 | url = f'{url_base}{urls.subscribers}?page=1&per_page=100&query=subscribers.id={subscriber_id}' 346 | 347 | # noinspection DuplicatedCode 348 | resp = httpx.get( 349 | url, 350 | auth=httpx.BasicAuth(username or '', password or ''), 351 | headers=core_headers, 352 | follow_redirects=True, 353 | timeout=timeout_config, 354 | ) 355 | raw_data = _validate_and_parse_json_response(resp) 356 | results: list[dict[str, Any]] = raw_data['data']['results'] 357 | 358 | if not results: 359 | return None 360 | 361 | return models.Subscriber(**results[0]) 362 | 363 | 364 | # endregion 365 | 366 | # region subscriber_by_uuid(subscriber_uuid: str) -> Optional[models.Subscriber] 367 | 368 | 369 | def subscriber_by_uuid( 370 | subscriber_uuid: str, timeout_config: Optional[httpx.Timeout] = None 371 | ) -> Optional[models.Subscriber]: 372 | """ 373 | Retrieves the subscriber by uuid (e.g. "c37786af-e6ab-4260-9b49-740adpcm6ed") 374 | Args: 375 | subscriber_uuid: UUID of the subscriber (e.g. "c37786af-e6ab-4260-9b49-740aaaa6ed") 376 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 377 | Returns: The subscribe if found, None otherwise. 378 | """ 379 | global core_headers 380 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 381 | validate_state(url=True) 382 | 383 | # noinspection DuplicatedCode 384 | url = f"{url_base}{urls.subscribers}?page=1&per_page=100&query=subscribers.uuid='{subscriber_uuid}'" 385 | 386 | resp = httpx.get( 387 | url, 388 | auth=httpx.BasicAuth(username or '', password or ''), 389 | headers=core_headers, 390 | follow_redirects=True, 391 | timeout=timeout_config, 392 | ) 393 | raw_data = _validate_and_parse_json_response(resp) 394 | results: list[dict[str, Any]] = raw_data['data']['results'] 395 | 396 | if not results: 397 | return None 398 | 399 | return models.Subscriber(**results[0]) 400 | 401 | 402 | # endregion 403 | 404 | # region def create_subscriber( 405 | # email: str, name: str, list_ids: set[int], pre_confirm: bool, 406 | # attribs: dict, timeout_config: Optional[httpx.Timeout] = None 407 | # ) 408 | 409 | 410 | def create_subscriber( 411 | email: str, 412 | name: str, 413 | list_ids: set[int], 414 | pre_confirm: bool, 415 | attribs: dict[str, Any], 416 | timeout_config: Optional[httpx.Timeout] = None, 417 | ) -> models.Subscriber: 418 | """ 419 | Create a new subscriber on the Listmonk server. 420 | Args: 421 | email: Email of the subscriber. 422 | name: Full name (first[SPACE]last) of the subscriber 423 | list_ids: List of list IDs for the lists to add them to (say that 3 times fast!) 424 | pre_confirm: Whether to preconfirm the subscriber for double opt-in lists (no email to them) 425 | attribs: Custom dictionary for the attribs data on the user record (queryable in the subscriber UI). 426 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 427 | Returns: The Subscribe object that was created on the server with ID, UUID, and much more. 428 | """ 429 | global core_headers 430 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 431 | validate_state(url=True) 432 | email = (email or '').lower().strip() 433 | name = (name or '').strip() 434 | if not email: 435 | raise ValueError('Email is required') 436 | if not name: 437 | raise ValueError('Name is required') 438 | 439 | model = models.CreateSubscriberModel( 440 | email=email, 441 | name=name, 442 | status='enabled', 443 | lists=list(list_ids), 444 | preconfirm_subscriptions=pre_confirm, 445 | attribs=attribs, 446 | ) 447 | 448 | # noinspection DuplicatedCode 449 | url = f'{url_base}{urls.subscribers}' 450 | resp = httpx.post( 451 | url, 452 | auth=httpx.BasicAuth(username or '', password or ''), 453 | json=model.model_dump(), 454 | headers=core_headers, 455 | follow_redirects=True, 456 | timeout=timeout_config, 457 | ) 458 | raw_data = _validate_and_parse_json_response(resp) 459 | sub_data = raw_data['data'] 460 | return models.Subscriber(**sub_data) 461 | 462 | 463 | # endregion 464 | 465 | # region def delete_subscriber(email: Optional[str] = None, overriding_subscriber_id: Optional[int] = None, timeout_config: Optional[httpx.Timeout] = None) -> bool # noqa: E501 466 | 467 | 468 | def delete_subscriber( 469 | email: Optional[str] = None, 470 | overriding_subscriber_id: Optional[int] = None, 471 | timeout_config: Optional[httpx.Timeout] = None, 472 | ) -> bool: 473 | """ 474 | Completely delete a subscriber from your system (it's as if they were never there). 475 | If your goal is to unsubscribe them, then use the block_subscriber method. 476 | Args: 477 | email: Email of the account to delete. 478 | overriding_subscriber_id: Optional ID of the account to delete (takes precedence). 479 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 480 | Returns: True if they were successfully deleted, False otherwise. 481 | """ 482 | global core_headers 483 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 484 | validate_state(url=True) 485 | email = (email or '').lower().strip() 486 | if not email and not overriding_subscriber_id: 487 | raise ValueError('Email is required') 488 | 489 | subscriber_id = overriding_subscriber_id 490 | if not subscriber_id: 491 | subscriber = subscriber_by_email(email, timeout_config) 492 | if not subscriber: 493 | return False 494 | subscriber_id = subscriber.id 495 | 496 | # noinspection DuplicatedCode 497 | url = f'{url_base}{urls.subscriber.format(subscriber_id=subscriber_id)}' 498 | resp = httpx.delete( 499 | url, 500 | auth=httpx.BasicAuth(username or '', password or ''), 501 | headers=core_headers, 502 | follow_redirects=True, 503 | timeout=timeout_config, 504 | ) 505 | raw_data = _validate_and_parse_json_response(resp) 506 | return bool(raw_data.get('data')) # Looks like {'data': True} 507 | 508 | 509 | # endregion 510 | 511 | # region def confirm_optin(subscriber_uuid: str, list_uuid: str, timeout_config: Optional[httpx.Timeout] = None) -> bool 512 | 513 | 514 | def confirm_optin(subscriber_uuid: str, list_uuid: str, timeout_config: Optional[httpx.Timeout] = None) -> bool: 515 | """ 516 | For opt-in situations, subscribers are added as unconfirmed first. This method will opt them in 517 | via the API. You should only do this when they are actually opting in. If you have your own opt-in 518 | form, but it's via your code, then this makes sense. 519 | Args: 520 | subscriber_uuid: The Subscriber.uuid value for the subscriber. 521 | list_uuid: The MailingList.uuid value for the list. 522 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 523 | Returns: True if they were successfully opted in. 524 | """ 525 | global core_headers 526 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 527 | validate_state(url=True) 528 | if not subscriber_uuid: 529 | raise ValueError('subscriber_uuid is required') 530 | if not list_uuid: 531 | raise ValueError('list_uuid is required') 532 | 533 | # 534 | # If there is a better endpoint / API for this, please let me know. 535 | # We're reduced to basically submitting the form via web scraping. 536 | # 537 | payload = { 538 | 'l': list_uuid, 539 | 'confirm': 'true', 540 | } 541 | url = f'{url_base}{urls.opt_in.format(subscriber_uuid=subscriber_uuid)}' 542 | resp = httpx.post( 543 | url, 544 | auth=httpx.BasicAuth(username or '', password or ''), 545 | data=payload, 546 | follow_redirects=True, 547 | timeout=timeout_config, 548 | ) 549 | resp.raise_for_status() 550 | 551 | success_phrases = { 552 | # New conformation was created now. 553 | 'Subscribed successfully.', 554 | 'Confirmed', 555 | # They were already confirmed somehow previously. 556 | 'no subscriptions to confirm', 557 | 'No subscriptions', 558 | } 559 | 560 | text = resp.text or '' 561 | return any(p in text for p in success_phrases) 562 | 563 | 564 | # endregion 565 | 566 | # region def update_subscriber(subscriber: models.Subscriber, add_to_lists: set[int], remove_from_lists: set[int], status: SubscriberStatuses = SubscriberStatuses.enabled, timeout_config: Optional[httpx.Timeout] = None) # noqa: E501 567 | 568 | 569 | def update_subscriber( 570 | subscriber: models.Subscriber, 571 | add_to_lists: Optional[set[int]] = None, 572 | remove_from_lists: Optional[set[int]] = None, 573 | status: SubscriberStatuses = SubscriberStatuses.enabled, 574 | timeout_config: Optional[httpx.Timeout] = None, 575 | ) -> Optional[models.Subscriber]: 576 | """ 577 | Update many aspects of a subscriber, from their email addresses and names, to custom attribute data, and 578 | from adding them to and removing them from lists. You can enable, disable, and block them here. But if that 579 | is all you want tod o there are functions dedicated to that which are simpler. 580 | Args: 581 | subscriber: The full subscriber object to update (with changed fields and values) 582 | add_to_lists: Any list to add to this subscriber to. 583 | remove_from_lists: Any list to remove from this subscriber. 584 | status: The status of the subscriber: enabled, disabled, blacklisted from SubscriberStatuses. 585 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 586 | Returns: The updated view of the subscriber object from the server. 587 | """ 588 | global core_headers 589 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 590 | validate_state(url=True) 591 | if subscriber is None or not subscriber.id: # type: ignore 592 | raise ValueError('Subscriber is required') 593 | 594 | add_to_lists = add_to_lists or set() 595 | remove_from_lists = remove_from_lists or set() 596 | 597 | existing_lists = {int(lst['id']) for lst in subscriber.lists} # type: ignore 598 | final_lists = existing_lists - remove_from_lists 599 | final_lists.update(add_to_lists) 600 | 601 | update_model = models.CreateSubscriberModel( 602 | email=subscriber.email, 603 | name=subscriber.name, 604 | status=status, 605 | lists=list(final_lists), 606 | preconfirm_subscriptions=True, 607 | attribs=subscriber.attribs, 608 | ) 609 | 610 | url = f'{url_base}{urls.subscriber.format(subscriber_id=subscriber.id)}' 611 | resp = httpx.put( 612 | url, 613 | auth=httpx.BasicAuth(username or '', password or ''), 614 | json=update_model.model_dump(), 615 | headers=core_headers, 616 | follow_redirects=True, 617 | timeout=timeout_config, 618 | ) 619 | resp.raise_for_status() 620 | 621 | return subscriber_by_id(subscriber.id, timeout_config) 622 | 623 | 624 | # endregion 625 | 626 | # region def disable_subscriber(subscriber: models.Subscriber) -> models.Subscriber 627 | 628 | 629 | def disable_subscriber( 630 | subscriber: models.Subscriber, timeout_config: Optional[httpx.Timeout] = None 631 | ) -> Optional[models.Subscriber]: 632 | """ 633 | Set a subscriber's status to disable. 634 | Args: 635 | subscriber: The subscriber to disable. 636 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 637 | Returns: The updated subscriber object from the server. 638 | """ 639 | return update_subscriber(subscriber, status=SubscriberStatuses.disabled, timeout_config=timeout_config) 640 | 641 | 642 | # endregion 643 | 644 | # region def enable_subscriber(subscriber: models.Subscriber) -> models.Subscriber 645 | 646 | 647 | def enable_subscriber( 648 | subscriber: models.Subscriber, timeout_config: Optional[httpx.Timeout] = None 649 | ) -> Optional[models.Subscriber]: 650 | """ 651 | Set a subscriber's status to enable. 652 | Args: 653 | subscriber: The subscriber to enable. 654 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 655 | Returns: The updated subscriber object from the server. 656 | """ 657 | return update_subscriber(subscriber, status=SubscriberStatuses.enabled, timeout_config=timeout_config) 658 | 659 | 660 | # endregion 661 | 662 | # region def block_subscriber(subscriber: models.Subscriber) -> models.Subscriber 663 | 664 | 665 | def block_subscriber( 666 | subscriber: models.Subscriber, timeout_config: Optional[httpx.Timeout] = None 667 | ) -> Optional[models.Subscriber]: 668 | """ 669 | Add a subscriber to the blocklist, AKA unsubscribe them. 670 | Args: 671 | subscriber: The subscriber to block/unsubscribe. 672 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 673 | Returns: The updated subscriber object from the server. 674 | """ 675 | return update_subscriber(subscriber, status=SubscriberStatuses.blocklisted, timeout_config=timeout_config) 676 | 677 | 678 | # endregion 679 | 680 | # region def send_transactional_email(subscriber_email: str, template_id: int, from_email: Optional[str] = None, template_data: Optional[dict] = None, messenger_channel: str = "email", content_type: str = "markdown", attachments: Optional[list[Path]] = None) # noqa: E501 681 | 682 | 683 | def send_transactional_email( 684 | subscriber_email: str, 685 | template_id: int, 686 | from_email: Optional[str] = None, 687 | template_data: Optional[dict[str, Any]] = None, 688 | messenger_channel: str = 'email', 689 | content_type: str = 'markdown', 690 | attachments: Optional[list[Path]] = None, 691 | email_headers: Optional[list[dict[str, Optional[str]]]] = None, 692 | timeout_config: Optional[httpx.Timeout] = None, 693 | ) -> bool: 694 | """ 695 | Send a transactional email through Listmonk to the recipient. 696 | Args: 697 | subscriber_email: The email address to send the email to (they must be a subscriber of *some* list on your server). 698 | template_id: The template ID to use for the email. It must be a "transactional" not campaign template. 699 | from_email: The from address for the email. Can be omitted to use default email at your output provider. 700 | template_data: A dictionary of merge parameters for the template, available in the template as {{ .Tx.Data.* }}. 701 | messenger_channel: Default is "email", if you have SMS or some other channel, you can use it here. 702 | content_type: Email format options include html, markdown, and plain. 703 | attachments: Optional list of `pathlib.Path` objects pointing to file that will be sent as attachment. 704 | email_headers: Optional array of e-mail headers to include in all messages sent from this server. eg: [{"X-Custom": "value"}, {"X-Custom2": "value"}] 705 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 706 | 707 | Returns: True if the email send was successful, False otherwise. 708 | Errors may show up in the logs section of your Listmonk dashboard. 709 | """ # noqa: E501 710 | global core_headers 711 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 712 | validate_state(url=True) 713 | subscriber_email = (subscriber_email or '').lower().strip() 714 | if not subscriber_email: 715 | raise ValueError('Email is required') 716 | 717 | # Verify attachments 718 | if attachments is not None: 719 | for attachment in attachments: 720 | if not attachment.exists() or not attachment.is_file(): 721 | raise ListmonkFileNotFoundError(f'Attachment {attachment} does not exist') 722 | 723 | body_data = { 724 | 'subscriber_email': subscriber_email, 725 | 'template_id': template_id, 726 | 'data': template_data or {}, 727 | 'messenger': messenger_channel, 728 | 'content_type': content_type, 729 | 'headers': email_headers or [], 730 | } 731 | 732 | if from_email is not None: 733 | body_data['from_email'] = from_email 734 | 735 | try: 736 | url = f'{url_base}{urls.send_tx}' 737 | 738 | # Depending on existence of attachments, we need to send data in different ways 739 | if attachments: 740 | # Multiple files can be uploaded in one go as per the advanced httpx docs 741 | # https://www.python-httpx.org/advanced/#multipart-file-encoding 742 | files = [('file', (attachment.name, open(attachment, 'rb'))) for attachment in attachments] 743 | # Data has to be sent as form field named data as per the listmonk API docs 744 | # https://listmonk.app/docs/apis/transactional/#file-attachments 745 | data = { 746 | 'data': json.dumps(body_data, ensure_ascii=False).encode('utf-8'), 747 | } 748 | # Need to remove content type header as it should not be JSON and is set 749 | # automatically by httpx including the correct boundary parameter 750 | headers = core_headers.copy() 751 | headers.pop('Content-Type') 752 | 753 | resp = httpx.post( 754 | url, 755 | auth=httpx.BasicAuth(username or '', password or ''), 756 | data=data, 757 | files=files, 758 | headers=headers, 759 | follow_redirects=True, 760 | timeout=timeout_config, 761 | ) 762 | else: 763 | resp = httpx.post( 764 | url, 765 | auth=httpx.BasicAuth(username or '', password or ''), 766 | json=body_data, 767 | headers=core_headers, 768 | follow_redirects=True, 769 | timeout=timeout_config, 770 | ) 771 | 772 | raw_data = _validate_and_parse_json_response(resp) 773 | return bool(raw_data.get('data')) # {'data': True} 774 | except Exception: 775 | # Maybe some logging here at some point. 776 | raise 777 | 778 | 779 | # endregion 780 | 781 | # region def is_healthy() -> bool 782 | 783 | 784 | def is_healthy(timeout_config: Optional[httpx.Timeout] = None) -> bool: 785 | """ 786 | Checks that the token retrieved during login is still valid at your server. 787 | Args: 788 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 789 | Returns: True if the token is still valid, False otherwise. 790 | """ 791 | # noinspection PyBroadException 792 | try: 793 | validate_state(url=True) 794 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 795 | 796 | url = f'{url_base}{urls.health}' 797 | resp = httpx.get( 798 | url, 799 | auth=httpx.BasicAuth(username or '', password or ''), 800 | headers=core_headers, 801 | follow_redirects=True, 802 | timeout=timeout_config, 803 | ) 804 | data = _validate_and_parse_json_response(resp) 805 | return data.get('data', False) 806 | except Exception: 807 | return False 808 | 809 | 810 | # endregion 811 | 812 | 813 | # region def verify_login() -> bool 814 | def verify_login(timeout_config: Optional[httpx.Timeout] = None) -> bool: 815 | """ 816 | Call to verify that the stored auth token is still valid. 817 | Args: 818 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 819 | Returns: True if the stored auth token is still value, False otherwise. 820 | """ 821 | return is_healthy(timeout_config=timeout_config) 822 | 823 | 824 | # endregion 825 | 826 | # region def validate_login(user_name, pw) 827 | 828 | 829 | def validate_login(user_name: str, pw: str): 830 | """ 831 | Internal use only. 832 | """ 833 | if not user_name: 834 | raise ValidationError('Username cannot be empty') 835 | if not pw: 836 | raise ValidationError('Password cannot be empty') 837 | 838 | 839 | # endregion 840 | 841 | # region def validate_state(url=False, user=False) 842 | 843 | 844 | def validate_state(url: bool = False) -> None: 845 | """ 846 | Internal use only. 847 | """ 848 | if url and not url_base: 849 | raise OperationNotAllowedError('URL Base must be set to proceed.') 850 | 851 | if not has_logged_in: 852 | raise OperationNotAllowedError('You must login before proceeding.') 853 | 854 | 855 | # endregion 856 | 857 | 858 | def test_user_pw_on_server(timeout_config: Optional[httpx.Timeout] = None) -> bool: 859 | """ 860 | Internal function to test if the username/password combination is valid on the server. 861 | 862 | Args: 863 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 864 | 865 | Returns: 866 | bool: True if the credentials are valid, False otherwise. 867 | """ 868 | if has_logged_in: 869 | return True 870 | 871 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 872 | 873 | # noinspection PyBroadException 874 | try: 875 | url = f'{url_base}{urls.health}' 876 | resp = httpx.get( 877 | url, 878 | auth=httpx.BasicAuth(username or '', password or ''), 879 | headers=core_headers, 880 | follow_redirects=True, 881 | timeout=timeout_config, 882 | ) 883 | resp.raise_for_status() 884 | 885 | return True 886 | except Exception: 887 | return False 888 | 889 | 890 | # region def campaigns() -> list[models.Campaign] 891 | 892 | 893 | def campaigns(timeout_config: Optional[httpx.Timeout] = None) -> list[models.Campaign]: 894 | """ 895 | Get campaigns on the server. 896 | Args: 897 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 898 | Returns: List of Campaign objects with the full details of that campaign. 899 | """ 900 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 901 | validate_state(url=True) 902 | 903 | url = f'{url_base}{urls.campaigns}?page=1&per_page=1000000' 904 | resp = httpx.get( 905 | url, 906 | auth=httpx.BasicAuth(username or '', password or ''), 907 | headers=core_headers, 908 | follow_redirects=True, 909 | timeout=timeout_config, 910 | ) 911 | data = _validate_and_parse_json_response(resp) 912 | list_of_campaigns = [models.Campaign(**d) for d in data.get('data', {}).get('results', [])] 913 | return list_of_campaigns 914 | 915 | 916 | # endregion 917 | 918 | # region def campaign_by_id(campaign_id: int) -> Optional[models.Campaign] 919 | 920 | 921 | def campaign_by_id(campaign_id: int, timeout_config: Optional[httpx.Timeout] = None) -> Optional[models.Campaign]: 922 | """ 923 | Get the full details of a campaign with the given ID. 924 | Args: 925 | campaign_id: A campaign to get the details about, e.g. 7. 926 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 927 | Returns: Campaign object with the full details of a campaign. 928 | """ 929 | # noinspection DuplicatedCode 930 | global core_headers 931 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 932 | validate_state(url=True) 933 | 934 | url = f'{url_base}{urls.campaign_id}' 935 | url = url.format(campaign_id=campaign_id) 936 | 937 | resp = httpx.get( 938 | url, 939 | auth=httpx.BasicAuth(username or '', password or ''), 940 | headers=core_headers, 941 | follow_redirects=True, 942 | timeout=timeout_config, 943 | ) 944 | data = _validate_and_parse_json_response(resp) 945 | campaign_data = data.get('data', {}) 946 | if not campaign_data: 947 | return None 948 | 949 | return models.Campaign(**campaign_data) 950 | 951 | 952 | # endregion 953 | 954 | 955 | # region def campaign_preview_by_id(campaign_id: int) -> Optional[models.CampaignPreview] 956 | 957 | 958 | def campaign_preview_by_id( 959 | campaign_id: int, timeout_config: Optional[httpx.Timeout] = None 960 | ) -> Optional[models.CampaignPreview]: 961 | """ 962 | Get the preview of a campaign with the given ID. 963 | Args: 964 | campaign_id: A campaign to get the details about, e.g. 7. 965 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 966 | Returns: String preview of the campaign. 967 | """ 968 | # noinspection DuplicatedCode 969 | global core_headers 970 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 971 | validate_state(url=True) 972 | 973 | url = f'{url_base}{urls.campaign_id_preview}' 974 | url = url.format(campaign_id=campaign_id) 975 | 976 | resp = httpx.get( 977 | url, 978 | auth=httpx.BasicAuth(username or '', password or ''), 979 | headers=core_headers, 980 | follow_redirects=True, 981 | timeout=timeout_config, 982 | ) 983 | resp.raise_for_status() 984 | preview = resp.text 985 | 986 | return models.CampaignPreview(preview=preview) 987 | 988 | 989 | # endregion 990 | 991 | # region def create_campaign(...) -> Optional[models.CreateCampaignModel] # noqa: F401, E402 992 | 993 | 994 | def create_campaign( 995 | name: Optional[str] = None, 996 | subject: Optional[str] = None, 997 | list_ids: Optional[set[int]] = None, 998 | from_email: Optional[str] = None, 999 | campaign_type: Optional[str] = None, 1000 | content_type: Optional[str] = None, 1001 | body: Optional[str] = None, 1002 | alt_body: Optional[str] = None, 1003 | send_at: Optional[datetime.datetime] = None, 1004 | messenger: Optional[str] = None, 1005 | template_id: Optional[int] = None, 1006 | tags: Optional[list[str]] = None, # noqa 1007 | headers: Optional[dict[str, Optional[str]]] = None, # noqa 1008 | timeout_config: Optional[httpx.Timeout] = None, 1009 | ) -> Optional[models.Campaign]: 1010 | """ 1011 | Create a new campaign with the given parameters. 1012 | 1013 | Parameters: 1014 | name (Optional[str]): The name of the campaign. 1015 | subject (Optional[str]): The subject of the campaign. 1016 | list_ids (set[int]): A set of list IDs to send the campaign to. Defaults to 1. 1017 | from_email (Optional[str]): 'From' email in campaign emails. Defaults to value from settings if not provided. 1018 | campaign_type (Optional[str]): The type of the campaign: 'regular' or 'optin'. 1019 | content_type (Optional[str]): The content type of the campaign: 'richtext', 'html', 'markdown', 'plain'. 1020 | body (Optional[str]): The body of the campaign. 1021 | alt_body (Optional[str]): The alternative text body of the campaign. 1022 | send_at (Optional[datetime.datetime]): Timestamp to schedule campaign. 1023 | messenger (Optional[str]): The messenger for the campaign. Usually 'email' 1024 | template_id (int): The template ID to be used for the campaign. Defaults to 1. 1025 | tags (list[str]): A list of tags for the campaign. 1026 | headers (list[dict]): A list of headers for the campaign. 1027 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1028 | 1029 | Returns: 1030 | CreateCampaignModel: A model representing the created campaign. 1031 | 1032 | Raises: 1033 | ValueError: If required parameters (name, subject, from_email) are not provided. 1034 | """ 1035 | if headers is None: 1036 | headers = {} 1037 | if tags is None: 1038 | tags = [] 1039 | 1040 | validate_state(url=True) 1041 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1042 | from_email = (from_email or '').lower().strip() 1043 | name = (name or '').strip() 1044 | if not name: 1045 | raise ValueError('Name is required') 1046 | if not subject: 1047 | raise ValueError('Subject is required') 1048 | if list_ids is None: # The Default list is 1. 1049 | list_ids = {1} 1050 | 1051 | model = models.CreateCampaignModel( 1052 | name=name, 1053 | subject=subject, 1054 | lists=list(list_ids), 1055 | from_email=from_email, 1056 | type=campaign_type, 1057 | content_type=content_type, 1058 | body=body, 1059 | altbody=alt_body, 1060 | send_at=send_at, 1061 | messenger=messenger, 1062 | template_id=template_id, 1063 | tags=tags, 1064 | headers=headers, 1065 | ) 1066 | # noinspection DuplicatedCode 1067 | url = f'{url_base}{urls.campaigns}' 1068 | resp = httpx.post( 1069 | url, 1070 | auth=httpx.BasicAuth(username or '', password or ''), 1071 | json=model.model_dump(), 1072 | headers=core_headers, 1073 | follow_redirects=True, 1074 | timeout=timeout_config, 1075 | ) 1076 | raw_data = _validate_and_parse_json_response(resp) 1077 | campaign_data = raw_data['data'] 1078 | return models.Campaign(**campaign_data) 1079 | 1080 | 1081 | # endregion 1082 | 1083 | # region def delete_campaign(campaign_id: Optional[str] = None) -> bool 1084 | 1085 | 1086 | def delete_campaign(campaign_id: Optional[int] = None, timeout_config: Optional[httpx.Timeout] = None) -> bool: 1087 | """ 1088 | Completely delete a campaign from your system. 1089 | 1090 | Args: 1091 | campaign_id: name of the campaign to delete. 1092 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1093 | Returns: True if the campaign was successfully deleted, False otherwise. 1094 | """ 1095 | global core_headers 1096 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1097 | validate_state(url=True) 1098 | 1099 | if not campaign_id: 1100 | raise ValueError('Campaign ID is required') 1101 | 1102 | campaign = campaign_by_id(campaign_id, timeout_config) 1103 | # noinspection DuplicatedCode 1104 | if not campaign: 1105 | return False 1106 | 1107 | url = f'{url_base}{urls.campaign_id.format(campaign_id=campaign_id)}' 1108 | resp = httpx.delete( 1109 | url, 1110 | auth=httpx.BasicAuth(username or '', password or ''), 1111 | headers=core_headers, 1112 | follow_redirects=True, 1113 | timeout=timeout_config, 1114 | ) 1115 | raw_data = _validate_and_parse_json_response(resp) 1116 | return bool(raw_data.get('data')) # Looks like {'data': True} 1117 | 1118 | 1119 | # endregion 1120 | 1121 | # region def update_campaign(campaign: models.Campaign) 1122 | 1123 | 1124 | def update_campaign( 1125 | campaign: models.Campaign, 1126 | timeout_config: Optional[httpx.Timeout] = None, 1127 | ) -> Optional[models.Campaign]: 1128 | """ 1129 | Update the given campaign with the provided campaign information. 1130 | 1131 | Parameters: 1132 | campaign: models.Campaign - The campaign object containing the updated information. 1133 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1134 | 1135 | Returns: 1136 | models.Campaign - The updated campaign object from api. 1137 | 1138 | Raises: 1139 | ValueError: If the campaign parameter is None or if the campaign id is not present. 1140 | """ 1141 | global core_headers 1142 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1143 | validate_state(url=True) 1144 | if campaign is None or not campaign.id: # type: ignore 1145 | raise ValueError('Campaign is required') 1146 | 1147 | update_lists = [item['id'] if isinstance(item, dict) else item for item in campaign.lists] # type: ignore 1148 | 1149 | update_model = models.UpdateCampaignModel( 1150 | name=campaign.name, 1151 | subject=campaign.subject, 1152 | lists=list(update_lists), # Convert to list to ensure type is known # type: ignore 1153 | from_email=campaign.from_email, 1154 | type=campaign.type, 1155 | content_type=campaign.content_type, 1156 | body=campaign.body, 1157 | altbody=campaign.altbody, 1158 | send_at=campaign.send_at, 1159 | messenger=campaign.messenger, 1160 | template_id=campaign.template_id, 1161 | tags=campaign.tags, 1162 | headers=campaign.headers, # type: ignore 1163 | ) 1164 | 1165 | url = f'{url_base}{urls.campaign_id.format(campaign_id=campaign.id)}' 1166 | resp = httpx.put( 1167 | url, 1168 | auth=httpx.BasicAuth(username or '', password or ''), 1169 | json=update_model.model_dump(), 1170 | headers=core_headers, 1171 | follow_redirects=True, 1172 | timeout=timeout_config, 1173 | ) 1174 | resp.raise_for_status() 1175 | 1176 | return campaign_by_id(campaign.id, timeout_config) 1177 | 1178 | 1179 | # endregion 1180 | 1181 | 1182 | # region def templates() -> list[models.Template] 1183 | 1184 | 1185 | def templates(timeout_config: Optional[httpx.Timeout] = None) -> list[models.Template]: 1186 | """ 1187 | This function retrieves a list of all templates available in the system. 1188 | 1189 | Args: 1190 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1191 | Returns: 1192 | list of models.Template objects representing the templates available in the system. 1193 | """ 1194 | # noinspection DuplicatedCode 1195 | validate_state(url=True) 1196 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1197 | 1198 | url = f'{url_base}{urls.templates}?page=1&per_page=1000000' 1199 | resp = httpx.get( 1200 | url, 1201 | auth=httpx.BasicAuth(username or '', password or ''), 1202 | headers=core_headers, 1203 | follow_redirects=True, 1204 | timeout=timeout_config, 1205 | ) 1206 | data = _validate_and_parse_json_response(resp) 1207 | list_of_templates = [models.Template(**d) for d in data.get('data', [])] 1208 | return list_of_templates 1209 | 1210 | 1211 | # endregion 1212 | 1213 | 1214 | # region def create_template(...) -> Optional[models.CreateTemplateModel] # noqa: F401, E402 1215 | 1216 | 1217 | # noinspection PyShadowingBuiltins 1218 | def create_template( 1219 | name: Optional[str] = None, 1220 | body: Optional[str] = None, 1221 | type: Optional[str] = None, 1222 | is_default: Optional[bool] = None, 1223 | subject: Optional[str] = None, 1224 | timeout_config: Optional[httpx.Timeout] = None, 1225 | ) -> Optional[models.Template]: 1226 | """ 1227 | Create a template with the specified details. 1228 | 1229 | Parameters: 1230 | name (Optional[str]): The name of the template. 1231 | body (Optional[str]): The body content of the template. 1232 | type (Optional[str]): The type of the template. 1233 | is_default (Optional[bool]): Indicates if the template is the default one. 1234 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1235 | 1236 | Returns: 1237 | Optional[models.Template]: An instance of models.Template representing the created template. 1238 | """ 1239 | validate_state(url=True) 1240 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1241 | name = (name or '').strip() 1242 | if not name: 1243 | raise ValueError('Name is required') 1244 | if not body: 1245 | raise ValueError('Body is required') 1246 | if """{{ template "content" . }}""" not in body: 1247 | raise ValueError("""The placeholder {{ template "content" . }} should appear exactly once in the template.""") 1248 | 1249 | model = models.CreateTemplateModel( 1250 | name=name, 1251 | subject=subject, 1252 | body=body, 1253 | type=type, 1254 | is_default=is_default, 1255 | ) 1256 | 1257 | # noinspection DuplicatedCode 1258 | url = f'{url_base}{urls.templates}' 1259 | resp = httpx.post( 1260 | url, 1261 | auth=httpx.BasicAuth(username or '', password or ''), 1262 | json=model.model_dump(), 1263 | headers=core_headers, 1264 | follow_redirects=True, 1265 | timeout=timeout_config, 1266 | ) 1267 | raw_data = _validate_and_parse_json_response(resp) 1268 | template_data = raw_data['data'] 1269 | return models.Template(**template_data) 1270 | 1271 | 1272 | # endregion 1273 | 1274 | 1275 | # region def template_by_id(template_id: int) -> Optional[models.template] 1276 | 1277 | 1278 | def template_by_id(template_id: int, timeout_config: Optional[httpx.Timeout] = None) -> Optional[models.Template]: 1279 | """ 1280 | Retrieve a template by its ID. 1281 | 1282 | Parameters: 1283 | template_id (int): The ID of the template to retrieve. 1284 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1285 | 1286 | Returns: 1287 | Optional[models.Template]: The template object retrieved based on the ID provided. 1288 | """ 1289 | # noinspection DuplicatedCode 1290 | global core_headers 1291 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1292 | validate_state(url=True) 1293 | 1294 | url = f'{url_base}{urls.template_id}' 1295 | url = url.format(template_id=template_id) 1296 | 1297 | resp = httpx.get( 1298 | url, 1299 | auth=httpx.BasicAuth(username or '', password or ''), 1300 | headers=core_headers, 1301 | follow_redirects=True, 1302 | timeout=timeout_config, 1303 | ) 1304 | data = _validate_and_parse_json_response(resp) 1305 | template_data = data.get('data', {}) 1306 | 1307 | return models.Template(**template_data) # type: ignore 1308 | 1309 | 1310 | # endregion 1311 | 1312 | # region def template_preview_by_id(template_id: int) -> Optional[models.TemplatePreview] 1313 | 1314 | 1315 | def template_preview_by_id( 1316 | template_id: int, timeout_config: Optional[httpx.Timeout] = None 1317 | ) -> Optional[models.TemplatePreview]: 1318 | """ 1319 | Get the preview of a template with the given ID. 1320 | Args: 1321 | template_id: A campaign to get the details about, e.g. 7. 1322 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1323 | Returns: String preview of the template with lorem ipsum. 1324 | """ 1325 | # noinspection DuplicatedCode 1326 | global core_headers 1327 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1328 | validate_state(url=True) 1329 | 1330 | url = f'{url_base}{urls.template_id_preview}' 1331 | url = url.format(template_id=template_id) 1332 | 1333 | resp = httpx.get( 1334 | url, 1335 | auth=httpx.BasicAuth(username or '', password or ''), 1336 | headers=core_headers, 1337 | follow_redirects=True, 1338 | timeout=timeout_config, 1339 | ) 1340 | resp.raise_for_status() 1341 | preview = resp.text 1342 | 1343 | return models.TemplatePreview(preview=preview) 1344 | 1345 | 1346 | # endregion 1347 | 1348 | 1349 | # region def delete_template(template_id: Optional[str] = None) -> bool 1350 | 1351 | 1352 | def delete_template(template_id: Optional[int] = None, timeout_config: Optional[httpx.Timeout] = None) -> bool: 1353 | """ 1354 | Completely delete a template from your system. 1355 | 1356 | Args: 1357 | template_id: name of the template to delete. 1358 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1359 | Returns: True if the template was successfully deleted, False otherwise. 1360 | """ 1361 | # noinspection DuplicatedCode 1362 | global core_headers 1363 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1364 | validate_state(url=True) 1365 | 1366 | if not template_id: 1367 | raise ValueError('Template ID is required') 1368 | 1369 | template = template_by_id(template_id, timeout_config) 1370 | if not template: 1371 | return False 1372 | 1373 | url = f'{url_base}{urls.template_id.format(template_id=template_id)}' 1374 | resp = httpx.delete( 1375 | url, 1376 | auth=httpx.BasicAuth(username or '', password or ''), 1377 | headers=core_headers, 1378 | follow_redirects=True, 1379 | timeout=timeout_config, 1380 | ) 1381 | raw_data = _validate_and_parse_json_response(resp) 1382 | return bool(raw_data.get('data')) # Looks like {'data': True} 1383 | 1384 | 1385 | # endregion 1386 | 1387 | 1388 | # region def update_template(template: models.Template) 1389 | 1390 | 1391 | def update_template( 1392 | template: models.Template, 1393 | timeout_config: Optional[httpx.Timeout] = None, 1394 | ) -> Optional[models.Template]: 1395 | """ 1396 | Update a template in the system. 1397 | 1398 | Parameters: 1399 | template: models.Template - the template object to be updated 1400 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1401 | 1402 | Returns: 1403 | models.Template - the updated template object after the update operation 1404 | """ 1405 | global core_headers 1406 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1407 | validate_state(url=True) 1408 | if template is None or not template.id: # type: ignore 1409 | raise ValueError('Template is required') 1410 | 1411 | update_model = models.CreateTemplateModel( 1412 | name=template.name, 1413 | subject=template.subject, 1414 | body=template.body, 1415 | type=template.type, 1416 | ) 1417 | 1418 | url = f'{url_base}{urls.template_id.format(template_id=template.id)}' 1419 | resp = httpx.put( 1420 | url, 1421 | auth=httpx.BasicAuth(username or '', password or ''), 1422 | json=update_model.model_dump(), 1423 | headers=core_headers, 1424 | follow_redirects=True, 1425 | timeout=timeout_config, 1426 | ) 1427 | resp.raise_for_status() 1428 | 1429 | return template_by_id(template.id, timeout_config) 1430 | 1431 | 1432 | # endregion 1433 | 1434 | 1435 | # region def set_default_template(template_id: Optional[str] = None) -> bool 1436 | 1437 | 1438 | def set_default_template(template_id: Optional[int] = None, timeout_config: Optional[httpx.Timeout] = None) -> bool: 1439 | """ 1440 | Set the given template ID as the default template. 1441 | 1442 | Parameters: 1443 | template_id (Optional[int]): The ID of the template to set as default. If not provided, a ValueError is raised. 1444 | timeout_config: Optional timeout configuration for the request. Default is 10 seconds. 1445 | 1446 | Returns: 1447 | bool: True if the default template was set successfully, False otherwise. 1448 | """ 1449 | # noinspection DuplicatedCode 1450 | global core_headers 1451 | timeout_config = timeout_config or httpx.Timeout(timeout=10) 1452 | validate_state(url=True) 1453 | 1454 | if not template_id: 1455 | raise ValueError('Template ID is required') 1456 | 1457 | template = template_by_id(template_id, timeout_config) 1458 | if not template: 1459 | return False 1460 | 1461 | url = f'{url_base}{urls.template_id_default.format(template_id=template_id)}' 1462 | resp = httpx.put( 1463 | url, 1464 | auth=httpx.BasicAuth(username or '', password or ''), 1465 | headers=core_headers, 1466 | follow_redirects=True, 1467 | timeout=timeout_config, 1468 | ) 1469 | raw_data = _validate_and_parse_json_response(resp) 1470 | return bool(raw_data.get('data')) # Looks like {'data': True} 1471 | 1472 | 1473 | # endregion 1474 | 1475 | 1476 | # region def set_default_template(template_id: Optional[str] = None) -> bool 1477 | 1478 | 1479 | def create_list( 1480 | list_name: str, 1481 | list_type: str = 'public', 1482 | optin: str = 'single', 1483 | tags: Optional[list[str]] = None, 1484 | description: Optional[str] = None, 1485 | ) -> Optional[models.MailingList]: 1486 | """ 1487 | Create a new mailing list on the server. 1488 | Args: 1489 | list_name: Name of the new list. 1490 | list_type: Type of list. Options: "private", "public". Defaults to "public". 1491 | optin: Opt-in type. Options: "single", "double". Defaults to "single". 1492 | tags: Optional list of tags associated with the list. 1493 | description: Optional description for the new list. 1494 | Returns: 1495 | The MailingList object that was created on the server. 1496 | """ 1497 | global core_headers 1498 | 1499 | validate_state(url=True) 1500 | list_name = (list_name or '').strip() 1501 | 1502 | if not list_name: 1503 | raise ValueError('List name is required') 1504 | 1505 | if list_type not in ['public', 'private']: 1506 | raise ValueError("list_type must be either 'public' or 'private'") 1507 | 1508 | if optin not in ['single', 'double']: 1509 | raise ValueError("optin must be either 'single' or 'double'") 1510 | 1511 | payload: dict[str, Any] = { 1512 | 'name': list_name, 1513 | 'type': list_type, 1514 | 'optin': optin, 1515 | } 1516 | 1517 | if tags is not None: 1518 | payload['tags'] = tags 1519 | 1520 | if description is not None: 1521 | payload['description'] = description 1522 | 1523 | url = f'{url_base}{urls.lists}' 1524 | 1525 | resp = httpx.post( 1526 | url, 1527 | auth=httpx.BasicAuth(username or '', password or ''), 1528 | json=payload, 1529 | headers=core_headers, 1530 | follow_redirects=True, 1531 | ) 1532 | raw_data = _validate_and_parse_json_response(resp) 1533 | list_data = raw_data['data'] 1534 | 1535 | return models.MailingList(**list_data) 1536 | 1537 | 1538 | # endregion 1539 | 1540 | 1541 | # region def delete_list(list_id: int) -> bool: 1542 | 1543 | 1544 | def delete_list(list_id: int) -> bool: 1545 | """ 1546 | Delete a specific list by its ID. 1547 | Args: 1548 | list_id: The ID of the list to delete. 1549 | Returns: 1550 | True if the list was successfully deleted, False otherwise. 1551 | """ 1552 | 1553 | global core_headers 1554 | 1555 | validate_state(url=True) 1556 | 1557 | if not list_id: 1558 | raise ValueError('List ID is required to delete a list.') 1559 | 1560 | # Check if the list exists first (optional, but good practice) 1561 | # This prevents attempting to delete a non-existent list, though the API might handle it gracefully. 1562 | existing_list = list_by_id(list_id) 1563 | 1564 | if not existing_list: 1565 | # Or raise an error? Depending on desired behavior. 1566 | 1567 | return False 1568 | 1569 | url = f'{url_base}{urls.lst}' 1570 | url = url.format(list_id=list_id) 1571 | 1572 | resp = httpx.delete( 1573 | url, 1574 | auth=httpx.BasicAuth(username or '', password or ''), 1575 | headers=core_headers, 1576 | follow_redirects=True, 1577 | timeout=30, 1578 | ) 1579 | raw_data = _validate_and_parse_json_response(resp) 1580 | 1581 | # Expecting {'data': True} on success 1582 | return raw_data.get('data', False) 1583 | 1584 | 1585 | # endregion 1586 | --------------------------------------------------------------------------------