penguin-email 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- penguin_email-0.1.0/LICENSE +21 -0
- penguin_email-0.1.0/PKG-INFO +95 -0
- penguin_email-0.1.0/README.md +52 -0
- penguin_email-0.1.0/pyproject.toml +86 -0
- penguin_email-0.1.0/setup.cfg +4 -0
- penguin_email-0.1.0/src/penguin_email/__init__.py +30 -0
- penguin_email-0.1.0/src/penguin_email/auth/__init__.py +5 -0
- penguin_email-0.1.0/src/penguin_email/auth/gmail_oauth.py +73 -0
- penguin_email-0.1.0/src/penguin_email/cli.py +134 -0
- penguin_email-0.1.0/src/penguin_email/client.py +173 -0
- penguin_email-0.1.0/src/penguin_email/dkim_signing.py +139 -0
- penguin_email-0.1.0/src/penguin_email/message.py +324 -0
- penguin_email-0.1.0/src/penguin_email/signature.py +33 -0
- penguin_email-0.1.0/src/penguin_email/templates/__init__.py +5 -0
- penguin_email-0.1.0/src/penguin_email/templates/builtin/alert.html.j2 +15 -0
- penguin_email-0.1.0/src/penguin_email/templates/builtin/base.html.j2 +127 -0
- penguin_email-0.1.0/src/penguin_email/templates/builtin/form.html.j2 +17 -0
- penguin_email-0.1.0/src/penguin_email/templates/builtin/notification.html.j2 +10 -0
- penguin_email-0.1.0/src/penguin_email/templates/builtin/password_reset.html.j2 +11 -0
- penguin_email-0.1.0/src/penguin_email/templates/builtin/transactional.html.j2 +35 -0
- penguin_email-0.1.0/src/penguin_email/templates/builtin/welcome.html.j2 +11 -0
- penguin_email-0.1.0/src/penguin_email/templates/engine.py +98 -0
- penguin_email-0.1.0/src/penguin_email/transports/__init__.py +48 -0
- penguin_email-0.1.0/src/penguin_email/transports/gmail.py +241 -0
- penguin_email-0.1.0/src/penguin_email/transports/sendgrid.py +182 -0
- penguin_email-0.1.0/src/penguin_email/transports/smtp.py +233 -0
- penguin_email-0.1.0/src/penguin_email.egg-info/PKG-INFO +95 -0
- penguin_email-0.1.0/src/penguin_email.egg-info/SOURCES.txt +40 -0
- penguin_email-0.1.0/src/penguin_email.egg-info/dependency_links.txt +1 -0
- penguin_email-0.1.0/src/penguin_email.egg-info/entry_points.txt +2 -0
- penguin_email-0.1.0/src/penguin_email.egg-info/requires.txt +24 -0
- penguin_email-0.1.0/src/penguin_email.egg-info/top_level.txt +1 -0
- penguin_email-0.1.0/tests/test_auth.py +62 -0
- penguin_email-0.1.0/tests/test_cli.py +234 -0
- penguin_email-0.1.0/tests/test_client.py +101 -0
- penguin_email-0.1.0/tests/test_dkim_signing.py +175 -0
- penguin_email-0.1.0/tests/test_gmail_transport.py +232 -0
- penguin_email-0.1.0/tests/test_message.py +127 -0
- penguin_email-0.1.0/tests/test_sendgrid_transport.py +244 -0
- penguin_email-0.1.0/tests/test_signature.py +211 -0
- penguin_email-0.1.0/tests/test_smtp_transport.py +282 -0
- penguin_email-0.1.0/tests/test_templates.py +110 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Penguin Tech Inc
|
|
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.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: penguin-email
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pluggable email sending library for Penguin Tech applications
|
|
5
|
+
Author-email: Penguin Tech Inc <dev@penguintech.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.penguintech.io
|
|
8
|
+
Project-URL: Repository, https://github.com/penguintechinc/penguin-libs
|
|
9
|
+
Keywords: penguintech,email,gmail,smtp,jinja2
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Communications :: Email
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.13
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: jinja2>=3.1
|
|
23
|
+
Provides-Extra: gmail
|
|
24
|
+
Requires-Dist: google-auth>=2.0; extra == "gmail"
|
|
25
|
+
Requires-Dist: google-auth-oauthlib>=1.0; extra == "gmail"
|
|
26
|
+
Requires-Dist: google-api-python-client>=2.0; extra == "gmail"
|
|
27
|
+
Provides-Extra: sendgrid
|
|
28
|
+
Requires-Dist: requests>=2.31; extra == "sendgrid"
|
|
29
|
+
Provides-Extra: dkim
|
|
30
|
+
Requires-Dist: dkimpy>=1.1.5; extra == "dkim"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
36
|
+
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
37
|
+
Requires-Dist: google-auth>=2.0; extra == "dev"
|
|
38
|
+
Requires-Dist: google-auth-oauthlib>=1.0; extra == "dev"
|
|
39
|
+
Requires-Dist: google-api-python-client>=2.0; extra == "dev"
|
|
40
|
+
Requires-Dist: requests>=2.31; extra == "dev"
|
|
41
|
+
Requires-Dist: dkimpy>=1.1.5; extra == "dev"
|
|
42
|
+
Dynamic: license-file
|
|
43
|
+
|
|
44
|
+
# penguin-email
|
|
45
|
+
|
|
46
|
+
Pluggable email sending library for Penguin Tech applications.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- **Gmail REST API** transport (OAuth2, primary)
|
|
51
|
+
- **SMTP** transport (SSL / STARTTLS / PLAIN with `InsecureConnectionWarning`)
|
|
52
|
+
- Fluent `EmailMessage` builder with Jinja2 templating
|
|
53
|
+
- Built-in templates: welcome, notification, transactional, alert, password_reset, form
|
|
54
|
+
- Form builder and table builder helpers
|
|
55
|
+
- Full attachment support (files, bytes, inline images)
|
|
56
|
+
- Formal `EmailTransport` Protocol — add new transports with zero core changes
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install penguin-email # SMTP + templates only
|
|
62
|
+
pip install "penguin-email[gmail]" # adds Gmail REST API support
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Quick Start
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from penguin_email import EmailClient, EmailMessage, SmtpTransport, SmtpMode
|
|
69
|
+
|
|
70
|
+
transport = SmtpTransport(host="smtp.example.com", mode=SmtpMode.STARTTLS,
|
|
71
|
+
username="user", password="pass")
|
|
72
|
+
client = EmailClient(transport)
|
|
73
|
+
|
|
74
|
+
result = client.send(
|
|
75
|
+
EmailMessage()
|
|
76
|
+
.from_addr("sender@example.com")
|
|
77
|
+
.to("recipient@example.com")
|
|
78
|
+
.subject("Hello!")
|
|
79
|
+
.template("welcome", name="Alice", app_name="MyApp", login_url="https://app.example.com/login")
|
|
80
|
+
)
|
|
81
|
+
print(result.success, result.message_id)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Gmail Transport
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from penguin_email import EmailClient, EmailMessage, GmailTransport
|
|
88
|
+
|
|
89
|
+
transport = GmailTransport.from_env() # reads GMAIL_* env vars
|
|
90
|
+
client = EmailClient(transport)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
AGPL-3.0 — see LICENSE.md
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# penguin-email
|
|
2
|
+
|
|
3
|
+
Pluggable email sending library for Penguin Tech applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Gmail REST API** transport (OAuth2, primary)
|
|
8
|
+
- **SMTP** transport (SSL / STARTTLS / PLAIN with `InsecureConnectionWarning`)
|
|
9
|
+
- Fluent `EmailMessage` builder with Jinja2 templating
|
|
10
|
+
- Built-in templates: welcome, notification, transactional, alert, password_reset, form
|
|
11
|
+
- Form builder and table builder helpers
|
|
12
|
+
- Full attachment support (files, bytes, inline images)
|
|
13
|
+
- Formal `EmailTransport` Protocol — add new transports with zero core changes
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install penguin-email # SMTP + templates only
|
|
19
|
+
pip install "penguin-email[gmail]" # adds Gmail REST API support
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from penguin_email import EmailClient, EmailMessage, SmtpTransport, SmtpMode
|
|
26
|
+
|
|
27
|
+
transport = SmtpTransport(host="smtp.example.com", mode=SmtpMode.STARTTLS,
|
|
28
|
+
username="user", password="pass")
|
|
29
|
+
client = EmailClient(transport)
|
|
30
|
+
|
|
31
|
+
result = client.send(
|
|
32
|
+
EmailMessage()
|
|
33
|
+
.from_addr("sender@example.com")
|
|
34
|
+
.to("recipient@example.com")
|
|
35
|
+
.subject("Hello!")
|
|
36
|
+
.template("welcome", name="Alice", app_name="MyApp", login_url="https://app.example.com/login")
|
|
37
|
+
)
|
|
38
|
+
print(result.success, result.message_id)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Gmail Transport
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from penguin_email import EmailClient, EmailMessage, GmailTransport
|
|
45
|
+
|
|
46
|
+
transport = GmailTransport.from_env() # reads GMAIL_* env vars
|
|
47
|
+
client = EmailClient(transport)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
AGPL-3.0 — see LICENSE.md
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "penguin-email"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pluggable email sending library for Penguin Tech applications"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
authors = [{name = "Penguin Tech Inc", email = "dev@penguintech.io"}]
|
|
13
|
+
keywords = ["penguintech", "email", "gmail", "smtp", "jinja2"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Communications :: Email",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"jinja2>=3.1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
gmail = [
|
|
31
|
+
"google-auth>=2.0",
|
|
32
|
+
"google-auth-oauthlib>=1.0",
|
|
33
|
+
"google-api-python-client>=2.0",
|
|
34
|
+
]
|
|
35
|
+
sendgrid = [
|
|
36
|
+
"requests>=2.31",
|
|
37
|
+
]
|
|
38
|
+
dkim = [
|
|
39
|
+
"dkimpy>=1.1.5",
|
|
40
|
+
]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=7.0",
|
|
43
|
+
"pytest-cov>=4.0",
|
|
44
|
+
"ruff>=0.1.0",
|
|
45
|
+
"mypy>=1.0",
|
|
46
|
+
"bandit>=1.7",
|
|
47
|
+
"google-auth>=2.0",
|
|
48
|
+
"google-auth-oauthlib>=1.0",
|
|
49
|
+
"google-api-python-client>=2.0",
|
|
50
|
+
"requests>=2.31",
|
|
51
|
+
"dkimpy>=1.1.5",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[project.scripts]
|
|
55
|
+
penguin-email = "penguin_email.cli:main"
|
|
56
|
+
|
|
57
|
+
[project.urls]
|
|
58
|
+
Homepage = "https://www.penguintech.io"
|
|
59
|
+
Repository = "https://github.com/penguintechinc/penguin-libs"
|
|
60
|
+
|
|
61
|
+
[tool.setuptools.packages.find]
|
|
62
|
+
where = ["src"]
|
|
63
|
+
|
|
64
|
+
[tool.setuptools.package-data]
|
|
65
|
+
penguin_email = ["templates/builtin/*.j2"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff]
|
|
68
|
+
line-length = 100
|
|
69
|
+
target-version = "py311"
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = ["E", "F", "I", "N", "W", "UP"]
|
|
73
|
+
ignore = ["N802"]
|
|
74
|
+
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
python_version = "3.11"
|
|
77
|
+
warn_return_any = true
|
|
78
|
+
warn_unused_configs = true
|
|
79
|
+
disallow_untyped_defs = true
|
|
80
|
+
|
|
81
|
+
[tool.pytest.ini_options]
|
|
82
|
+
testpaths = ["tests"]
|
|
83
|
+
python_files = ["test_*.py"]
|
|
84
|
+
|
|
85
|
+
[tool.bandit]
|
|
86
|
+
skips = ["B101"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""penguin-email — pluggable email sending library for Penguin Tech applications."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .client import EmailClient
|
|
6
|
+
from .message import EmailMessage
|
|
7
|
+
from .signature import Signature
|
|
8
|
+
from .transports import EmailTransport, SendResult
|
|
9
|
+
from .transports.smtp import InsecureConnectionWarning, SmtpMode, SmtpTransport
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"__version__",
|
|
13
|
+
"EmailClient",
|
|
14
|
+
"EmailMessage",
|
|
15
|
+
"EmailTransport",
|
|
16
|
+
"SendResult",
|
|
17
|
+
"Signature",
|
|
18
|
+
"SmtpTransport",
|
|
19
|
+
"SmtpMode",
|
|
20
|
+
"InsecureConnectionWarning",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# GmailTransport is only importable when google-auth extras are installed.
|
|
24
|
+
# Import it lazily so SMTP-only users don't get an ImportError.
|
|
25
|
+
try:
|
|
26
|
+
from .transports.gmail import GmailTransport # noqa: F401
|
|
27
|
+
|
|
28
|
+
__all__ = [*__all__, "GmailTransport"]
|
|
29
|
+
except ImportError:
|
|
30
|
+
pass
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Gmail OAuth2 flow and token refresh helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_SCOPES = ["https://www.googleapis.com/auth/gmail.send"]
|
|
6
|
+
|
|
7
|
+
# Module-level imports so that unittest.mock.patch can target them.
|
|
8
|
+
# Both are None when the [gmail] extra is not installed.
|
|
9
|
+
try:
|
|
10
|
+
from google.auth.transport.requests import Request
|
|
11
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
12
|
+
except ImportError:
|
|
13
|
+
InstalledAppFlow = None # type: ignore[assignment, misc]
|
|
14
|
+
Request = None # type: ignore[assignment, misc]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_oauth_flow(
|
|
18
|
+
credentials_path: str,
|
|
19
|
+
token_path: str,
|
|
20
|
+
scopes: list[str] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Run the Google OAuth2 installed-app flow and save the token.
|
|
23
|
+
|
|
24
|
+
Opens a browser window for the user to grant permission. The resulting
|
|
25
|
+
token is written to *token_path* for later use by
|
|
26
|
+
:class:`~penguin_email.transports.gmail.GmailTransport`.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
credentials_path:
|
|
31
|
+
Path to the ``credentials.json`` file downloaded from the Google
|
|
32
|
+
Cloud Console (OAuth 2.0 Client IDs).
|
|
33
|
+
token_path:
|
|
34
|
+
Path where the resulting ``token.json`` will be written (or
|
|
35
|
+
overwritten on refresh).
|
|
36
|
+
scopes:
|
|
37
|
+
OAuth2 scopes to request. Defaults to
|
|
38
|
+
``["https://www.googleapis.com/auth/gmail.send"]``.
|
|
39
|
+
"""
|
|
40
|
+
if InstalledAppFlow is None:
|
|
41
|
+
raise ImportError(
|
|
42
|
+
"Gmail support requires 'google-auth-oauthlib'. "
|
|
43
|
+
"Install with: pip install 'penguin-email[gmail]'"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
requested_scopes = scopes or _SCOPES
|
|
47
|
+
flow = InstalledAppFlow.from_client_secrets_file(credentials_path, requested_scopes)
|
|
48
|
+
creds = flow.run_local_server(port=0)
|
|
49
|
+
|
|
50
|
+
with open(token_path, "w") as f:
|
|
51
|
+
f.write(creds.to_json())
|
|
52
|
+
|
|
53
|
+
print(f"Token saved to {token_path}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def refresh_credentials(creds: object) -> object:
|
|
57
|
+
"""Refresh an expired Google OAuth2 credential object.
|
|
58
|
+
|
|
59
|
+
Calls ``creds.refresh(Request())`` using the stored refresh token.
|
|
60
|
+
Returns the same credential object (mutated in place) for convenience.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
creds:
|
|
65
|
+
A :class:`google.oauth2.credentials.Credentials` instance.
|
|
66
|
+
"""
|
|
67
|
+
if Request is None:
|
|
68
|
+
raise ImportError(
|
|
69
|
+
"Gmail support requires 'google-auth'. Install with: pip install 'penguin-email[gmail]'"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
creds.refresh(Request()) # type: ignore[union-attr]
|
|
73
|
+
return creds
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""CLI entry point for penguin-email.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
python -m penguin_email auth --credentials credentials.json --token token.json
|
|
6
|
+
python -m penguin_email check
|
|
7
|
+
|
|
8
|
+
Or via the console script::
|
|
9
|
+
|
|
10
|
+
penguin-email auth --credentials credentials.json --token token.json
|
|
11
|
+
penguin-email check
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cmd_auth(args: argparse.Namespace) -> int:
|
|
22
|
+
"""Run the Gmail OAuth2 flow and save the token."""
|
|
23
|
+
try:
|
|
24
|
+
from .auth.gmail_oauth import run_oauth_flow
|
|
25
|
+
except ImportError:
|
|
26
|
+
print(
|
|
27
|
+
"ERROR: Gmail support not installed. Run: pip install 'penguin-email[gmail]'",
|
|
28
|
+
file=sys.stderr,
|
|
29
|
+
)
|
|
30
|
+
return 1
|
|
31
|
+
|
|
32
|
+
run_oauth_flow(
|
|
33
|
+
credentials_path=args.credentials,
|
|
34
|
+
token_path=args.token,
|
|
35
|
+
scopes=args.scopes or None,
|
|
36
|
+
)
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
41
|
+
"""Run a health check on the configured transport."""
|
|
42
|
+
mode = os.environ.get("EMAIL_TRANSPORT", "smtp").lower()
|
|
43
|
+
|
|
44
|
+
if mode == "gmail":
|
|
45
|
+
try:
|
|
46
|
+
from .transports.gmail import GmailTransport
|
|
47
|
+
|
|
48
|
+
transport = GmailTransport.from_env()
|
|
49
|
+
except ImportError:
|
|
50
|
+
print(
|
|
51
|
+
"ERROR: Gmail support not installed. Run: pip install 'penguin-email[gmail]'",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
return 1
|
|
55
|
+
except KeyError as exc:
|
|
56
|
+
print(f"ERROR: Missing environment variable: {exc}", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
else:
|
|
59
|
+
from .transports.smtp import SmtpMode, SmtpTransport
|
|
60
|
+
|
|
61
|
+
host = os.environ.get("SMTP_HOST", "localhost")
|
|
62
|
+
port_str = os.environ.get("SMTP_PORT", "")
|
|
63
|
+
mode_str = os.environ.get("SMTP_MODE", "starttls").upper()
|
|
64
|
+
username = os.environ.get("SMTP_USERNAME", "")
|
|
65
|
+
password = os.environ.get("SMTP_PASSWORD", "")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
smtp_mode = SmtpMode[mode_str]
|
|
69
|
+
except KeyError:
|
|
70
|
+
print(f"ERROR: Unknown SMTP_MODE '{mode_str}'", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
transport = SmtpTransport(
|
|
74
|
+
host=host,
|
|
75
|
+
port=int(port_str) if port_str else None,
|
|
76
|
+
mode=smtp_mode,
|
|
77
|
+
username=username,
|
|
78
|
+
password=password,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
ok = transport.health_check()
|
|
82
|
+
if ok:
|
|
83
|
+
print(f"✓ Transport '{transport.transport_name}' is healthy.")
|
|
84
|
+
return 0
|
|
85
|
+
else:
|
|
86
|
+
print(f"✗ Transport '{transport.transport_name}' health check FAILED.", file=sys.stderr)
|
|
87
|
+
return 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> None:
|
|
91
|
+
"""Entry point for ``penguin-email`` console script."""
|
|
92
|
+
parser = argparse.ArgumentParser(
|
|
93
|
+
prog="penguin-email",
|
|
94
|
+
description="penguin-email CLI — manage Gmail OAuth2 and check transport health",
|
|
95
|
+
)
|
|
96
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
97
|
+
|
|
98
|
+
# auth subcommand
|
|
99
|
+
auth_parser = subparsers.add_parser("auth", help="Run Gmail OAuth2 flow")
|
|
100
|
+
auth_parser.add_argument(
|
|
101
|
+
"--credentials",
|
|
102
|
+
default="credentials.json",
|
|
103
|
+
help="Path to Google credentials.json (default: credentials.json)",
|
|
104
|
+
)
|
|
105
|
+
auth_parser.add_argument(
|
|
106
|
+
"--token",
|
|
107
|
+
default="token.json",
|
|
108
|
+
help="Output path for token.json (default: token.json)",
|
|
109
|
+
)
|
|
110
|
+
auth_parser.add_argument(
|
|
111
|
+
"--scopes",
|
|
112
|
+
nargs="*",
|
|
113
|
+
help="OAuth2 scopes (default: gmail.send)",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# check subcommand
|
|
117
|
+
subparsers.add_parser(
|
|
118
|
+
"check",
|
|
119
|
+
help="Health-check the configured transport (reads EMAIL_TRANSPORT env var)",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
args = parser.parse_args()
|
|
123
|
+
|
|
124
|
+
if args.command == "auth":
|
|
125
|
+
sys.exit(_cmd_auth(args))
|
|
126
|
+
elif args.command == "check":
|
|
127
|
+
sys.exit(_cmd_check(args))
|
|
128
|
+
else:
|
|
129
|
+
parser.print_help()
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""EmailClient — orchestrates message building, rendering, and sending."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .message import EmailMessage
|
|
8
|
+
from .signature import Signature
|
|
9
|
+
from .templates.engine import TemplateRenderer
|
|
10
|
+
from .transports import EmailTransport, SendResult
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EmailClient:
|
|
16
|
+
"""High-level email client that validates, renders, and dispatches messages.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
transport:
|
|
21
|
+
Primary :class:`~penguin_email.transports.EmailTransport`.
|
|
22
|
+
fallback:
|
|
23
|
+
Optional secondary transport used when *fallback_on_error* is ``True``
|
|
24
|
+
and the primary transport raises an exception.
|
|
25
|
+
fallback_on_error:
|
|
26
|
+
When ``True``, a send error on the primary transport is logged and the
|
|
27
|
+
fallback transport is tried. Defaults to ``False`` (re-raise).
|
|
28
|
+
default_signature:
|
|
29
|
+
Optional :class:`~penguin_email.signature.Signature` appended to
|
|
30
|
+
every message sent through this client. A signature attached
|
|
31
|
+
directly to a message via
|
|
32
|
+
:meth:`~penguin_email.message.EmailMessage.signature` overrides
|
|
33
|
+
this default for that message only.
|
|
34
|
+
|
|
35
|
+
Raises
|
|
36
|
+
------
|
|
37
|
+
TypeError
|
|
38
|
+
If *transport* (or *fallback*) does not implement the
|
|
39
|
+
:class:`~penguin_email.transports.EmailTransport` protocol.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
transport: EmailTransport,
|
|
45
|
+
fallback: EmailTransport | None = None,
|
|
46
|
+
fallback_on_error: bool = False,
|
|
47
|
+
default_signature: Signature | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
if not isinstance(transport, EmailTransport):
|
|
50
|
+
raise TypeError(f"{transport!r} does not implement the EmailTransport protocol")
|
|
51
|
+
if fallback is not None and not isinstance(fallback, EmailTransport):
|
|
52
|
+
raise TypeError(f"{fallback!r} does not implement the EmailTransport protocol")
|
|
53
|
+
self._transport = transport
|
|
54
|
+
self._fallback = fallback
|
|
55
|
+
self._fallback_on_error = fallback_on_error
|
|
56
|
+
self._default_signature = default_signature
|
|
57
|
+
self._renderer = TemplateRenderer()
|
|
58
|
+
|
|
59
|
+
def send(self, message: EmailMessage) -> SendResult:
|
|
60
|
+
"""Validate, render, and send *message*.
|
|
61
|
+
|
|
62
|
+
1. Calls :meth:`~penguin_email.message.EmailMessage.build` to validate.
|
|
63
|
+
2. Renders the template (if any) into HTML.
|
|
64
|
+
3. Appends the effective signature (message override or client
|
|
65
|
+
default) to both the HTML and plain-text bodies.
|
|
66
|
+
4. Tries the primary transport.
|
|
67
|
+
5. Falls back to the secondary transport when configured.
|
|
68
|
+
|
|
69
|
+
Returns a :class:`~penguin_email.transports.SendResult` with
|
|
70
|
+
``transport_used`` set to the name of the transport that succeeded (or
|
|
71
|
+
attempted last).
|
|
72
|
+
"""
|
|
73
|
+
if not message.is_built:
|
|
74
|
+
message.build()
|
|
75
|
+
|
|
76
|
+
self._render_message(message)
|
|
77
|
+
self._apply_signature(message)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
return self._transport.send(message)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
if self._fallback_on_error and self._fallback is not None:
|
|
83
|
+
logger.warning(
|
|
84
|
+
"Primary transport '%s' failed (%s), trying fallback '%s'",
|
|
85
|
+
self._transport.transport_name,
|
|
86
|
+
exc,
|
|
87
|
+
self._fallback.transport_name,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
return self._fallback.send(message)
|
|
91
|
+
except Exception as fallback_exc:
|
|
92
|
+
logger.error(
|
|
93
|
+
"Fallback transport '%s' also failed: %s",
|
|
94
|
+
self._fallback.transport_name,
|
|
95
|
+
fallback_exc,
|
|
96
|
+
)
|
|
97
|
+
return SendResult(
|
|
98
|
+
success=False,
|
|
99
|
+
transport_used=self._fallback.transport_name,
|
|
100
|
+
error=str(fallback_exc),
|
|
101
|
+
)
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Internal helpers
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _render_message(self, message: EmailMessage) -> None:
|
|
109
|
+
"""Render the template (if any) and inject the result into the message.
|
|
110
|
+
|
|
111
|
+
After rendering, ``message._html_body`` is set so the transport only
|
|
112
|
+
needs to handle plain HTML. This mutates the message in place.
|
|
113
|
+
"""
|
|
114
|
+
if message.html_body:
|
|
115
|
+
# Already has raw HTML — nothing to render.
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if message.template_name:
|
|
119
|
+
html = self._renderer.render_builtin(message.template_name, **message.template_kwargs)
|
|
120
|
+
message._html_body = html # noqa: SLF001
|
|
121
|
+
elif message.template_path:
|
|
122
|
+
html = self._renderer.render_file(message.template_path, **message.template_kwargs)
|
|
123
|
+
message._html_body = html # noqa: SLF001
|
|
124
|
+
elif message.form_data is not None:
|
|
125
|
+
html = self._renderer.render_builtin(
|
|
126
|
+
"form",
|
|
127
|
+
title=message.template_kwargs.get("title", "Form Submission"),
|
|
128
|
+
data=message.form_data,
|
|
129
|
+
)
|
|
130
|
+
message._html_body = html # noqa: SLF001
|
|
131
|
+
|
|
132
|
+
def _apply_signature(self, message: EmailMessage) -> None:
|
|
133
|
+
"""Render the effective signature and append it to both bodies.
|
|
134
|
+
|
|
135
|
+
A per-message signature (``EmailMessage.signature()``) overrides the
|
|
136
|
+
client-wide ``default_signature``; if neither is set, the message is
|
|
137
|
+
left completely untouched. This runs once, here, in the client —
|
|
138
|
+
never in a transport — so every transport (SMTP, Gmail, SendGrid)
|
|
139
|
+
receives an already-signed ``html_body``/``text_body`` and cannot
|
|
140
|
+
forget to apply it.
|
|
141
|
+
|
|
142
|
+
Every transport independently falls back to
|
|
143
|
+
``strip_tags(html_body)`` for the plain-text body when
|
|
144
|
+
``text_body`` is empty. To keep the signature's own text form (when
|
|
145
|
+
supplied) out of that fallback — i.e. to avoid ending up with
|
|
146
|
+
``strip_tags(body_html + signature_html)`` instead of
|
|
147
|
+
``strip_tags(body_html) + signature.text`` — the body's
|
|
148
|
+
auto-generated plain text is captured *before* the signature's HTML
|
|
149
|
+
is appended, and the final ``text_body`` is written back here so
|
|
150
|
+
transports see it already populated and skip their own fallback.
|
|
151
|
+
"""
|
|
152
|
+
sig = message.signature_block or self._default_signature
|
|
153
|
+
if sig is None:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Capture the body's own auto-generated plain text before the
|
|
157
|
+
# signature's HTML is merged in.
|
|
158
|
+
base_text = message.text_body or (
|
|
159
|
+
self._renderer.strip_tags(message.html_body) if message.html_body else ""
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
sig_html = self._renderer.render_string(sig.html, **sig.variables) if sig.html else ""
|
|
163
|
+
if sig.text:
|
|
164
|
+
sig_text = self._renderer.render_string(sig.text, autoescape=False, **sig.variables)
|
|
165
|
+
elif sig_html:
|
|
166
|
+
sig_text = self._renderer.strip_tags(sig_html)
|
|
167
|
+
else:
|
|
168
|
+
sig_text = ""
|
|
169
|
+
|
|
170
|
+
if sig_html:
|
|
171
|
+
message._html_body = message.html_body + sig_html # noqa: SLF001
|
|
172
|
+
|
|
173
|
+
message._text_body = f"{base_text}\n\n{sig_text}" if sig_text else base_text # noqa: SLF001
|