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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ emailcli = emailcli.cli:cli
@@ -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