email-cli-tool 0.1.0__py3-none-any.whl
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.
- email_cli_tool-0.1.0.dist-info/METADATA +155 -0
- email_cli_tool-0.1.0.dist-info/RECORD +12 -0
- email_cli_tool-0.1.0.dist-info/WHEEL +4 -0
- email_cli_tool-0.1.0.dist-info/entry_points.txt +2 -0
- email_cli_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
- emailcli/__init__.py +0 -0
- emailcli/cli.py +142 -0
- emailcli/config.py +53 -0
- emailcli/exceptions.py +14 -0
- emailcli/message.py +65 -0
- emailcli/py.typed +0 -0
- emailcli/sender.py +43 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: email-cli-tool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A simple CLI tool for sending emails with plain text, HTML, and file attachments
|
|
5
|
+
Project-URL: Homepage, https://github.com/ClaymanTwinkle/email-cli-tool
|
|
6
|
+
Project-URL: Repository, https://github.com/ClaymanTwinkle/email-cli-tool
|
|
7
|
+
Project-URL: Issues, https://github.com/ClaymanTwinkle/email-cli-tool/issues
|
|
8
|
+
Author: ClaymanTwinkle
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: attachments,cli,email,smtp
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: Email
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# email-cli-tool
|
|
28
|
+
|
|
29
|
+
> A simple CLI tool for sending emails with plain text, HTML, and file attachments.
|
|
30
|
+
>
|
|
31
|
+
> [中文文档](README_CN.md)
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- Send plain text / HTML / mixed format emails
|
|
36
|
+
- Attach multiple files and images
|
|
37
|
+
- Multiple recipients support
|
|
38
|
+
- Direct SMTP connection (SSL / STARTTLS)
|
|
39
|
+
- Interactive configuration wizard
|
|
40
|
+
- Read body content from stdin
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# From PyPI
|
|
46
|
+
pip install email-cli-tool
|
|
47
|
+
|
|
48
|
+
# Or with uv
|
|
49
|
+
uv tool install email-cli-tool
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
### 1. Initialize Configuration
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
emailcli init
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Follow the prompts to enter your SMTP settings. Example for Gmail:
|
|
61
|
+
|
|
62
|
+
| Field | Value |
|
|
63
|
+
|-------|-------|
|
|
64
|
+
| From address | `yourname@gmail.com` |
|
|
65
|
+
| SMTP host | `smtp.gmail.com` |
|
|
66
|
+
| SMTP port | `465` |
|
|
67
|
+
| SMTP username | `yourname@gmail.com` |
|
|
68
|
+
| SMTP password | App password |
|
|
69
|
+
| Encryption | `ssl` |
|
|
70
|
+
|
|
71
|
+
### 2. Send Emails
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Plain text
|
|
75
|
+
emailcli send --to user@example.com --subject "Hello" --body "Hello World"
|
|
76
|
+
|
|
77
|
+
# HTML format
|
|
78
|
+
emailcli send --to user@example.com --subject "Notice" \
|
|
79
|
+
--html "<h1>Title</h1><p>Body content</p>"
|
|
80
|
+
|
|
81
|
+
# With attachments
|
|
82
|
+
emailcli send --to user@example.com --subject "Report" \
|
|
83
|
+
--body "Please see attachments" \
|
|
84
|
+
--attach report.pdf \
|
|
85
|
+
--attach photo.png
|
|
86
|
+
|
|
87
|
+
# Multiple recipients
|
|
88
|
+
emailcli send \
|
|
89
|
+
--to a@example.com \
|
|
90
|
+
--to b@example.com \
|
|
91
|
+
--subject "Broadcast" --body "Hello everyone"
|
|
92
|
+
|
|
93
|
+
# HTML body from file
|
|
94
|
+
emailcli send --to user@example.com --subject "Newsletter" \
|
|
95
|
+
--html-file template.html
|
|
96
|
+
|
|
97
|
+
# Read body from stdin
|
|
98
|
+
echo "Content" | emailcli send \
|
|
99
|
+
--to user@example.com --subject "Piped" --body -
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Command Reference
|
|
103
|
+
|
|
104
|
+
### `emailcli send`
|
|
105
|
+
|
|
106
|
+
| Option | Required | Repeatable | Description |
|
|
107
|
+
|--------|:--------:|:----------:|-------------|
|
|
108
|
+
| `--to` | ✅ | ✅ | Recipient email address |
|
|
109
|
+
| `--subject` | ✅ | | Email subject |
|
|
110
|
+
| `--body` | | | Plain text body, `-` reads from stdin |
|
|
111
|
+
| `--html` | | | HTML body string (mutually exclusive with `--html-file`) |
|
|
112
|
+
| `--html-file` | | | Read HTML body from file (mutually exclusive with `--html`) |
|
|
113
|
+
| `--attach` | | ✅ | Attachment file path |
|
|
114
|
+
| `--from` | | | Override sender address from config |
|
|
115
|
+
|
|
116
|
+
> At least one of `--body`, `--html`, or `--html-file` is required.
|
|
117
|
+
|
|
118
|
+
### `emailcli init`
|
|
119
|
+
|
|
120
|
+
Interactively create the configuration file at `~/.emailcli/config.yaml`.
|
|
121
|
+
|
|
122
|
+
### `emailcli config show`
|
|
123
|
+
|
|
124
|
+
Display current configuration (password is masked).
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
Path: `~/.emailcli/config.yaml`
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
from: yourname@gmail.com
|
|
132
|
+
smtp:
|
|
133
|
+
host: smtp.gmail.com
|
|
134
|
+
port: 465
|
|
135
|
+
username: yourname@gmail.com
|
|
136
|
+
password: your-app-password
|
|
137
|
+
encryption: ssl # ssl | starttls | none
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Install dependencies
|
|
144
|
+
uv sync
|
|
145
|
+
|
|
146
|
+
# Run tests
|
|
147
|
+
uv run pytest -v
|
|
148
|
+
|
|
149
|
+
# Run locally
|
|
150
|
+
uv run emailcli --help
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
emailcli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
emailcli/cli.py,sha256=pu7j_r9G2dmi0Of-Jm2ky22keFsqm_S0ce72baCA6iI,5005
|
|
3
|
+
emailcli/config.py,sha256=y1gvAowozFxxBr0VCc0Cm8D1MDw1b8_Ba64wdCgml4E,1435
|
|
4
|
+
emailcli/exceptions.py,sha256=PAXFwwBjkr82VfNmR3_14PyN6E5g4HZZjJO6pSqjGUY,288
|
|
5
|
+
emailcli/message.py,sha256=zL6JA4gtxoytafvr07d3ZydGEUl4OYJjOjAXSlhu50Y,1833
|
|
6
|
+
emailcli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
emailcli/sender.py,sha256=0LzMlMa8-v9cvfgBtrSZ3-B8CjaBSvmDFoAzgTdDWUs,1158
|
|
8
|
+
email_cli_tool-0.1.0.dist-info/METADATA,sha256=N-M6kdsGGh_oG84G92tqa1U8oRWkRKBeMS_yzhfbG00,3844
|
|
9
|
+
email_cli_tool-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
email_cli_tool-0.1.0.dist-info/entry_points.txt,sha256=v4g4XtxIoMN04iG6hnBI5JOKYe5yxmTZF88Bmi30w1A,46
|
|
11
|
+
email_cli_tool-0.1.0.dist-info/licenses/LICENSE,sha256=uyHV-GMpScxptcV957gUjyAk0nceqdisWpm0KaDuA68,1071
|
|
12
|
+
email_cli_tool-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ClaymanTwinkle
|
|
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.
|
emailcli/__init__.py
ADDED
|
File without changes
|
emailcli/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from emailcli.config import load_config
|
|
7
|
+
from emailcli.exceptions import EmailCliError
|
|
8
|
+
from emailcli.message import build_message
|
|
9
|
+
from emailcli.sender import SmtpSender
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def cli():
|
|
14
|
+
"""CLI tool for sending emails with attachments."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cli.command()
|
|
18
|
+
@click.option("--to", "to_addrs", required=True, multiple=True, help="Recipient email address (repeatable).")
|
|
19
|
+
@click.option("--subject", required=True, help="Email subject.")
|
|
20
|
+
@click.option("--body", default=None, help="Plain text body. Use '-' to read from stdin.")
|
|
21
|
+
@click.option("--html", "html_content", default=None, help="HTML body string.")
|
|
22
|
+
@click.option("--html-file", "html_file_path", default=None, type=click.Path(exists=True), help="Read HTML body from file.")
|
|
23
|
+
@click.option("--attach", "attachments", multiple=True, type=click.Path(exists=True), help="Attachment file path (repeatable).")
|
|
24
|
+
@click.option("--from", "from_addr", default=None, help="Sender address (overrides config).")
|
|
25
|
+
@click.option("--config-dir", default=None, type=click.Path(), hidden=True, help="Config directory (for testing).")
|
|
26
|
+
def send(to_addrs, subject, body, html_content, html_file_path, attachments, from_addr, config_dir):
|
|
27
|
+
"""Send an email."""
|
|
28
|
+
try:
|
|
29
|
+
# Validate --html and --html-file mutual exclusivity
|
|
30
|
+
if html_content and html_file_path:
|
|
31
|
+
raise click.UsageError("--html and --html-file are mutually exclusive.")
|
|
32
|
+
|
|
33
|
+
# Read stdin if body is "-"
|
|
34
|
+
if body == "-":
|
|
35
|
+
body = click.get_text_stream("stdin").read()
|
|
36
|
+
|
|
37
|
+
# Load config
|
|
38
|
+
cfg_dir = Path(config_dir) if config_dir else None
|
|
39
|
+
config = load_config(cfg_dir)
|
|
40
|
+
|
|
41
|
+
# Determine from address
|
|
42
|
+
sender_addr = from_addr or config.from_addr
|
|
43
|
+
if not sender_addr:
|
|
44
|
+
raise EmailCliError("No sender address. Set 'from' in config or use --from.")
|
|
45
|
+
|
|
46
|
+
# Build message
|
|
47
|
+
msg = build_message(
|
|
48
|
+
from_addr=sender_addr,
|
|
49
|
+
to_addrs=list(to_addrs),
|
|
50
|
+
subject=subject,
|
|
51
|
+
body=body,
|
|
52
|
+
html=html_content,
|
|
53
|
+
html_file=Path(html_file_path) if html_file_path else None,
|
|
54
|
+
attachments=[Path(a) for a in attachments] if attachments else None,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Send
|
|
58
|
+
sender = SmtpSender(
|
|
59
|
+
host=config.smtp_host,
|
|
60
|
+
port=config.smtp_port,
|
|
61
|
+
username=config.smtp_username,
|
|
62
|
+
password=config.smtp_password,
|
|
63
|
+
encryption=config.smtp_encryption,
|
|
64
|
+
)
|
|
65
|
+
sender.send(msg)
|
|
66
|
+
|
|
67
|
+
click.echo("Email sent successfully.")
|
|
68
|
+
except click.UsageError:
|
|
69
|
+
raise
|
|
70
|
+
except EmailCliError as e:
|
|
71
|
+
click.echo(f"Error: {e}", err=True)
|
|
72
|
+
raise SystemExit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@cli.command()
|
|
76
|
+
@click.option("--config-dir", default=None, type=click.Path(), hidden=True, help="Config directory (for testing).")
|
|
77
|
+
def init(config_dir):
|
|
78
|
+
"""Initialize emailcli configuration."""
|
|
79
|
+
import os
|
|
80
|
+
|
|
81
|
+
import yaml
|
|
82
|
+
|
|
83
|
+
cfg_dir = Path(config_dir) if config_dir else Path.home() / ".emailcli"
|
|
84
|
+
config_file = cfg_dir / "config.yaml"
|
|
85
|
+
|
|
86
|
+
if config_file.exists():
|
|
87
|
+
if not click.confirm(f"Config already exists at {config_file}. Overwrite?"):
|
|
88
|
+
click.echo("Aborted.")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
click.echo("Setting up emailcli configuration...\n")
|
|
92
|
+
|
|
93
|
+
from_addr = click.prompt("From address (sender email)")
|
|
94
|
+
smtp_host = click.prompt("SMTP host")
|
|
95
|
+
smtp_port = click.prompt("SMTP port", type=int, default=465)
|
|
96
|
+
smtp_username = click.prompt("SMTP username")
|
|
97
|
+
smtp_password = click.prompt("SMTP password", hide_input=True)
|
|
98
|
+
smtp_encryption = click.prompt(
|
|
99
|
+
"Encryption (starttls/ssl/none)", default="ssl"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
config_data = {
|
|
103
|
+
"from": from_addr,
|
|
104
|
+
"smtp": {
|
|
105
|
+
"host": smtp_host,
|
|
106
|
+
"port": smtp_port,
|
|
107
|
+
"username": smtp_username,
|
|
108
|
+
"password": smtp_password,
|
|
109
|
+
"encryption": smtp_encryption,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
cfg_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
with open(config_file, "w") as f:
|
|
115
|
+
yaml.dump(config_data, f, default_flow_style=False)
|
|
116
|
+
os.chmod(config_file, 0o600)
|
|
117
|
+
|
|
118
|
+
click.echo(f"\nConfig saved to {config_file}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@cli.group(name="config")
|
|
122
|
+
def config_group():
|
|
123
|
+
"""Manage emailcli configuration."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@config_group.command()
|
|
127
|
+
@click.option("--config-dir", default=None, type=click.Path(), hidden=True, help="Config directory (for testing).")
|
|
128
|
+
def show(config_dir):
|
|
129
|
+
"""Show current configuration."""
|
|
130
|
+
try:
|
|
131
|
+
cfg_dir = Path(config_dir) if config_dir else None
|
|
132
|
+
cfg = load_config(cfg_dir)
|
|
133
|
+
|
|
134
|
+
click.echo(f"From: {cfg.from_addr}")
|
|
135
|
+
click.echo(f"SMTP Host: {cfg.smtp_host}")
|
|
136
|
+
click.echo(f"SMTP Port: {cfg.smtp_port}")
|
|
137
|
+
click.echo(f"Username: {cfg.smtp_username}")
|
|
138
|
+
click.echo(f"Password: ***")
|
|
139
|
+
click.echo(f"Encryption: {cfg.smtp_encryption}")
|
|
140
|
+
except EmailCliError as e:
|
|
141
|
+
click.echo(f"Error: {e}", err=True)
|
|
142
|
+
raise SystemExit(1)
|
emailcli/config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from emailcli.exceptions import ConfigError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ConfigData:
|
|
11
|
+
from_addr: str
|
|
12
|
+
smtp_host: str
|
|
13
|
+
smtp_port: int
|
|
14
|
+
smtp_username: str
|
|
15
|
+
smtp_password: str
|
|
16
|
+
smtp_encryption: str # "starttls" | "ssl" | "none"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config(config_dir: Path | None = None) -> ConfigData:
|
|
20
|
+
if config_dir is None:
|
|
21
|
+
config_dir = Path.home() / ".emailcli"
|
|
22
|
+
|
|
23
|
+
config_file = config_dir / "config.yaml"
|
|
24
|
+
|
|
25
|
+
if not config_file.exists():
|
|
26
|
+
raise ConfigError(
|
|
27
|
+
f"Config file not found: {config_file}\n"
|
|
28
|
+
"Run 'emailcli init' to create one."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
with open(config_file) as f:
|
|
32
|
+
data = yaml.safe_load(f)
|
|
33
|
+
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
raise ConfigError(f"Invalid config format in {config_file}")
|
|
36
|
+
|
|
37
|
+
smtp = data.get("smtp", {})
|
|
38
|
+
if not isinstance(smtp, dict):
|
|
39
|
+
raise ConfigError("'smtp' must be a mapping")
|
|
40
|
+
|
|
41
|
+
required = ["host", "username", "password"]
|
|
42
|
+
for field in required:
|
|
43
|
+
if field not in smtp:
|
|
44
|
+
raise ConfigError(f"Missing required smtp field: '{field}'")
|
|
45
|
+
|
|
46
|
+
return ConfigData(
|
|
47
|
+
from_addr=data.get("from", ""),
|
|
48
|
+
smtp_host=smtp["host"],
|
|
49
|
+
smtp_port=smtp.get("port", 587),
|
|
50
|
+
smtp_username=smtp["username"],
|
|
51
|
+
smtp_password=smtp["password"],
|
|
52
|
+
smtp_encryption=smtp.get("encryption", "starttls"),
|
|
53
|
+
)
|
emailcli/exceptions.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class EmailCliError(Exception):
|
|
2
|
+
"""Base exception for emailcli."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigError(EmailCliError):
|
|
6
|
+
"""Configuration file errors."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MessageError(EmailCliError):
|
|
10
|
+
"""Email message building errors."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SendError(EmailCliError):
|
|
14
|
+
"""Email sending errors."""
|
emailcli/message.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from email.message import EmailMessage
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from emailcli.exceptions import MessageError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_message(
|
|
9
|
+
from_addr: str,
|
|
10
|
+
to_addrs: list[str],
|
|
11
|
+
subject: str,
|
|
12
|
+
body: str | None = None,
|
|
13
|
+
html: str | None = None,
|
|
14
|
+
html_file: Path | None = None,
|
|
15
|
+
attachments: list[Path] | None = None,
|
|
16
|
+
) -> EmailMessage:
|
|
17
|
+
# Resolve html_file to html string
|
|
18
|
+
if html_file is not None:
|
|
19
|
+
if not html_file.exists():
|
|
20
|
+
raise MessageError(f"HTML file not found: {html_file}")
|
|
21
|
+
html = html_file.read_text(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
if not body and not html:
|
|
24
|
+
raise MessageError("Must provide at least one of: --body, --html, --html-file")
|
|
25
|
+
|
|
26
|
+
# Validate attachments exist before building
|
|
27
|
+
if attachments:
|
|
28
|
+
for path in attachments:
|
|
29
|
+
if not path.exists():
|
|
30
|
+
raise MessageError(f"Attachment not found: {path}")
|
|
31
|
+
|
|
32
|
+
msg = EmailMessage()
|
|
33
|
+
msg["From"] = from_addr
|
|
34
|
+
msg["To"] = ", ".join(to_addrs)
|
|
35
|
+
msg["Subject"] = subject
|
|
36
|
+
|
|
37
|
+
# Build content
|
|
38
|
+
if body and html:
|
|
39
|
+
msg.set_content(body)
|
|
40
|
+
msg.add_alternative(html, subtype="html")
|
|
41
|
+
elif body:
|
|
42
|
+
msg.set_content(body)
|
|
43
|
+
else:
|
|
44
|
+
msg.set_content(html, subtype="html")
|
|
45
|
+
|
|
46
|
+
# Add attachments
|
|
47
|
+
if attachments:
|
|
48
|
+
for path in attachments:
|
|
49
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
50
|
+
if mime_type is None:
|
|
51
|
+
maintype, subtype = "application", "octet-stream"
|
|
52
|
+
else:
|
|
53
|
+
maintype, subtype = mime_type.split("/", 1)
|
|
54
|
+
|
|
55
|
+
with open(path, "rb") as f:
|
|
56
|
+
data = f.read()
|
|
57
|
+
|
|
58
|
+
msg.add_attachment(
|
|
59
|
+
data,
|
|
60
|
+
maintype=maintype,
|
|
61
|
+
subtype=subtype,
|
|
62
|
+
filename=path.name,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return msg
|
emailcli/py.typed
ADDED
|
File without changes
|
emailcli/sender.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import smtplib
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from email.message import EmailMessage
|
|
4
|
+
|
|
5
|
+
from emailcli.exceptions import SendError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Sender(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def send(self, message: EmailMessage) -> None: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SmtpSender(Sender):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
host: str,
|
|
17
|
+
port: int,
|
|
18
|
+
username: str,
|
|
19
|
+
password: str,
|
|
20
|
+
encryption: str,
|
|
21
|
+
):
|
|
22
|
+
self.host = host
|
|
23
|
+
self.port = port
|
|
24
|
+
self.username = username
|
|
25
|
+
self.password = password
|
|
26
|
+
self.encryption = encryption
|
|
27
|
+
|
|
28
|
+
def send(self, message: EmailMessage) -> None:
|
|
29
|
+
try:
|
|
30
|
+
if self.encryption == "ssl":
|
|
31
|
+
smtp_cls = smtplib.SMTP_SSL
|
|
32
|
+
else:
|
|
33
|
+
smtp_cls = smtplib.SMTP
|
|
34
|
+
|
|
35
|
+
with smtp_cls(self.host, self.port) as server:
|
|
36
|
+
if self.encryption == "starttls":
|
|
37
|
+
server.starttls()
|
|
38
|
+
server.login(self.username, self.password)
|
|
39
|
+
server.send_message(message)
|
|
40
|
+
except SendError:
|
|
41
|
+
raise
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise SendError(f"Failed to send email: {e}") from e
|