benethos-mailbox-common 0.4.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 (33) hide show
  1. benethos_mailbox_common-0.4.0/.gitignore +62 -0
  2. benethos_mailbox_common-0.4.0/LICENSE +21 -0
  3. benethos_mailbox_common-0.4.0/PKG-INFO +75 -0
  4. benethos_mailbox_common-0.4.0/README.md +37 -0
  5. benethos_mailbox_common-0.4.0/pyproject.toml +63 -0
  6. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/__init__.py +17 -0
  7. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/log/__init__.py +9 -0
  8. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/log/lines.py +131 -0
  9. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/log/redact.py +48 -0
  10. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/mail/__init__.py +8 -0
  11. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/mail/addresses.py +18 -0
  12. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/mail/plaintext.py +187 -0
  13. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/paths/__init__.py +9 -0
  14. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/paths/folders.py +53 -0
  15. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/py.typed +0 -0
  16. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/settings/__init__.py +9 -0
  17. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/settings/files.py +41 -0
  18. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/values/__init__.py +9 -0
  19. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/values/canonical.py +24 -0
  20. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/values/secret.py +50 -0
  21. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/values/sizes.py +10 -0
  22. benethos_mailbox_common-0.4.0/src/benethos_mailbox_common/values/text.py +49 -0
  23. benethos_mailbox_common-0.4.0/tests/log/test_lines.py +134 -0
  24. benethos_mailbox_common-0.4.0/tests/log/test_redact.py +33 -0
  25. benethos_mailbox_common-0.4.0/tests/mail/test_addresses.py +36 -0
  26. benethos_mailbox_common-0.4.0/tests/mail/test_plaintext.py +114 -0
  27. benethos_mailbox_common-0.4.0/tests/paths/test_folders.py +69 -0
  28. benethos_mailbox_common-0.4.0/tests/settings/test_files.py +67 -0
  29. benethos_mailbox_common-0.4.0/tests/test_architecture.py +152 -0
  30. benethos_mailbox_common-0.4.0/tests/values/test_canonical.py +20 -0
  31. benethos_mailbox_common-0.4.0/tests/values/test_secret.py +42 -0
  32. benethos_mailbox_common-0.4.0/tests/values/test_sizes.py +11 -0
  33. benethos_mailbox_common-0.4.0/tests/values/test_text.py +51 -0
@@ -0,0 +1,62 @@
1
+ # Virtual environment
2
+ .venv/
3
+ venv/
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ .eggs/
10
+ build/
11
+ dist/
12
+
13
+ # Caching / coverage
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .cache/
18
+ *.sqlite
19
+ .coverage
20
+ .coverage.*
21
+ htmlcov/
22
+ coverage.xml
23
+
24
+ # IDE / OS
25
+ .vscode/
26
+ .idea/
27
+ .DS_Store
28
+ Thumbs.db
29
+
30
+ # Local configuration and secrets
31
+ .env
32
+ # config/: per package, only the templates are versioned
33
+ config/**
34
+ !config/**/
35
+ !config/*/.env.example
36
+ *.token.json
37
+ # containers/*/secrets/: the master key of a deployment, the passwords
38
+ # and the CA of the test mail server
39
+ containers/**/secrets/
40
+ # containers/production/service.env: settings, among them perhaps a secret
41
+ containers/**/service.env
42
+ # containers/production/: the MCP server instances and their routes in Caddy
43
+ containers/production/compose.override.yaml
44
+ containers/production/caddy.d/*.caddy
45
+
46
+ # Service data: the database holds encrypted credentials, backups the same.
47
+ # data/: per package, only the folders are versioned (.gitkeep)
48
+ data/**
49
+ !data/**/
50
+ *.db
51
+ *.db-journal
52
+ *.db-wal
53
+ *.db-shm
54
+ **/backups/**
55
+ !**/backups/**/
56
+
57
+ # Live checks: test server address and test accounts stay local
58
+ live/.env
59
+
60
+ # .gitkeep holds an empty folder and is kept wherever it is. Only works
61
+ # below folders ignored file by file (as above), not below an ignored folder.
62
+ !.gitkeep
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benedikt Oswald
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,75 @@
1
+ Metadata-Version: 2.5
2
+ Name: benethos-mailbox-common
3
+ Version: 0.4.0
4
+ Summary: What mailbox-service and mailbox-mcp of Mailbox share: mail as plain text, the folders of the operating system, settings from a file, the time of a log line, secrets masked in text, JSON for a hash.
5
+ Project-URL: Homepage, https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-common#readme
6
+ Project-URL: Repository, https://github.com/benethos-hub/mailbox
7
+ Project-URL: Issues, https://github.com/benethos-hub/mailbox/issues
8
+ Project-URL: Changelog, https://github.com/benethos-hub/mailbox/blob/main/CHANGELOG.md
9
+ Author: Benedikt Oswald
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: email,html-to-text,logging,redaction,self-hosted
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Information Technology
16
+ Classifier: Intended Audience :: System Administrators
17
+ Classifier: Natural Language :: English
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Programming Language :: Python :: 3.15
26
+ Classifier: Topic :: Communications :: Email
27
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.11
30
+ Provides-Extra: all
31
+ Requires-Dist: platformdirs>=4.3; extra == 'all'
32
+ Requires-Dist: pydantic-settings>=2.10; extra == 'all'
33
+ Provides-Extra: paths
34
+ Requires-Dist: platformdirs>=4.3; extra == 'paths'
35
+ Provides-Extra: settings
36
+ Requires-Dist: pydantic-settings>=2.10; extra == 'settings'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # <img src="https://raw.githubusercontent.com/benethos-hub/mailbox/main/assets/logo/icon-3d.svg" alt="" height="36" align="absmiddle"> mailbox-common
40
+
41
+ [![CI](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml/badge.svg)](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml)
42
+ [![PyPI benethos-mailbox-common](https://img.shields.io/pypi/v/benethos-mailbox-common?label=PyPI%20benethos-mailbox-common)](https://pypi.org/project/benethos-mailbox-common/)
43
+ [![Python](https://img.shields.io/pypi/pyversions/benethos-mailbox-common)](https://pypi.org/project/benethos-mailbox-common/)
44
+ [![License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/benethos-hub/mailbox/blob/main/LICENSE)
45
+
46
+ > **Beta, version 0.4.0.** Usable with real accounts. A breaking change
47
+ > of the API or the configuration is announced in the changelog. Stored
48
+ > data is carried forward by migrations.
49
+
50
+ What
51
+ [`mailbox-service`](https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-service)
52
+ and
53
+ [`mailbox-mcp`](https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-mcp)
54
+ of [Mailbox](https://github.com/benethos-hub/mailbox) both need, held
55
+ once, on PyPI as `benethos-mailbox-common`. The MCP server cannot see
56
+ the service, so what both use lives here.
57
+
58
+ It is installed with them and is not meant to be used on its own. It
59
+ is built for the packages of this repository, and its modules, groups
60
+ and extras may change between versions without notice. It carries the
61
+ version of the other packages, and the service and the MCP server pin
62
+ it to their own.
63
+
64
+ Its modules are in five groups, one per concern: `mail` (addresses
65
+ and the plain text of an HTML body), `log` (the log line and the
66
+ masking of secrets), `paths` (the folders of the operating system),
67
+ `settings` (a program's settings from the environment and a file)
68
+ and `values` (secrets, sizes, text and canonical JSON).
69
+
70
+ It needs the standard library alone. The extra `paths` brings
71
+ platformdirs for the folders of the operating system, `settings`
72
+ pydantic-settings for a program's settings, and `all` brings every
73
+ extra.
74
+
75
+ It sees neither the service, nor the MCP server, nor the client.
@@ -0,0 +1,37 @@
1
+ # <img src="https://raw.githubusercontent.com/benethos-hub/mailbox/main/assets/logo/icon-3d.svg" alt="" height="36" align="absmiddle"> mailbox-common
2
+
3
+ [![CI](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml/badge.svg)](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml)
4
+ [![PyPI benethos-mailbox-common](https://img.shields.io/pypi/v/benethos-mailbox-common?label=PyPI%20benethos-mailbox-common)](https://pypi.org/project/benethos-mailbox-common/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/benethos-mailbox-common)](https://pypi.org/project/benethos-mailbox-common/)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/benethos-hub/mailbox/blob/main/LICENSE)
7
+
8
+ > **Beta, version 0.4.0.** Usable with real accounts. A breaking change
9
+ > of the API or the configuration is announced in the changelog. Stored
10
+ > data is carried forward by migrations.
11
+
12
+ What
13
+ [`mailbox-service`](https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-service)
14
+ and
15
+ [`mailbox-mcp`](https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-mcp)
16
+ of [Mailbox](https://github.com/benethos-hub/mailbox) both need, held
17
+ once, on PyPI as `benethos-mailbox-common`. The MCP server cannot see
18
+ the service, so what both use lives here.
19
+
20
+ It is installed with them and is not meant to be used on its own. It
21
+ is built for the packages of this repository, and its modules, groups
22
+ and extras may change between versions without notice. It carries the
23
+ version of the other packages, and the service and the MCP server pin
24
+ it to their own.
25
+
26
+ Its modules are in five groups, one per concern: `mail` (addresses
27
+ and the plain text of an HTML body), `log` (the log line and the
28
+ masking of secrets), `paths` (the folders of the operating system),
29
+ `settings` (a program's settings from the environment and a file)
30
+ and `values` (secrets, sizes, text and canonical JSON).
31
+
32
+ It needs the standard library alone. The extra `paths` brings
33
+ platformdirs for the folders of the operating system, `settings`
34
+ pydantic-settings for a program's settings, and `all` brings every
35
+ extra.
36
+
37
+ It sees neither the service, nor the MCP server, nor the client.
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "benethos-mailbox-common"
7
+ version = "0.4.0"
8
+ description = "What mailbox-service and mailbox-mcp of Mailbox share: mail as plain text, the folders of the operating system, settings from a file, the time of a log line, secrets masked in text, JSON for a hash."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Benedikt Oswald" }]
14
+ keywords = [
15
+ "email",
16
+ "html-to-text",
17
+ "logging",
18
+ "redaction",
19
+ "self-hosted",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Developers",
24
+ "Intended Audience :: Information Technology",
25
+ "Intended Audience :: System Administrators",
26
+ "Natural Language :: English",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3 :: Only",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Programming Language :: Python :: 3.14",
34
+ "Programming Language :: Python :: 3.15",
35
+ "Topic :: Communications :: Email",
36
+ "Topic :: Software Development :: Libraries :: Python Modules",
37
+ "Typing :: Typed",
38
+ ]
39
+ # It sees neither the service, nor the MCP server, nor the client.
40
+ # The standard library only. A group with a library of its own names it
41
+ # in an extra named after the group.
42
+ dependencies = []
43
+
44
+ [project.optional-dependencies]
45
+ # The minimum is a version without a known vulnerability: the job
46
+ # lowest-versions of ci.yml checks it against OSV.
47
+ # The folders of the operating system for settings and data
48
+ # (paths/folders.py).
49
+ paths = ["platformdirs>=4.3"]
50
+ # The settings of a program from the environment and a settings file
51
+ # (settings/files.py).
52
+ settings = ["pydantic-settings>=2.10"]
53
+ # Every group.
54
+ all = ["benethos-mailbox-common[paths,settings]"]
55
+
56
+ [project.urls]
57
+ Homepage = "https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-common#readme"
58
+ Repository = "https://github.com/benethos-hub/mailbox"
59
+ Issues = "https://github.com/benethos-hub/mailbox/issues"
60
+ Changelog = "https://github.com/benethos-hub/mailbox/blob/main/CHANGELOG.md"
61
+
62
+ [tool.hatch.build.targets.wheel]
63
+ packages = ["src/benethos_mailbox_common"]
@@ -0,0 +1,17 @@
1
+ """mailbox-common: what the service and the MCP server of Mailbox both
2
+ need, held once, in groups, one per concern: ``mail``, ``log``, ``paths``
3
+ and ``values``. A module imports only inside its group, so a group can
4
+ be cut out as a package of its own. Callers import a module from its
5
+ group, ``from benethos_mailbox_common.log import lines``, never deeper.
6
+ This file imports no group."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ __version__ = version("benethos-mailbox-common")
14
+ except PackageNotFoundError: # pragma: no cover - running from a bare tree
15
+ __version__ = "0.0.0"
16
+
17
+ __all__ = ["__version__"]
@@ -0,0 +1,9 @@
1
+ """The lines of a log and the secrets kept out of them. Callers import
2
+ the modules from here, ``from benethos_mailbox_common.log import lines,
3
+ redact``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from . import lines, redact
8
+
9
+ __all__ = ["lines", "redact"]
@@ -0,0 +1,131 @@
1
+ """The lines of a log, alike in every program of Mailbox (docs/LOGGING.md
2
+ 6.9): the time, the level, where a line comes from and the message, a
3
+ noted secret masked (``redact``).
4
+
5
+ On a terminal a line is short and in colour, elsewhere, in a container
6
+ log or the journal, plain text. What a program logs, and its own kinds
7
+ of lines such as the access line of a web server, stay its own: it
8
+ hands ``Console`` a ``line_of`` for them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import sys
16
+ from collections.abc import Callable
17
+ from datetime import datetime
18
+
19
+ from . import redact
20
+
21
+ FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
22
+ # As uvicorn names them. Its trace level is below debug.
23
+ LEVELS = {
24
+ "critical": logging.CRITICAL,
25
+ "error": logging.ERROR,
26
+ "warning": logging.WARNING,
27
+ "info": logging.INFO,
28
+ "debug": logging.DEBUG,
29
+ "trace": 5,
30
+ }
31
+
32
+ # ANSI colours, for a terminal only.
33
+ RESET = "\033[0m"
34
+ DIM = "\033[2m"
35
+ _LEVEL_COLOURS = {
36
+ logging.DEBUG: "\033[34m",
37
+ logging.INFO: "\033[32m",
38
+ logging.WARNING: "\033[33m",
39
+ logging.ERROR: "\033[31m",
40
+ logging.CRITICAL: "\033[1;31m",
41
+ }
42
+ _SOURCE = "\033[36m"
43
+ # The column of the source: the longest name of an activity of the
44
+ # service, ``activity.<area>.<name>``, has 32 characters (docs/LOGGING.md
45
+ # 7.2).
46
+ SOURCE_WIDTH = 32
47
+
48
+ # A program's own text for a record, None for the message as it is.
49
+ LineOf = Callable[[logging.LogRecord], str | None]
50
+
51
+
52
+ def log_time(value: datetime) -> str:
53
+ """A time as every line of the log writes it, wherever it stands:
54
+ ISO 8601, the local time of this machine, to the millisecond, with the
55
+ offset. ``2026-09-28T10:12:22.123+02:00``."""
56
+ return value.astimezone().isoformat(timespec="milliseconds")
57
+
58
+
59
+ def colours_wanted() -> bool:
60
+ """Colours on a terminal, unless ``NO_COLOR`` is set (no-color.org)."""
61
+ return sys.stderr.isatty() and "NO_COLOR" not in os.environ
62
+
63
+
64
+ def short_source(name: str, package: str) -> str:
65
+ """``domain.auth`` for the loggers of ``package``, ``http`` for the
66
+ access log of uvicorn, ``uvicorn`` for the server."""
67
+ if name == "uvicorn.access":
68
+ return "http"
69
+ if name.startswith("uvicorn"):
70
+ return "uvicorn"
71
+ return name.removeprefix(f"{package}.")
72
+
73
+
74
+ class Redacting(logging.Formatter):
75
+ """Each line as written, a noted secret masked: in the message, its
76
+ arguments and a traceback alike. The time as every line writes it
77
+ (``log_time``)."""
78
+
79
+ def __init__(self, fmt: str = FORMAT) -> None:
80
+ super().__init__(fmt)
81
+
82
+ def format(self, record: logging.LogRecord) -> str:
83
+ return redact.redact(super().format(record))
84
+
85
+ def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
86
+ return log_time(datetime.fromtimestamp(record.created).astimezone())
87
+
88
+
89
+ class Console(Redacting):
90
+ """A line for a person at a terminal: the time dim, the level in
91
+ colour, the source short (``short_source`` of ``package``), and the
92
+ text ``line_of`` gives, else the message. The time as in every other
93
+ line."""
94
+
95
+ def __init__(self, package: str = "", line_of: LineOf | None = None) -> None:
96
+ super().__init__()
97
+ self._package = package
98
+ self._line_of = line_of
99
+
100
+ def format(self, record: logging.LogRecord) -> str:
101
+ colour = _LEVEL_COLOURS.get(record.levelno, "")
102
+ source = short_source(record.name, self._package)
103
+ head = (
104
+ f"{DIM}{self.formatTime(record)}{RESET} "
105
+ f"{colour}{record.levelname:<8}{RESET} "
106
+ f"{_SOURCE}{source:<{SOURCE_WIDTH}}{RESET} "
107
+ )
108
+ own = self._line_of(record) if self._line_of is not None else None
109
+ text = own or record.getMessage()
110
+ if record.exc_info:
111
+ text += "\n" + self.formatException(record.exc_info)
112
+ return redact.redact(head + text)
113
+
114
+
115
+ def formatter(
116
+ package: str, *, colours: bool | None = None, line_of: LineOf | None = None
117
+ ) -> logging.Formatter:
118
+ """``Console`` where ``colours`` is true, by default on a terminal,
119
+ else the plain ``Redacting``."""
120
+ if colours is None:
121
+ colours = colours_wanted()
122
+ return Console(package, line_of) if colours else Redacting()
123
+
124
+
125
+ def stderr_handler(
126
+ package: str, *, colours: bool | None = None, line_of: LineOf | None = None
127
+ ) -> logging.Handler:
128
+ """A handler that writes to stderr with the ``formatter`` that fits."""
129
+ handler = logging.StreamHandler(sys.stderr)
130
+ handler.setFormatter(formatter(package, colours=colours, line_of=line_of))
131
+ return handler
@@ -0,0 +1,48 @@
1
+ """The secrets a program holds in plain text, kept out of what it writes.
2
+
3
+ The second line of defence (CONCEPT 7.4): no code logs a secret or puts
4
+ one into an error text on purpose. Whatever still carries one, a
5
+ library's message or a traceback, is written with ``***`` in its place.
6
+ A secret is noted where it is decrypted or received. The newest
7
+ ``KEPT`` are held: access tokens come anew every hour, and one in use
8
+ is noted again on its next decryption.
9
+
10
+ The one module of the package with state of its own: a secret noted
11
+ where it is decrypted must be masked wherever a text is written, in every
12
+ layer and in the log, so the noted secrets are held process-wide.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ from collections import OrderedDict
19
+
20
+ MASK = "***"
21
+ # Shorter values are not masked: a password "a" would hide every "a".
22
+ SHORTEST = 6
23
+ KEPT = 256
24
+
25
+ _known: OrderedDict[str, None] = OrderedDict()
26
+ _lock = threading.Lock()
27
+
28
+
29
+ def note(secret: str) -> None:
30
+ """Mask ``secret`` from now on, in every text ``redact`` sees."""
31
+ if len(secret) < SHORTEST:
32
+ return
33
+ with _lock:
34
+ _known[secret] = None
35
+ _known.move_to_end(secret)
36
+ while len(_known) > KEPT:
37
+ _known.popitem(last=False)
38
+
39
+
40
+ def redact(text: str) -> str:
41
+ """``text`` with every noted secret masked, the longest first, so one
42
+ secret inside another leaves nothing of the longer behind."""
43
+ with _lock:
44
+ known = sorted(_known, key=len, reverse=True)
45
+ for secret in known:
46
+ if secret in text:
47
+ text = text.replace(secret, MASK)
48
+ return text
@@ -0,0 +1,8 @@
1
+ """Mail itself, nothing of Mailbox. Callers import the modules from here,
2
+ ``from benethos_mailbox_common.mail import plaintext``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from . import addresses, plaintext
7
+
8
+ __all__ = ["addresses", "plaintext"]
@@ -0,0 +1,18 @@
1
+ """An address as people read it: ``Name <email>``, or the email alone.
2
+ Not the header of a message, which ``email.utils.formataddr`` writes,
3
+ encoding a name beyond ASCII."""
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ def readable(name: str | None, email: str, *, quoted: bool = False) -> str:
9
+ """``Name <email>``, the email alone without a name. ``quoted`` puts
10
+ the name in a quoted string (RFC 5322), with a backslash before a
11
+ backslash or a double quote in it, so a form reads it back as it was:
12
+ ``"Smith, Ann" <ann@example.org>``."""
13
+ if not name:
14
+ return email
15
+ if quoted:
16
+ escaped = name.replace("\\", "\\\\").replace('"', '\\"')
17
+ return f'"{escaped}" <{email}>'
18
+ return f"{name} <{email}>"