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.
- simple_emailer-0.2.0/MANIFEST.in +4 -0
- simple_emailer-0.2.0/PKG-INFO +153 -0
- simple_emailer-0.2.0/README.md +146 -0
- simple_emailer-0.2.0/SECURITY.md +38 -0
- simple_emailer-0.2.0/docs/ROADMAP.md +40 -0
- simple_emailer-0.2.0/emailer/__init__.py +3 -0
- simple_emailer-0.2.0/emailer/__main__.py +287 -0
- simple_emailer-0.2.0/emailer/mailer.py +235 -0
- simple_emailer-0.2.0/emailer/models.py +283 -0
- simple_emailer-0.2.0/emailer/providers.py +73 -0
- simple_emailer-0.2.0/emailer/server.py +463 -0
- simple_emailer-0.2.0/emailer/static/__init__.py +1 -0
- simple_emailer-0.2.0/emailer/static/app.js +475 -0
- simple_emailer-0.2.0/emailer/static/icon.svg +6 -0
- simple_emailer-0.2.0/emailer/static/index.html +186 -0
- simple_emailer-0.2.0/emailer/static/manifest.webmanifest +19 -0
- simple_emailer-0.2.0/emailer/static/styles.css +827 -0
- simple_emailer-0.2.0/emailer/static/sw.js +50 -0
- simple_emailer-0.2.0/msg.py +7 -0
- simple_emailer-0.2.0/pyproject.toml +17 -0
- simple_emailer-0.2.0/setup.cfg +4 -0
- simple_emailer-0.2.0/simple_emailer.egg-info/PKG-INFO +153 -0
- simple_emailer-0.2.0/simple_emailer.egg-info/SOURCES.txt +29 -0
- simple_emailer-0.2.0/simple_emailer.egg-info/dependency_links.txt +1 -0
- simple_emailer-0.2.0/simple_emailer.egg-info/entry_points.txt +2 -0
- simple_emailer-0.2.0/simple_emailer.egg-info/top_level.txt +1 -0
- simple_emailer-0.2.0/tests/__init__.py +0 -0
- simple_emailer-0.2.0/tests/test_cli.py +154 -0
- simple_emailer-0.2.0/tests/test_mailer.py +152 -0
- simple_emailer-0.2.0/tests/test_models.py +135 -0
- simple_emailer-0.2.0/tests/test_server.py +163 -0
|
@@ -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,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())
|