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.
- dbrownell_email-0.2.5/PKG-INFO +89 -0
- dbrownell_email-0.2.5/README.md +64 -0
- dbrownell_email-0.2.5/pyproject.toml +106 -0
- dbrownell_email-0.2.5/src/dbrownell_Email/EmailTee.py +185 -0
- dbrownell_email-0.2.5/src/dbrownell_Email/SmtpMailer.py +201 -0
- dbrownell_email-0.2.5/src/dbrownell_Email/__init__.py +10 -0
- dbrownell_email-0.2.5/src/dbrownell_Email/py.typed +0 -0
|
@@ -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
|
+
[](https://github.com/davidbrownell/dbrownell_Email/blob/master/LICENSE)
|
|
28
|
+
|
|
29
|
+
**Package:**
|
|
30
|
+
[](https://pypi.org/project/dbrownell_Email/)
|
|
31
|
+
[](https://pypi.org/project/dbrownell_Email/)
|
|
32
|
+
[](https://pypistats.org/packages/dbrownell-email)
|
|
33
|
+
|
|
34
|
+
**Development:**
|
|
35
|
+
[](https://github.com/astral-sh/uv)
|
|
36
|
+
[](https://github.com/davidbrownell/dbrownell_Email/actions/workflows/CICD.yml)
|
|
37
|
+
[](https://github.com/davidbrownell/dbrownell_Email/actions)
|
|
38
|
+
[](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
|
+
[](https://github.com/davidbrownell/dbrownell_Email/blob/master/LICENSE)
|
|
3
|
+
|
|
4
|
+
**Package:**
|
|
5
|
+
[](https://pypi.org/project/dbrownell_Email/)
|
|
6
|
+
[](https://pypi.org/project/dbrownell_Email/)
|
|
7
|
+
[](https://pypistats.org/packages/dbrownell-email)
|
|
8
|
+
|
|
9
|
+
**Development:**
|
|
10
|
+
[](https://github.com/astral-sh/uv)
|
|
11
|
+
[](https://github.com/davidbrownell/dbrownell_Email/actions/workflows/CICD.yml)
|
|
12
|
+
[](https://github.com/davidbrownell/dbrownell_Email/actions)
|
|
13
|
+
[](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, " "),
|
|
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
|
|
File without changes
|