dbrownell-email 0.2.5__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.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.3
2
+ Name: dbrownell-email
3
+ Version: 0.2.5
4
+ Summary: Email tools and utilities.
5
+ Author: David Brownell
6
+ Author-email: David Brownell <github@DavidBrownell.com>
7
+ License: MIT
8
+ Classifier: Operating System :: MacOS
9
+ Classifier: Operating System :: Microsoft :: Windows
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Requires-Dist: ansi2html>=1.9.2
17
+ Requires-Dist: dbrownell-common>=0.16.0
18
+ Requires-Dist: pywin32>=310 ; sys_platform == 'win32'
19
+ Requires-Dist: typer>=0.15.3
20
+ Requires-Python: >=3.10
21
+ Project-URL: Documentation, https://github.com/davidbrownell/dbrownell_Email
22
+ Project-URL: Homepage, https://github.com/davidbrownell/dbrownell_Email
23
+ Project-URL: Repository, https://github.com/davidbrownell/dbrownell_Email
24
+ Description-Content-Type: text/markdown
25
+
26
+ **Project:**
27
+ [![License](https://img.shields.io/github/license/davidbrownell/dbrownell_Email?color=dark-green)](https://github.com/davidbrownell/dbrownell_Email/blob/master/LICENSE)
28
+
29
+ **Package:**
30
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/dbrownell_Email?color=dark-green)](https://pypi.org/project/dbrownell_Email/)
31
+ [![PyPI - Version](https://img.shields.io/pypi/v/dbrownell_Email?color=dark-green)](https://pypi.org/project/dbrownell_Email/)
32
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/dbrownell_Email)](https://pypistats.org/packages/dbrownell-email)
33
+
34
+ **Development:**
35
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
36
+ [![CI](https://github.com/davidbrownell/dbrownell_Email/actions/workflows/CICD.yml/badge.svg)](https://github.com/davidbrownell/dbrownell_Email/actions/workflows/CICD.yml)
37
+ [![Code Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/davidbrownell/f15146b1b8fdc0a5d45ac0eb786a84f7/raw/dbrownell_Email_code_coverage.json)](https://github.com/davidbrownell/dbrownell_Email/actions)
38
+ [![GitHub commit activity](https://img.shields.io/github/commit-activity/y/davidbrownell/dbrownell_Email?color=dark-green)](https://github.com/davidbrownell/dbrownell_Email/commits/main/)
39
+
40
+ <!-- Content above this delimiter will be copied to the generated README.md file. DO NOT REMOVE THIS COMMENT, as it will cause regeneration to fail. -->
41
+
42
+ ## Contents
43
+ - [Overview](#overview)
44
+ - [Installation](#installation)
45
+ - [Development](#development)
46
+ - [Additional Information](#additional-information)
47
+ - [License](#license)
48
+
49
+ ## Overview
50
+ TODO: Complete this section
51
+
52
+ ### How to use dbrownell_Email
53
+ TODO: Complete this section
54
+
55
+ <!-- Content below this delimiter will be copied to the generated README.md file. DO NOT REMOVE THIS COMMENT, as it will cause regeneration to fail. -->
56
+
57
+ ## Installation
58
+
59
+ | Installation Method | Command |
60
+ | --- | --- |
61
+ | Via [uv](https://github.com/astral-sh/uv) | `uv add dbrownell_Email` |
62
+ | Via [pip](https://pip.pypa.io/en/stable/) | `pip install dbrownell_Email` |
63
+
64
+ ### Verifying Signed Artifacts
65
+ Artifacts are signed and verified using [py-minisign](https://github.com/x13a/py-minisign) and the public key in the file `./minisign_key.pub`.
66
+
67
+ To verify that an artifact is valid, visit [the latest release](https://github.com/davidbrownell/dbrownell_Email/releases/latest) and download the `.minisign` signature file that corresponds to the artifact, then run the following command, replacing `<filename>` with the name of the artifact to be verified:
68
+
69
+ ```shell
70
+ uv run --with py-minisign python -c "import minisign; minisign.PublicKey.from_file('minisign_key.pub').verify_file('<filename>'); print('The file has been verified.')"
71
+ ```
72
+
73
+ ## Development
74
+ Please visit [Contributing](https://github.com/davidbrownell/dbrownell_Email/blob/main/CONTRIBUTING.md) and [Development](https://github.com/davidbrownell/dbrownell_Email/blob/main/DEVELOPMENT.md) for information on contributing to this project.
75
+
76
+ ## Additional Information
77
+ Additional information can be found at these locations.
78
+
79
+ | Title | Document | Description |
80
+ | --- | --- | --- |
81
+ | Code of Conduct | [CODE_OF_CONDUCT.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/CODE_OF_CONDUCT.md) | Information about the norms, rules, and responsibilities we adhere to when participating in this open source community. |
82
+ | Contributing | [CONTRIBUTING.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/CONTRIBUTING.md) | Information about contributing to this project. |
83
+ | Development | [DEVELOPMENT.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/DEVELOPMENT.md) | Information about development activities involved in making changes to this project. |
84
+ | Governance | [GOVERNANCE.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/GOVERNANCE.md) | Information about how this project is governed. |
85
+ | Maintainers | [MAINTAINERS.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/MAINTAINERS.md) | Information about individuals who maintain this project. |
86
+ | Security | [SECURITY.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/SECURITY.md) | Information about how to privately report security issues associated with this project. |
87
+
88
+ ## License
89
+ dbrownell_Email is licensed under the <a href="https://choosealicense.com/licenses/MIT/" target="_blank">MIT</a> license.
@@ -0,0 +1,64 @@
1
+ **Project:**
2
+ [![License](https://img.shields.io/github/license/davidbrownell/dbrownell_Email?color=dark-green)](https://github.com/davidbrownell/dbrownell_Email/blob/master/LICENSE)
3
+
4
+ **Package:**
5
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/dbrownell_Email?color=dark-green)](https://pypi.org/project/dbrownell_Email/)
6
+ [![PyPI - Version](https://img.shields.io/pypi/v/dbrownell_Email?color=dark-green)](https://pypi.org/project/dbrownell_Email/)
7
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/dbrownell_Email)](https://pypistats.org/packages/dbrownell-email)
8
+
9
+ **Development:**
10
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
11
+ [![CI](https://github.com/davidbrownell/dbrownell_Email/actions/workflows/CICD.yml/badge.svg)](https://github.com/davidbrownell/dbrownell_Email/actions/workflows/CICD.yml)
12
+ [![Code Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/davidbrownell/f15146b1b8fdc0a5d45ac0eb786a84f7/raw/dbrownell_Email_code_coverage.json)](https://github.com/davidbrownell/dbrownell_Email/actions)
13
+ [![GitHub commit activity](https://img.shields.io/github/commit-activity/y/davidbrownell/dbrownell_Email?color=dark-green)](https://github.com/davidbrownell/dbrownell_Email/commits/main/)
14
+
15
+ <!-- Content above this delimiter will be copied to the generated README.md file. DO NOT REMOVE THIS COMMENT, as it will cause regeneration to fail. -->
16
+
17
+ ## Contents
18
+ - [Overview](#overview)
19
+ - [Installation](#installation)
20
+ - [Development](#development)
21
+ - [Additional Information](#additional-information)
22
+ - [License](#license)
23
+
24
+ ## Overview
25
+ TODO: Complete this section
26
+
27
+ ### How to use dbrownell_Email
28
+ TODO: Complete this section
29
+
30
+ <!-- Content below this delimiter will be copied to the generated README.md file. DO NOT REMOVE THIS COMMENT, as it will cause regeneration to fail. -->
31
+
32
+ ## Installation
33
+
34
+ | Installation Method | Command |
35
+ | --- | --- |
36
+ | Via [uv](https://github.com/astral-sh/uv) | `uv add dbrownell_Email` |
37
+ | Via [pip](https://pip.pypa.io/en/stable/) | `pip install dbrownell_Email` |
38
+
39
+ ### Verifying Signed Artifacts
40
+ Artifacts are signed and verified using [py-minisign](https://github.com/x13a/py-minisign) and the public key in the file `./minisign_key.pub`.
41
+
42
+ To verify that an artifact is valid, visit [the latest release](https://github.com/davidbrownell/dbrownell_Email/releases/latest) and download the `.minisign` signature file that corresponds to the artifact, then run the following command, replacing `<filename>` with the name of the artifact to be verified:
43
+
44
+ ```shell
45
+ uv run --with py-minisign python -c "import minisign; minisign.PublicKey.from_file('minisign_key.pub').verify_file('<filename>'); print('The file has been verified.')"
46
+ ```
47
+
48
+ ## Development
49
+ Please visit [Contributing](https://github.com/davidbrownell/dbrownell_Email/blob/main/CONTRIBUTING.md) and [Development](https://github.com/davidbrownell/dbrownell_Email/blob/main/DEVELOPMENT.md) for information on contributing to this project.
50
+
51
+ ## Additional Information
52
+ Additional information can be found at these locations.
53
+
54
+ | Title | Document | Description |
55
+ | --- | --- | --- |
56
+ | Code of Conduct | [CODE_OF_CONDUCT.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/CODE_OF_CONDUCT.md) | Information about the norms, rules, and responsibilities we adhere to when participating in this open source community. |
57
+ | Contributing | [CONTRIBUTING.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/CONTRIBUTING.md) | Information about contributing to this project. |
58
+ | Development | [DEVELOPMENT.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/DEVELOPMENT.md) | Information about development activities involved in making changes to this project. |
59
+ | Governance | [GOVERNANCE.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/GOVERNANCE.md) | Information about how this project is governed. |
60
+ | Maintainers | [MAINTAINERS.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/MAINTAINERS.md) | Information about individuals who maintain this project. |
61
+ | Security | [SECURITY.md](https://github.com/davidbrownell/dbrownell_Email/blob/main/SECURITY.md) | Information about how to privately report security issues associated with this project. |
62
+
63
+ ## License
64
+ dbrownell_Email is licensed under the <a href="https://choosealicense.com/licenses/MIT/" target="_blank">MIT</a> license.
@@ -0,0 +1,106 @@
1
+ [project]
2
+ name = "dbrownell-Email"
3
+ version = "0.2.5"
4
+ # ^^^^^
5
+ # Wheel names will be generated according to this value. Do not manually modify this value; instead
6
+ # update it according to committed changes by running this command from the root of the repository:
7
+ #
8
+ # uv run python -m AutoGitSemVer.scripts.UpdatePythonVersion ./pyproject.toml ./src
9
+
10
+ description = "Email tools and utilities."
11
+ readme = "README.md"
12
+ authors = [
13
+ { name = "David Brownell", email = "github@DavidBrownell.com" }
14
+ ]
15
+ requires-python = ">= 3.10"
16
+ dependencies = [
17
+ "ansi2html>=1.9.2",
18
+ "dbrownell-common>=0.16.0",
19
+ "pywin32>=310; sys_platform == 'win32'",
20
+ "typer>=0.15.3",
21
+ ]
22
+
23
+ classifiers = [
24
+ "Operating System :: MacOS",
25
+ "Operating System :: Microsoft :: Windows",
26
+ "Operating System :: POSIX :: Linux",
27
+ "Programming Language :: Python",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.10",
32
+ ]
33
+
34
+ [project.license]
35
+ text = "MIT"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/davidbrownell/dbrownell_Email"
39
+ Documentation = "https://github.com/davidbrownell/dbrownell_Email"
40
+ Repository = "https://github.com/davidbrownell/dbrownell_Email"
41
+
42
+ [project.scripts]
43
+ email_tee = "dbrownell_Email:EmailTee.app"
44
+
45
+ [build-system]
46
+ requires = ["uv_build>=0.8.15,<0.9.0"]
47
+ build-backend = "uv_build"
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "autogitsemver>=0.8.4",
52
+ "dbrownell-commitemojis>=0.1.1",
53
+ "pre-commit>=4.2.0",
54
+ "py-minisign>=0.12.0",
55
+ "pytest>=8.4.1",
56
+ "pytest-cov>=6.2.1",
57
+ "ruff>=0.12.3",
58
+ ]
59
+
60
+ [tool.hatch.version]
61
+ path = "src/dbrownell_Email/__init__.py"
62
+
63
+ [tool.pytest.ini_options]
64
+ addopts = "--verbose -vv --capture=no --cov=dbrownell_Email --cov-report term --cov-report xml:coverage.xml --cov-fail-under=35.0"
65
+
66
+ [tool.ruff]
67
+ line-length = 110
68
+
69
+ [tool.ruff.lint]
70
+ exclude = ["tests/**"]
71
+
72
+ select = ["ALL"]
73
+
74
+ ignore = [
75
+ "ANN002", # Missing type annotation for `*args`
76
+ "ANN003", # Missing type annotation for `**kwargs`
77
+ "BLE001", # Do not catch blind exception: `Exception`
78
+ "COM812", # Trailing comma missing
79
+ "D105", # Missing docstring in magic method
80
+ "D107", # Missing docstring in `__init__` method
81
+ "D202", # No blank lines allowed after function docstring
82
+ "E501", # Line too long
83
+ "FIX002", # Line contains TODO, consider resolving the issue
84
+ "I001", # Import block is un-sorted or un-formatted
85
+ "N802", # Function name `xxx` should be lowercase
86
+ "N999", # Invalid module name
87
+ "RSE102", # Unnecessary parentheses on raise exception
88
+ "S101", # Use of assert detected
89
+ "TC006", # Add quotes to type expression in `typing.cast()`
90
+ "TD002", # Missing author in TODO
91
+ "TD003", # Missing issue link for this TODO
92
+ "TRY002", # Create your own exception
93
+ "TRY300", # Consider moving this statement to an `else` block
94
+ "UP032", # Use f-string instead of `format` call
95
+ ]
96
+
97
+ [tool.ruff.lint.mccabe]
98
+ max-complexity = 15
99
+
100
+ [tool.ruff.lint.pylint]
101
+ max-args = 10
102
+ max-branches = 20
103
+ max-returns = 20
104
+
105
+ [tool.uv.build-backend]
106
+ module-name = "dbrownell_Email"
@@ -0,0 +1,185 @@
1
+ """Run a process and tee its output to an email message and the console."""
2
+
3
+ from datetime import datetime
4
+ from io import StringIO
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from ansi2html.converter import Ansi2HTMLConverter
11
+ from typer.core import TyperGroup
12
+
13
+ from dbrownell_Common.Streams.Capabilities import Capabilities
14
+ from dbrownell_Common.Streams.DoneManager import DoneManager, Flags as DoneManagerFlags
15
+ from dbrownell_Common.Streams.StreamDecorator import StreamDecorator
16
+ from dbrownell_Common import SubprocessEx
17
+ from dbrownell_Email.SmtpMailer import SmtpMailer
18
+
19
+
20
+ # ----------------------------------------------------------------------
21
+ class NaturalOrderGrouper(TyperGroup): # noqa: D101
22
+ # ----------------------------------------------------------------------
23
+ def list_commands(self, *args, **kwargs) -> list[str]: # noqa: ARG002, D102
24
+ return list(self.commands.keys())
25
+
26
+
27
+ # ----------------------------------------------------------------------
28
+ app = typer.Typer(
29
+ cls=NaturalOrderGrouper,
30
+ help=__doc__,
31
+ no_args_is_help=True,
32
+ pretty_exceptions_show_locals=False,
33
+ pretty_exceptions_enable=False,
34
+ )
35
+
36
+
37
+ # ----------------------------------------------------------------------
38
+ @app.command(
39
+ "EntryPoint",
40
+ help=__doc__,
41
+ no_args_is_help=True,
42
+ )
43
+ def EntryPoint( # noqa: D103
44
+ command_line: Annotated[
45
+ str,
46
+ typer.Argument(
47
+ ...,
48
+ help="Command line to invoke; all output will be included in the email message. If the argument begins with '@', the rest of the command line will be interpreted as a filename and the command line will be read from that file.",
49
+ ),
50
+ ],
51
+ smtp_profile_name: Annotated[
52
+ str,
53
+ typer.Argument(..., help="SMTP profile name."),
54
+ ],
55
+ email_recipients: Annotated[
56
+ list[str],
57
+ typer.Argument(..., help="Recipient(s) for the email message."),
58
+ ],
59
+ email_subject: Annotated[
60
+ str,
61
+ typer.Argument(
62
+ ...,
63
+ help="Subject of the email message; '{now}' can be used in the string as a template placeholder for the current time.",
64
+ ),
65
+ ],
66
+ output_filename: Annotated[
67
+ Path | None,
68
+ typer.Option(
69
+ "--output-filename",
70
+ dir_okay=False,
71
+ resolve_path=True,
72
+ help="Writes formatted html output to a file; this is useful when --force-color has also been specified as an argument.",
73
+ ),
74
+ ] = None,
75
+ background_color: Annotated[
76
+ str,
77
+ typer.Option("--background-color", help="Email background color."),
78
+ ] = "black",
79
+ verbose: Annotated[ # noqa: FBT002
80
+ bool,
81
+ typer.Option("--verbose", help="Write verbose information to the terminal."),
82
+ ] = False,
83
+ debug: Annotated[ # noqa: FBT002
84
+ bool,
85
+ typer.Option("--debug", help="Write debug information to the terminal."),
86
+ ] = False,
87
+ ) -> None:
88
+ with DoneManager.CreateCommandLine(
89
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
90
+ ) as dm:
91
+ try:
92
+ smtp_mailer = SmtpMailer.Load(smtp_profile_name)
93
+ except Exception as ex:
94
+ dm.WriteError(str(ex))
95
+ return
96
+
97
+ with dm.Nested(
98
+ "Running command...",
99
+ suffix="\n",
100
+ ) as running_dm:
101
+ # Create the stream used to capture the message content
102
+ message_sink = StringIO()
103
+
104
+ Capabilities.Set(
105
+ message_sink,
106
+ Capabilities(
107
+ is_interactive=False,
108
+ supports_colors=True,
109
+ is_headless=True,
110
+ ),
111
+ no_column_warning=True,
112
+ )
113
+
114
+ with running_dm.YieldStream() as dm_stream:
115
+ running_dm.result = SubprocessEx.Stream(
116
+ command_line,
117
+ StreamDecorator([message_sink, dm_stream]),
118
+ )
119
+
120
+ message = message_sink.getvalue()
121
+
122
+ with dm.Nested(
123
+ "Processing output...",
124
+ suffix="\n",
125
+ ) as processing_dm:
126
+ title = None
127
+
128
+ if output_filename:
129
+ title = output_filename.stem
130
+
131
+ # Value to convert spaces into before the text is converted to html.
132
+ space_placeholder = "__nbsp;__"
133
+
134
+ with processing_dm.Nested("Converting output to HTML..."):
135
+ message = message.replace(" ", space_placeholder)
136
+
137
+ message = Ansi2HTMLConverter(
138
+ dark_bg=True,
139
+ inline=True,
140
+ line_wrap=False,
141
+ title=title or "",
142
+ ).convert(message)
143
+
144
+ for source, dest in [
145
+ (space_placeholder, "&nbsp;"),
146
+ # Create a div to set the background color
147
+ (
148
+ '<pre class="ansi2html-content">\n',
149
+ '<pre class="ansi2html-content">\n<div style="background-color: {}">\n'.format(
150
+ background_color
151
+ ),
152
+ ),
153
+ # Undo the div that set the background color
154
+ (
155
+ "</pre>\n",
156
+ "</div>\n</pre>\n",
157
+ ),
158
+ ]:
159
+ message = message.replace(source, dest)
160
+
161
+ if output_filename is not None:
162
+ with processing_dm.Nested("Writing to '{}'...".format(output_filename)):
163
+ output_filename.parent.mkdir(parents=True, exist_ok=True)
164
+
165
+ with output_filename.open("w", encoding="utf-8") as f:
166
+ f.write(message)
167
+
168
+ with dm.Nested("Sending email...") as email_dm:
169
+ try:
170
+ smtp_mailer.SendMessage(
171
+ email_recipients,
172
+ email_subject.format(now=datetime.now()), # noqa: DTZ005
173
+ message,
174
+ message_format="html",
175
+ )
176
+ except Exception as ex:
177
+ email_dm.WriteError(str(ex))
178
+ return
179
+
180
+
181
+ # ----------------------------------------------------------------------
182
+ # ----------------------------------------------------------------------
183
+ # ----------------------------------------------------------------------
184
+ if __name__ == "__main__":
185
+ app()
@@ -0,0 +1,201 @@
1
+ """Contains the SmtpMailer object."""
2
+
3
+ import datetime
4
+ import json
5
+ import mimetypes
6
+ import os
7
+ import smtplib
8
+ import ssl
9
+ import textwrap
10
+
11
+ from dataclasses import dataclass, field
12
+ from email import encoders
13
+ from email.mime.audio import MIMEAudio
14
+ from email.mime.base import MIMEBase
15
+ from email.mime.image import MIMEImage
16
+ from email.mime.multipart import MIMEMultipart
17
+ from email.mime.text import MIMEText
18
+
19
+ from collections.abc import Generator
20
+ from pathlib import Path
21
+
22
+ from dbrownell_Common.ContextlibEx import ExitStack
23
+
24
+
25
+ # ----------------------------------------------------------------------
26
+ @dataclass(frozen=True)
27
+ class SmtpMailer:
28
+ """Code that manages SMTP profiles and uses them to send messages."""
29
+
30
+ # ----------------------------------------------------------------------
31
+ # | Public Types
32
+ PROFILE_EXTENSION = ".SmtpMailer"
33
+
34
+ # ----------------------------------------------------------------------
35
+ # | Public Data
36
+ host: str
37
+ username: str
38
+ password: str
39
+ from_name: str
40
+ from_email: str
41
+
42
+ ssl: bool = field(kw_only=True)
43
+
44
+ port: int | None = field(default=None)
45
+
46
+ # ----------------------------------------------------------------------
47
+ # | Public Methods
48
+ def ToString(
49
+ self,
50
+ *,
51
+ show_password: bool = False,
52
+ ) -> str:
53
+ """Return a string representation of the profile."""
54
+
55
+ return textwrap.dedent(
56
+ """\
57
+ host : {host}
58
+ username : {username}
59
+ password : {password}
60
+ from_name : {from_name}
61
+ from_email : {from_email}
62
+ ssl : {ssl}
63
+ port : {port}
64
+ """,
65
+ ).format(
66
+ host=self.host,
67
+ username=self.username,
68
+ password=self.password if show_password else "****",
69
+ from_name=self.from_name,
70
+ from_email=self.from_email,
71
+ ssl=self.ssl,
72
+ port=self.port,
73
+ )
74
+
75
+ # ----------------------------------------------------------------------
76
+ def Save(
77
+ self,
78
+ profile_name: str,
79
+ ) -> None:
80
+ """Save a profile."""
81
+
82
+ content = json.dumps(self.__dict__).encode("utf-8")
83
+
84
+ if os.name == "nt":
85
+ import win32crypt # noqa: PLC0415
86
+
87
+ content = win32crypt.CryptProtectData(content, "", None, None, None, 0)
88
+
89
+ with (Path("~").expanduser() / (profile_name + self.__class__.PROFILE_EXTENSION)).open("wb") as f:
90
+ f.write(content)
91
+
92
+ # ----------------------------------------------------------------------
93
+ def SendMessage(
94
+ self,
95
+ recipients: list[str],
96
+ subject: str,
97
+ message: str,
98
+ attachment_filenames: list[Path] | None = None,
99
+ message_format: str = "plain", # "html"
100
+ ) -> None:
101
+ """Send an email message using the current profile."""
102
+
103
+ if self.ssl:
104
+ port = self.port or 465
105
+ smtp = smtplib.SMTP_SSL(self.host, port, context=ssl.create_default_context())
106
+ else:
107
+ port = self.port or 26
108
+ smtp = smtplib.SMTP(self.host, port)
109
+
110
+ smtp.connect(self.host, port)
111
+ with ExitStack(smtp.close):
112
+ if not self.ssl:
113
+ smtp.starttls()
114
+
115
+ smtp.login(self.username, self.password)
116
+
117
+ from_addr = "{} <{}>".format(self.from_name, self.from_email)
118
+
119
+ msg = MIMEMultipart() if attachment_filenames else MIMEMultipart("alternative")
120
+
121
+ msg["Subject"] = subject
122
+ msg["From"] = from_addr
123
+ msg["To"] = ", ".join(recipients)
124
+
125
+ msg.attach(MIMEText(message, message_format))
126
+
127
+ for attachment_filename in attachment_filenames or []:
128
+ ctype, encoding = mimetypes.guess_type(attachment_filename)
129
+
130
+ if ctype is None or encoding is not None:
131
+ ctype = "application/octet-stream"
132
+
133
+ maintype, subtype = ctype.split("/", 1)
134
+
135
+ with attachment_filename.open("rb") as f:
136
+ content = f.read()
137
+
138
+ if maintype == "text":
139
+ attachment = MIMEText(content.decode("utf-8"), _subtype=subtype)
140
+ elif maintype == "image":
141
+ attachment = MIMEImage(content, _subtype=subtype)
142
+ elif maintype == "audio":
143
+ attachment = MIMEAudio(content, _subtype=subtype)
144
+ else:
145
+ attachment = MIMEBase(maintype, subtype)
146
+
147
+ attachment.set_payload(content)
148
+ encoders.encode_base64(attachment)
149
+
150
+ attachment.add_header("Content-Disposition", "attachment", filename=attachment_filename.name)
151
+
152
+ msg.attach(attachment)
153
+
154
+ smtp.sendmail(from_addr, recipients, msg.as_string())
155
+
156
+ # ----------------------------------------------------------------------
157
+ def SendTestMessage(
158
+ self,
159
+ recipients: list[str],
160
+ attachment_filenames: list[Path] | None = None,
161
+ ) -> None:
162
+ """Send a test message using the current profile."""
163
+
164
+ self.SendMessage(
165
+ recipients,
166
+ f"SmtpMailer Verification ({datetime.datetime.now()})", # noqa: DTZ005
167
+ "This is a test message to ensure that the profile is working as expected.\n",
168
+ attachment_filenames,
169
+ )
170
+
171
+ # ----------------------------------------------------------------------
172
+ @classmethod
173
+ def Load(
174
+ cls,
175
+ profile_name: str,
176
+ ) -> "SmtpMailer":
177
+ """Load a previously saved profile file."""
178
+
179
+ data_filename = Path("~").expanduser() / (profile_name + cls.PROFILE_EXTENSION)
180
+
181
+ if not data_filename.is_file():
182
+ message = f"'{profile_name}' is not a recognized profile name."
183
+ raise Exception(message)
184
+
185
+ content = data_filename.read_bytes()
186
+
187
+ if os.name == "nt":
188
+ import win32crypt # noqa: PLC0415
189
+
190
+ content = win32crypt.CryptUnprotectData(content, None, None, None, 0)[1]
191
+
192
+ return cls(**json.loads(content.decode("utf-8")))
193
+
194
+ # ----------------------------------------------------------------------
195
+ @classmethod
196
+ def EnumProfiles(cls) -> Generator[str, None, None]:
197
+ """Enumerate all saved profiles."""
198
+
199
+ for item in Path("~").expanduser().iterdir():
200
+ if item.suffix == cls.PROFILE_EXTENSION:
201
+ yield item.stem
@@ -0,0 +1,10 @@
1
+ # noqa: D104
2
+ from importlib.metadata import version
3
+
4
+ from .SmtpMailer import SmtpMailer
5
+
6
+ __all__ = [
7
+ "SmtpMailer",
8
+ ]
9
+
10
+ __version__ = version("dbrownell_Email")
File without changes