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.
Files changed (42) hide show
  1. penguin_email-0.1.0/LICENSE +21 -0
  2. penguin_email-0.1.0/PKG-INFO +95 -0
  3. penguin_email-0.1.0/README.md +52 -0
  4. penguin_email-0.1.0/pyproject.toml +86 -0
  5. penguin_email-0.1.0/setup.cfg +4 -0
  6. penguin_email-0.1.0/src/penguin_email/__init__.py +30 -0
  7. penguin_email-0.1.0/src/penguin_email/auth/__init__.py +5 -0
  8. penguin_email-0.1.0/src/penguin_email/auth/gmail_oauth.py +73 -0
  9. penguin_email-0.1.0/src/penguin_email/cli.py +134 -0
  10. penguin_email-0.1.0/src/penguin_email/client.py +173 -0
  11. penguin_email-0.1.0/src/penguin_email/dkim_signing.py +139 -0
  12. penguin_email-0.1.0/src/penguin_email/message.py +324 -0
  13. penguin_email-0.1.0/src/penguin_email/signature.py +33 -0
  14. penguin_email-0.1.0/src/penguin_email/templates/__init__.py +5 -0
  15. penguin_email-0.1.0/src/penguin_email/templates/builtin/alert.html.j2 +15 -0
  16. penguin_email-0.1.0/src/penguin_email/templates/builtin/base.html.j2 +127 -0
  17. penguin_email-0.1.0/src/penguin_email/templates/builtin/form.html.j2 +17 -0
  18. penguin_email-0.1.0/src/penguin_email/templates/builtin/notification.html.j2 +10 -0
  19. penguin_email-0.1.0/src/penguin_email/templates/builtin/password_reset.html.j2 +11 -0
  20. penguin_email-0.1.0/src/penguin_email/templates/builtin/transactional.html.j2 +35 -0
  21. penguin_email-0.1.0/src/penguin_email/templates/builtin/welcome.html.j2 +11 -0
  22. penguin_email-0.1.0/src/penguin_email/templates/engine.py +98 -0
  23. penguin_email-0.1.0/src/penguin_email/transports/__init__.py +48 -0
  24. penguin_email-0.1.0/src/penguin_email/transports/gmail.py +241 -0
  25. penguin_email-0.1.0/src/penguin_email/transports/sendgrid.py +182 -0
  26. penguin_email-0.1.0/src/penguin_email/transports/smtp.py +233 -0
  27. penguin_email-0.1.0/src/penguin_email.egg-info/PKG-INFO +95 -0
  28. penguin_email-0.1.0/src/penguin_email.egg-info/SOURCES.txt +40 -0
  29. penguin_email-0.1.0/src/penguin_email.egg-info/dependency_links.txt +1 -0
  30. penguin_email-0.1.0/src/penguin_email.egg-info/entry_points.txt +2 -0
  31. penguin_email-0.1.0/src/penguin_email.egg-info/requires.txt +24 -0
  32. penguin_email-0.1.0/src/penguin_email.egg-info/top_level.txt +1 -0
  33. penguin_email-0.1.0/tests/test_auth.py +62 -0
  34. penguin_email-0.1.0/tests/test_cli.py +234 -0
  35. penguin_email-0.1.0/tests/test_client.py +101 -0
  36. penguin_email-0.1.0/tests/test_dkim_signing.py +175 -0
  37. penguin_email-0.1.0/tests/test_gmail_transport.py +232 -0
  38. penguin_email-0.1.0/tests/test_message.py +127 -0
  39. penguin_email-0.1.0/tests/test_sendgrid_transport.py +244 -0
  40. penguin_email-0.1.0/tests/test_signature.py +211 -0
  41. penguin_email-0.1.0/tests/test_smtp_transport.py +282 -0
  42. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ """Gmail OAuth2 authentication helpers."""
2
+
3
+ from .gmail_oauth import refresh_credentials, run_oauth_flow
4
+
5
+ __all__ = ["run_oauth_flow", "refresh_credentials"]
@@ -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