simple-emailer 0.2.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 (31) hide show
  1. simple_emailer-0.2.0/MANIFEST.in +4 -0
  2. simple_emailer-0.2.0/PKG-INFO +153 -0
  3. simple_emailer-0.2.0/README.md +146 -0
  4. simple_emailer-0.2.0/SECURITY.md +38 -0
  5. simple_emailer-0.2.0/docs/ROADMAP.md +40 -0
  6. simple_emailer-0.2.0/emailer/__init__.py +3 -0
  7. simple_emailer-0.2.0/emailer/__main__.py +287 -0
  8. simple_emailer-0.2.0/emailer/mailer.py +235 -0
  9. simple_emailer-0.2.0/emailer/models.py +283 -0
  10. simple_emailer-0.2.0/emailer/providers.py +73 -0
  11. simple_emailer-0.2.0/emailer/server.py +463 -0
  12. simple_emailer-0.2.0/emailer/static/__init__.py +1 -0
  13. simple_emailer-0.2.0/emailer/static/app.js +475 -0
  14. simple_emailer-0.2.0/emailer/static/icon.svg +6 -0
  15. simple_emailer-0.2.0/emailer/static/index.html +186 -0
  16. simple_emailer-0.2.0/emailer/static/manifest.webmanifest +19 -0
  17. simple_emailer-0.2.0/emailer/static/styles.css +827 -0
  18. simple_emailer-0.2.0/emailer/static/sw.js +50 -0
  19. simple_emailer-0.2.0/msg.py +7 -0
  20. simple_emailer-0.2.0/pyproject.toml +17 -0
  21. simple_emailer-0.2.0/setup.cfg +4 -0
  22. simple_emailer-0.2.0/simple_emailer.egg-info/PKG-INFO +153 -0
  23. simple_emailer-0.2.0/simple_emailer.egg-info/SOURCES.txt +29 -0
  24. simple_emailer-0.2.0/simple_emailer.egg-info/dependency_links.txt +1 -0
  25. simple_emailer-0.2.0/simple_emailer.egg-info/entry_points.txt +2 -0
  26. simple_emailer-0.2.0/simple_emailer.egg-info/top_level.txt +1 -0
  27. simple_emailer-0.2.0/tests/__init__.py +0 -0
  28. simple_emailer-0.2.0/tests/test_cli.py +154 -0
  29. simple_emailer-0.2.0/tests/test_mailer.py +152 -0
  30. simple_emailer-0.2.0/tests/test_models.py +135 -0
  31. simple_emailer-0.2.0/tests/test_server.py +163 -0
@@ -0,0 +1,4 @@
1
+ include SECURITY.md
2
+ include msg.py
3
+ recursive-include docs *.md
4
+ recursive-include tests *.py
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple-emailer
3
+ Version: 0.2.0
4
+ Summary: A secure local CLI and web interface for sending email through SMTP providers
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Simple Emailer
9
+
10
+ Simple Emailer is a local CLI and optional web interface for sending plain-text email through an existing SMTP account. It runs on Python 3.11+, stores no credentials, and has no runtime dependencies.
11
+
12
+ Email cannot be delivered through Gmail, Yahoo, Outlook, or another reputable provider without that provider authenticating the sender. Simple Emailer has no separate application login, but the SMTP provider still requires an app password or OAuth. The CLI securely prompts for an app password, or reads it from an environment variable so automated commands need no interactive login.
13
+
14
+ ## Install Globally
15
+
16
+ [`pipx`](https://pipx.pypa.io/) is recommended because it installs an isolated application and exposes `simple-emailer` globally:
17
+
18
+ ```bash
19
+ pipx install .
20
+ simple-emailer --version
21
+ ```
22
+
23
+ For development from this checkout:
24
+
25
+ ```bash
26
+ python3 -m pip install -e .
27
+ ```
28
+
29
+ ## Send From The CLI
30
+
31
+ Set the provider-generated app password without placing it in shell history:
32
+
33
+ ```bash
34
+ read -rsp "App password: " EMAILER_PASSWORD && export EMAILER_PASSWORD
35
+ printf '\n'
36
+ ```
37
+
38
+ Send a message:
39
+
40
+ ```bash
41
+ simple-emailer send \
42
+ --provider gmail \
43
+ --from you@gmail.com \
44
+ --to recipient@example.com \
45
+ --subject "Hello" \
46
+ --body "Sent from my terminal"
47
+ ```
48
+
49
+ If `EMAILER_PASSWORD` is not set, the command prompts without displaying the password. The credential is never accepted as a command-line argument, where it could leak through shell history or the process list.
50
+
51
+ Use a file or standard input for longer messages, and repeat recipient or attachment options:
52
+
53
+ ```bash
54
+ simple-emailer send \
55
+ --provider yahoo \
56
+ --from you@yahoo.com \
57
+ --to first@example.com \
58
+ --to second@example.com \
59
+ --cc copy@example.com \
60
+ --subject "Report" \
61
+ --body-file message.txt \
62
+ --attach report.pdf
63
+
64
+ printf 'Message from a pipeline\n' | simple-emailer send \
65
+ --provider gmail \
66
+ --from you@gmail.com \
67
+ --to recipient@example.com \
68
+ --subject "Piped message"
69
+ ```
70
+
71
+ Run a complete validation and preview without a credential or network connection:
72
+
73
+ ```bash
74
+ simple-emailer send \
75
+ --provider gmail \
76
+ --from you@gmail.com \
77
+ --to recipient@example.com \
78
+ --subject "Preview" \
79
+ --body "Nothing will be sent" \
80
+ --dry-run
81
+ ```
82
+
83
+ Example output:
84
+
85
+ ```text
86
+ Dry run: no network connection was made.
87
+ Provider: Gmail (smtp.gmail.com:465, tls)
88
+ Envelope from: you@gmail.com
89
+ Envelope recipients: recipient@example.com
90
+ From: you@gmail.com
91
+ To: recipient@example.com
92
+ Subject: Preview
93
+ Body: 20 characters
94
+ ```
95
+
96
+ ## Provider Support
97
+
98
+ Run `simple-emailer providers` to see the built-in status.
99
+
100
+ | Provider | Authentication | Status |
101
+ | --- | --- | --- |
102
+ | Gmail | App password with 2-Step Verification | Supported |
103
+ | Yahoo Mail | Third-party app password | Supported |
104
+ | Outlook.com | OAuth2 / Modern Auth | Not yet supported |
105
+ | Other SMTP services | Password/app password over TLS | Supported with `custom` |
106
+
107
+ Microsoft requires OAuth2 for Outlook.com SMTP. A distributable OAuth client requires an application registration, redirect URI, consent configuration, and secure token storage. Until those product credentials exist, Simple Emailer reports Outlook as unavailable rather than attempting obsolete Basic Authentication.
108
+
109
+ For another SMTP provider, use its documented host, port, TLS mode, and password. This is intentionally available only in the local CLI; the web API retains a fixed provider allowlist.
110
+
111
+ ```bash
112
+ simple-emailer send \
113
+ --provider custom \
114
+ --smtp-host smtp.example.com \
115
+ --smtp-port 587 \
116
+ --smtp-security starttls \
117
+ --username account-name \
118
+ --from sender@example.com \
119
+ --to recipient@example.net \
120
+ --subject "Custom SMTP" \
121
+ --body "Hello"
122
+ ```
123
+
124
+ ## Local Web Interface
125
+
126
+ The original local-first PWA remains available:
127
+
128
+ ```bash
129
+ simple-emailer web
130
+ ```
131
+
132
+ It opens at `http://127.0.0.1:8765`. `simple-emailer` with no subcommand and `python3 msg.py` remain compatible aliases. Use `--no-browser` to prevent automatic browser launch.
133
+
134
+ ## Test
135
+
136
+ Tests never contact a real SMTP server:
137
+
138
+ ```bash
139
+ python3 -m unittest discover -s tests -v
140
+ python3 -m compileall -q emailer tests msg.py
141
+ ```
142
+
143
+ ## Security Model
144
+
145
+ - SMTP certificates and hostnames are verified with the operating system trust store.
146
+ - TLS 1.2 or newer is required.
147
+ - Credentials are held only for the current process and are not logged or written by the application.
148
+ - Bcc addresses are used only in the SMTP envelope and omitted from message headers.
149
+ - Attachments are capped at 5 files, 8 MB each, and 12 MB total.
150
+ - The web server accepts only loopback bindings and same-origin API requests.
151
+ - The browser API cannot select arbitrary SMTP hosts.
152
+
153
+ Environment variables can be inspected by sufficiently privileged local processes. For interactive use, leave `EMAILER_PASSWORD` unset and use the hidden prompt. Revoke an app password if a device or credential is compromised. See [SECURITY.md](SECURITY.md) and [docs/ROADMAP.md](docs/ROADMAP.md).
@@ -0,0 +1,146 @@
1
+ # Simple Emailer
2
+
3
+ Simple Emailer is a local CLI and optional web interface for sending plain-text email through an existing SMTP account. It runs on Python 3.11+, stores no credentials, and has no runtime dependencies.
4
+
5
+ Email cannot be delivered through Gmail, Yahoo, Outlook, or another reputable provider without that provider authenticating the sender. Simple Emailer has no separate application login, but the SMTP provider still requires an app password or OAuth. The CLI securely prompts for an app password, or reads it from an environment variable so automated commands need no interactive login.
6
+
7
+ ## Install Globally
8
+
9
+ [`pipx`](https://pipx.pypa.io/) is recommended because it installs an isolated application and exposes `simple-emailer` globally:
10
+
11
+ ```bash
12
+ pipx install .
13
+ simple-emailer --version
14
+ ```
15
+
16
+ For development from this checkout:
17
+
18
+ ```bash
19
+ python3 -m pip install -e .
20
+ ```
21
+
22
+ ## Send From The CLI
23
+
24
+ Set the provider-generated app password without placing it in shell history:
25
+
26
+ ```bash
27
+ read -rsp "App password: " EMAILER_PASSWORD && export EMAILER_PASSWORD
28
+ printf '\n'
29
+ ```
30
+
31
+ Send a message:
32
+
33
+ ```bash
34
+ simple-emailer send \
35
+ --provider gmail \
36
+ --from you@gmail.com \
37
+ --to recipient@example.com \
38
+ --subject "Hello" \
39
+ --body "Sent from my terminal"
40
+ ```
41
+
42
+ If `EMAILER_PASSWORD` is not set, the command prompts without displaying the password. The credential is never accepted as a command-line argument, where it could leak through shell history or the process list.
43
+
44
+ Use a file or standard input for longer messages, and repeat recipient or attachment options:
45
+
46
+ ```bash
47
+ simple-emailer send \
48
+ --provider yahoo \
49
+ --from you@yahoo.com \
50
+ --to first@example.com \
51
+ --to second@example.com \
52
+ --cc copy@example.com \
53
+ --subject "Report" \
54
+ --body-file message.txt \
55
+ --attach report.pdf
56
+
57
+ printf 'Message from a pipeline\n' | simple-emailer send \
58
+ --provider gmail \
59
+ --from you@gmail.com \
60
+ --to recipient@example.com \
61
+ --subject "Piped message"
62
+ ```
63
+
64
+ Run a complete validation and preview without a credential or network connection:
65
+
66
+ ```bash
67
+ simple-emailer send \
68
+ --provider gmail \
69
+ --from you@gmail.com \
70
+ --to recipient@example.com \
71
+ --subject "Preview" \
72
+ --body "Nothing will be sent" \
73
+ --dry-run
74
+ ```
75
+
76
+ Example output:
77
+
78
+ ```text
79
+ Dry run: no network connection was made.
80
+ Provider: Gmail (smtp.gmail.com:465, tls)
81
+ Envelope from: you@gmail.com
82
+ Envelope recipients: recipient@example.com
83
+ From: you@gmail.com
84
+ To: recipient@example.com
85
+ Subject: Preview
86
+ Body: 20 characters
87
+ ```
88
+
89
+ ## Provider Support
90
+
91
+ Run `simple-emailer providers` to see the built-in status.
92
+
93
+ | Provider | Authentication | Status |
94
+ | --- | --- | --- |
95
+ | Gmail | App password with 2-Step Verification | Supported |
96
+ | Yahoo Mail | Third-party app password | Supported |
97
+ | Outlook.com | OAuth2 / Modern Auth | Not yet supported |
98
+ | Other SMTP services | Password/app password over TLS | Supported with `custom` |
99
+
100
+ Microsoft requires OAuth2 for Outlook.com SMTP. A distributable OAuth client requires an application registration, redirect URI, consent configuration, and secure token storage. Until those product credentials exist, Simple Emailer reports Outlook as unavailable rather than attempting obsolete Basic Authentication.
101
+
102
+ For another SMTP provider, use its documented host, port, TLS mode, and password. This is intentionally available only in the local CLI; the web API retains a fixed provider allowlist.
103
+
104
+ ```bash
105
+ simple-emailer send \
106
+ --provider custom \
107
+ --smtp-host smtp.example.com \
108
+ --smtp-port 587 \
109
+ --smtp-security starttls \
110
+ --username account-name \
111
+ --from sender@example.com \
112
+ --to recipient@example.net \
113
+ --subject "Custom SMTP" \
114
+ --body "Hello"
115
+ ```
116
+
117
+ ## Local Web Interface
118
+
119
+ The original local-first PWA remains available:
120
+
121
+ ```bash
122
+ simple-emailer web
123
+ ```
124
+
125
+ It opens at `http://127.0.0.1:8765`. `simple-emailer` with no subcommand and `python3 msg.py` remain compatible aliases. Use `--no-browser` to prevent automatic browser launch.
126
+
127
+ ## Test
128
+
129
+ Tests never contact a real SMTP server:
130
+
131
+ ```bash
132
+ python3 -m unittest discover -s tests -v
133
+ python3 -m compileall -q emailer tests msg.py
134
+ ```
135
+
136
+ ## Security Model
137
+
138
+ - SMTP certificates and hostnames are verified with the operating system trust store.
139
+ - TLS 1.2 or newer is required.
140
+ - Credentials are held only for the current process and are not logged or written by the application.
141
+ - Bcc addresses are used only in the SMTP envelope and omitted from message headers.
142
+ - Attachments are capped at 5 files, 8 MB each, and 12 MB total.
143
+ - The web server accepts only loopback bindings and same-origin API requests.
144
+ - The browser API cannot select arbitrary SMTP hosts.
145
+
146
+ Environment variables can be inspected by sufficiently privileged local processes. For interactive use, leave `EMAILER_PASSWORD` unset and use the hidden prompt. Revoke an app password if a device or credential is compromised. See [SECURITY.md](SECURITY.md) and [docs/ROADMAP.md](docs/ROADMAP.md).
@@ -0,0 +1,38 @@
1
+ # Security
2
+
3
+ ## Credential handling
4
+
5
+ Simple Emailer does not persist app passwords. A password exists temporarily in the CLI environment or hidden prompt, the browser request when using the web UI, the Python request object, and the SMTP authentication exchange. Application logs contain only HTTP request metadata and safe error messages.
6
+
7
+ Do not add credentials to source files, environment examples, URLs, analytics, crash reports, or tests. If a credential is ever committed or logged, revoke it at the provider rather than merely deleting the local copy.
8
+
9
+ The CLI deliberately has no password argument because command arguments can be exposed in shell history and process listings. Prefer its hidden prompt for interactive sends. Environment variables are useful for automation but may be readable by sufficiently privileged local processes.
10
+
11
+ ## Local operation
12
+
13
+ The default loopback binding is the safest supported mode:
14
+
15
+ ```bash
16
+ python3 -m emailer
17
+ ```
18
+
19
+ Host-header and origin checks reduce browser-based attacks against the local API. Do not disable them. Malware, a hostile browser extension, or another process running as the same user remains outside the application's security boundary.
20
+
21
+ ## Hosted operation
22
+
23
+ Hosted operation is intentionally disabled in this release. The built-in HTTP server accepts only loopback bindings and is not an internet-facing TLS terminator. Before adding a hosted deployment:
24
+
25
+ 1. Put it behind a maintained HTTPS reverse proxy.
26
+ 2. Add an explicit, narrowly scoped private service binding.
27
+ 3. Add exact public-host and origin configuration.
28
+ 4. Add authenticated user sessions and provider OAuth before general public access.
29
+ 5. Add proxy-aware, distributed rate limits and attachment malware controls.
30
+ 6. Prevent request-body logging at every proxy and monitoring layer.
31
+ 7. Set short request timeouts and strict process/network egress policies.
32
+ 8. Run each deployment with a dedicated unprivileged operating-system account.
33
+
34
+ The current in-memory rate limiter is defense in depth for local use, not sufficient public abuse prevention.
35
+
36
+ ## Reporting
37
+
38
+ This repository has no public security contact yet. Until one is established, do not publish a newly discovered credential exposure or remotely exploitable issue with live secrets attached.
@@ -0,0 +1,40 @@
1
+ # Product Roadmap
2
+
3
+ ## Current: secure local MVP
4
+
5
+ - Gmail and Yahoo provider presets
6
+ - Verified TLS and app-password authentication
7
+ - To, Cc, Bcc, Reply-To, plain-text body, and bounded attachments
8
+ - Responsive installable PWA
9
+ - Deliberate review and confirmation before delivery
10
+ - Local-only defaults and offline unit tests
11
+
12
+ ## Next: OAuth and deployment boundary
13
+
14
+ 1. Register separate OAuth clients for development and production.
15
+ 2. Add authorization-code flow with PKCE for Google and Microsoft.
16
+ 3. Store refresh tokens only in an operating-system credential vault for native targets.
17
+ 4. For the hosted web target, encrypt refresh tokens with managed keys and add authenticated sessions.
18
+ 5. Add token revocation, account disconnect, privacy policy, and provider verification.
19
+ 6. Replace app-password prompts with OAuth where providers support it.
20
+
21
+ OAuth client identifiers, redirect URIs, consent-screen ownership, and production domains are product decisions and cannot be safely hard-coded before those accounts exist.
22
+
23
+ ## Packaging
24
+
25
+ 1. Keep the web UI and API contract shared across targets.
26
+ 2. Add an Android wrapper only after OAuth works without exposing a reusable client secret.
27
+ 3. Add Linux packages and a Windows installer around a loopback service and embedded web view.
28
+ 4. Sign every binary and publish checksums.
29
+ 5. Build Android, Linux, and Windows artifacts in isolated CI runners.
30
+ 6. Run accessibility, responsive-layout, provider sandbox, and clean-machine installer tests before release.
31
+
32
+ ## Production hardening
33
+
34
+ - User authentication and account ownership checks
35
+ - Distributed abuse limits and delivery audit events without message content
36
+ - Provider-specific quota and error guidance
37
+ - Attachment malware scanning for hosted delivery
38
+ - Structured, redacted observability
39
+ - Privacy, retention, acceptable-use, and incident-response policies
40
+ - OAuth-first HTML composer with explicit sanitization and previews
@@ -0,0 +1,3 @@
1
+ """Simple Emailer package."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,287 @@
1
+ """Command-line interface for Simple Emailer."""
2
+
3
+ import argparse
4
+ import base64
5
+ import getpass
6
+ import logging
7
+ import os
8
+ import sys
9
+ import threading
10
+ import webbrowser
11
+ from pathlib import Path
12
+ from typing import Sequence, TextIO
13
+
14
+ from . import __version__
15
+ from .mailer import MailDeliveryError, build_message, send_email
16
+ from .models import MailRequest, ValidationError
17
+ from .providers import PROVIDERS, Provider, get_provider
18
+ from .server import create_server
19
+
20
+
21
+ def _add_web_options(parser: argparse.ArgumentParser) -> None:
22
+ parser.add_argument(
23
+ "--host",
24
+ choices=("127.0.0.1", "localhost"),
25
+ default="127.0.0.1",
26
+ help="loopback address to bind (default: 127.0.0.1)",
27
+ )
28
+ parser.add_argument("--port", type=int, default=8765, help="port to bind (default: 8765)")
29
+ parser.add_argument("--no-browser", action="store_true", help="do not open a browser")
30
+ parser.add_argument("--verbose", action="store_true", help="enable request logging")
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="simple-emailer",
36
+ description="Send email securely from a terminal or run the local web interface.",
37
+ )
38
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
39
+ _add_web_options(parser)
40
+ commands = parser.add_subparsers(dest="command")
41
+
42
+ send_parser = commands.add_parser("send", help="send an email from the terminal")
43
+ send_parser.add_argument(
44
+ "--provider",
45
+ required=True,
46
+ choices=(*PROVIDERS.keys(), "custom"),
47
+ help="sender's email provider",
48
+ )
49
+ send_parser.add_argument("--from", dest="sender_email", required=True, help="sender address")
50
+ send_parser.add_argument("--name", default="", help="sender display name")
51
+ send_parser.add_argument(
52
+ "--username", help="SMTP login name when it differs from the sender address"
53
+ )
54
+ send_parser.add_argument(
55
+ "--to", action="append", required=True, metavar="ADDRESS", help="recipient; repeat as needed"
56
+ )
57
+ send_parser.add_argument(
58
+ "--cc", action="append", default=[], metavar="ADDRESS", help="Cc recipient; repeat as needed"
59
+ )
60
+ send_parser.add_argument(
61
+ "--bcc", action="append", default=[], metavar="ADDRESS", help="Bcc recipient; repeat as needed"
62
+ )
63
+ send_parser.add_argument("--reply-to", help="reply-to address")
64
+ send_parser.add_argument("--subject", required=True, help="message subject")
65
+ body_group = send_parser.add_mutually_exclusive_group()
66
+ body_group.add_argument("--body", help="plain-text message body")
67
+ body_group.add_argument(
68
+ "--body-file", metavar="PATH", help="read the body from PATH, or - for standard input"
69
+ )
70
+ send_parser.add_argument(
71
+ "--attach", action="append", default=[], metavar="PATH", help="attachment; repeat as needed"
72
+ )
73
+ send_parser.add_argument(
74
+ "--credential-env",
75
+ default="EMAILER_PASSWORD",
76
+ metavar="NAME",
77
+ help="environment variable containing the SMTP/app password (default: EMAILER_PASSWORD)",
78
+ )
79
+ send_parser.add_argument("--smtp-host", help="custom provider SMTP hostname")
80
+ send_parser.add_argument("--smtp-port", type=int, help="custom provider SMTP port")
81
+ send_parser.add_argument(
82
+ "--smtp-security", choices=("tls", "starttls"), help="custom provider TLS mode"
83
+ )
84
+ send_parser.add_argument(
85
+ "--dry-run", action="store_true", help="validate and preview without connecting to SMTP"
86
+ )
87
+
88
+ commands.add_parser("providers", help="list provider support and SMTP requirements")
89
+ web_parser = commands.add_parser("web", help="run the local web interface")
90
+ _add_web_options(web_parser)
91
+ return parser
92
+
93
+
94
+ def _split_addresses(values: Sequence[str]) -> list[str]:
95
+ return [address.strip() for value in values for address in value.split(",") if address.strip()]
96
+
97
+
98
+ def _custom_provider(args: argparse.Namespace, parser: argparse.ArgumentParser) -> Provider:
99
+ missing = [
100
+ option
101
+ for option, value in (
102
+ ("--smtp-host", args.smtp_host),
103
+ ("--smtp-port", args.smtp_port),
104
+ ("--smtp-security", args.smtp_security),
105
+ )
106
+ if value is None
107
+ ]
108
+ if missing:
109
+ parser.error(f"custom provider requires {', '.join(missing)}")
110
+ if not 1 <= args.smtp_port <= 65535:
111
+ parser.error("--smtp-port must be between 1 and 65535")
112
+ host = args.smtp_host.strip()
113
+ if not host or any(character.isspace() for character in host):
114
+ parser.error("--smtp-host must be a valid hostname")
115
+ return Provider(
116
+ id="custom",
117
+ name="Custom SMTP",
118
+ smtp_host=host,
119
+ smtp_port=args.smtp_port,
120
+ security=args.smtp_security,
121
+ auth_method="password",
122
+ enabled=True,
123
+ help_url="",
124
+ guidance="User-supplied SMTP configuration.",
125
+ )
126
+
127
+
128
+ def _read_body(args: argparse.Namespace, parser: argparse.ArgumentParser, stdin: TextIO) -> str:
129
+ if args.body is not None:
130
+ return args.body
131
+ if args.body_file is not None:
132
+ if args.body_file == "-":
133
+ return stdin.read()
134
+ try:
135
+ return Path(args.body_file).read_text(encoding="utf-8")
136
+ except (OSError, UnicodeError) as error:
137
+ parser.error(f"cannot read body file {args.body_file!r}: {error}")
138
+ if not stdin.isatty():
139
+ return stdin.read()
140
+ parser.error("provide --body, --body-file, or pipe the message body on standard input")
141
+
142
+
143
+ def _read_attachments(paths: Sequence[str], parser: argparse.ArgumentParser) -> list[dict[str, str]]:
144
+ attachments = []
145
+ for raw_path in paths:
146
+ path = Path(raw_path)
147
+ try:
148
+ content = path.read_bytes()
149
+ except OSError as error:
150
+ parser.error(f"cannot read attachment {raw_path!r}: {error}")
151
+ attachments.append(
152
+ {
153
+ "filename": path.name,
154
+ "content": base64.b64encode(content).decode("ascii"),
155
+ }
156
+ )
157
+ return attachments
158
+
159
+
160
+ def _print_preview(request: MailRequest, provider: Provider, stdout: TextIO) -> None:
161
+ message = build_message(request)
162
+ print("Dry run: no network connection was made.", file=stdout)
163
+ print(f"Provider: {provider.name} ({provider.smtp_host}:{provider.smtp_port}, {provider.security})", file=stdout)
164
+ print(f"Envelope from: {request.sender_email}", file=stdout)
165
+ print(f"Envelope recipients: {', '.join(request.recipients)}", file=stdout)
166
+ for name in ("From", "To", "Cc", "Reply-To", "Subject"):
167
+ if message[name]:
168
+ print(f"{name}: {message[name]}", file=stdout)
169
+ print(f"Body: {len(request.body)} characters", file=stdout)
170
+ if request.attachments:
171
+ summary = ", ".join(
172
+ f"{attachment.filename} ({len(attachment.content)} bytes)"
173
+ for attachment in request.attachments
174
+ )
175
+ print(f"Attachments: {summary}", file=stdout)
176
+
177
+
178
+ def _run_send(
179
+ args: argparse.Namespace,
180
+ parser: argparse.ArgumentParser,
181
+ *,
182
+ stdin: TextIO,
183
+ stdout: TextIO,
184
+ stderr: TextIO,
185
+ ) -> int:
186
+ provider = _custom_provider(args, parser) if args.provider == "custom" else get_provider(args.provider)
187
+ if provider is None:
188
+ parser.error("unknown provider")
189
+ if not provider.enabled:
190
+ print(provider.unavailable_reason or "This provider is not available.", file=stderr)
191
+ return 2
192
+
193
+ credential = "dry-run-placeholder" if args.dry_run else os.environ.get(args.credential_env)
194
+ if not credential:
195
+ credential = getpass.getpass(f"{provider.name} SMTP/app password: ")
196
+
197
+ payload = {
198
+ "provider": provider.id,
199
+ "sender_email": args.sender_email,
200
+ "sender_name": args.name,
201
+ "auth_username": args.username,
202
+ "app_password": credential,
203
+ "to": _split_addresses(args.to),
204
+ "cc": _split_addresses(args.cc),
205
+ "bcc": _split_addresses(args.bcc),
206
+ "reply_to": args.reply_to,
207
+ "subject": args.subject,
208
+ "body": _read_body(args, parser, stdin),
209
+ "attachments": _read_attachments(args.attach, parser),
210
+ }
211
+ try:
212
+ request = MailRequest.from_payload(payload, provider_override=provider)
213
+ except ValidationError as error:
214
+ location = f" ({error.field_name})" if error.field_name else ""
215
+ print(f"Invalid message{location}: {error.message}", file=stderr)
216
+ return 2
217
+ finally:
218
+ payload["app_password"] = None
219
+
220
+ if args.dry_run:
221
+ _print_preview(request, provider, stdout)
222
+ return 0
223
+ try:
224
+ result = send_email(request, provider_override=provider)
225
+ except MailDeliveryError as error:
226
+ print(f"Send failed [{error.code}]: {error.message}", file=stderr)
227
+ return 1
228
+
229
+ print(f"Provider accepted {len(result.accepted)} recipient(s).", file=stdout)
230
+ if result.refused:
231
+ print(f"Refused: {', '.join(result.refused)}", file=stderr)
232
+ return 1
233
+ return 0
234
+
235
+
236
+ def _run_providers(stdout: TextIO) -> int:
237
+ for provider in PROVIDERS.values():
238
+ status = "available" if provider.enabled else "unavailable"
239
+ print(f"{provider.id:8} {status:11} {provider.name}: {provider.guidance}", file=stdout)
240
+ print("custom available Any SMTP service with password authentication and TLS.", file=stdout)
241
+ return 0
242
+
243
+
244
+ def _run_web(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
245
+ if not 0 <= args.port <= 65535:
246
+ parser.error("port must be between 0 and 65535")
247
+ logging.basicConfig(
248
+ level=logging.INFO if args.verbose else logging.WARNING,
249
+ format="%(levelname)s %(name)s: %(message)s",
250
+ )
251
+ server = create_server(host=args.host, port=args.port)
252
+ actual_port = server.server_address[1]
253
+ display_url = f"http://{args.host}:{actual_port}"
254
+ print(f"Simple Emailer is running at {display_url}")
255
+ print("Press Ctrl+C to stop. App passwords are held only for the current send request.")
256
+ if not args.no_browser:
257
+ threading.Timer(0.25, webbrowser.open, args=(display_url,)).start()
258
+ try:
259
+ server.serve_forever()
260
+ except KeyboardInterrupt:
261
+ print("\nStopping Simple Emailer.")
262
+ finally:
263
+ server.server_close()
264
+ return 0
265
+
266
+
267
+ def main(
268
+ argv: list[str] | None = None,
269
+ *,
270
+ stdin: TextIO | None = None,
271
+ stdout: TextIO | None = None,
272
+ stderr: TextIO | None = None,
273
+ ) -> int:
274
+ parser = build_parser()
275
+ args = parser.parse_args(argv)
276
+ stdin = stdin or sys.stdin
277
+ stdout = stdout or sys.stdout
278
+ stderr = stderr or sys.stderr
279
+ if args.command == "send":
280
+ return _run_send(args, parser, stdin=stdin, stdout=stdout, stderr=stderr)
281
+ if args.command == "providers":
282
+ return _run_providers(stdout)
283
+ return _run_web(args, parser)
284
+
285
+
286
+ if __name__ == "__main__":
287
+ raise SystemExit(main())