simple-emailer 0.2.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.
emailer/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Simple Emailer package."""
2
+
3
+ __version__ = "0.2.0"
emailer/__main__.py ADDED
@@ -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())
emailer/mailer.py ADDED
@@ -0,0 +1,235 @@
1
+ """Secure MIME construction and SMTP delivery."""
2
+
3
+ import smtplib
4
+ import socket
5
+ import ssl
6
+ from dataclasses import dataclass
7
+ from email.headerregistry import Address
8
+ from email.message import EmailMessage
9
+ from email.utils import formatdate, make_msgid
10
+ from typing import Callable
11
+
12
+ from .models import MailRequest, normalized_address_key
13
+ from .providers import Provider, get_provider
14
+
15
+
16
+ class MailDeliveryError(RuntimeError):
17
+ def __init__(self, code: str, message: str, refused: tuple[str, ...] = ()) -> None:
18
+ super().__init__(message)
19
+ self.code = code
20
+ self.message = message
21
+ self.refused = refused
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class SendResult:
26
+ accepted: tuple[str, ...]
27
+ refused: tuple[str, ...]
28
+
29
+ @property
30
+ def partial(self) -> bool:
31
+ return bool(self.refused)
32
+
33
+
34
+ def build_message(request: MailRequest) -> EmailMessage:
35
+ message = EmailMessage()
36
+ message["Subject"] = request.subject
37
+ message["From"] = Address(
38
+ display_name=request.sender_name,
39
+ addr_spec=request.sender_email,
40
+ )
41
+ message["To"] = ", ".join(request.to)
42
+ if request.cc:
43
+ message["Cc"] = ", ".join(request.cc)
44
+ if request.reply_to:
45
+ message["Reply-To"] = request.reply_to
46
+ message["Date"] = formatdate(localtime=True)
47
+ message["Message-ID"] = make_msgid(domain=request.sender_email.rsplit("@", 1)[1])
48
+ message.set_content(request.body, cte="quoted-printable")
49
+
50
+ for attachment in request.attachments:
51
+ maintype, subtype = attachment.content_type.split("/", 1)
52
+ message.add_attachment(
53
+ attachment.content,
54
+ maintype=maintype,
55
+ subtype=subtype,
56
+ filename=attachment.filename,
57
+ )
58
+ return message
59
+
60
+
61
+ def send_email(
62
+ request: MailRequest,
63
+ *,
64
+ provider_override: Provider | None = None,
65
+ timeout: float = 30,
66
+ smtp_factory: Callable[..., smtplib.SMTP] | None = None,
67
+ smtp_ssl_factory: Callable[..., smtplib.SMTP_SSL] | None = None,
68
+ ) -> SendResult:
69
+ provider = provider_override or get_provider(request.provider_id)
70
+ if provider_override is not None and provider_override.id != request.provider_id:
71
+ raise MailDeliveryError(
72
+ "provider_configuration", "The SMTP provider configuration does not match."
73
+ )
74
+ if provider is None or not provider.enabled:
75
+ raise MailDeliveryError("provider_unavailable", "This provider is not available.")
76
+ if provider.auth_method not in {"app_password", "password"}:
77
+ raise MailDeliveryError(
78
+ "provider_unavailable", "This provider requires an unsupported sign-in method."
79
+ )
80
+
81
+ smtp_factory = smtp_factory or smtplib.SMTP
82
+ smtp_ssl_factory = smtp_ssl_factory or smtplib.SMTP_SSL
83
+ context = ssl.create_default_context()
84
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
85
+
86
+ client: smtplib.SMTP | smtplib.SMTP_SSL | None = None
87
+ delivery_started = False
88
+ result: SendResult | None = None
89
+ try:
90
+ if provider.security == "tls":
91
+ client = smtp_ssl_factory(
92
+ provider.smtp_host,
93
+ provider.smtp_port,
94
+ timeout=timeout,
95
+ context=context,
96
+ )
97
+ elif provider.security == "starttls":
98
+ client = smtp_factory(provider.smtp_host, provider.smtp_port, timeout=timeout)
99
+ client.ehlo()
100
+ client.starttls(context=context)
101
+ client.ehlo()
102
+ else:
103
+ raise MailDeliveryError(
104
+ "provider_configuration", "The provider has an invalid security configuration."
105
+ )
106
+
107
+ client.login(request.auth_username or request.sender_email, request.app_password)
108
+ delivery_started = True
109
+ refused_map = client.send_message(
110
+ build_message(request),
111
+ from_addr=request.sender_email,
112
+ to_addrs=list(request.recipients),
113
+ )
114
+ delivery_started = False
115
+ refused_keys = {normalized_address_key(address) for address in refused_map}
116
+ refused = tuple(
117
+ address
118
+ for address in request.recipients
119
+ if normalized_address_key(address) in refused_keys
120
+ )
121
+ accepted = tuple(
122
+ address
123
+ for address in request.recipients
124
+ if normalized_address_key(address) not in refused_keys
125
+ )
126
+ result = SendResult(accepted=accepted, refused=refused)
127
+ except MailDeliveryError:
128
+ raise
129
+ except smtplib.SMTPAuthenticationError as error:
130
+ if _temporary_response(error.smtp_code):
131
+ raise MailDeliveryError(
132
+ "provider_temporary", "The provider temporarily refused authentication. Try again later."
133
+ ) from None
134
+ raise MailDeliveryError(
135
+ "auth_failed",
136
+ "The provider rejected the email address or app password.",
137
+ ) from None
138
+ except smtplib.SMTPRecipientsRefused as error:
139
+ responses = tuple(error.recipients.values())
140
+ if responses and all(_temporary_response(response[0]) for response in responses):
141
+ raise MailDeliveryError(
142
+ "provider_temporary",
143
+ "The provider temporarily refused the recipients. Try again later.",
144
+ tuple(error.recipients),
145
+ ) from None
146
+ raise MailDeliveryError(
147
+ "recipients_refused",
148
+ "The provider refused every recipient.",
149
+ tuple(error.recipients),
150
+ ) from None
151
+ except smtplib.SMTPSenderRefused as error:
152
+ if _temporary_response(error.smtp_code):
153
+ raise MailDeliveryError(
154
+ "provider_temporary", "The provider temporarily refused the sender. Try again later."
155
+ ) from None
156
+ raise MailDeliveryError(
157
+ "sender_refused", "The provider did not allow this sender address."
158
+ ) from None
159
+ except smtplib.SMTPDataError as error:
160
+ if _temporary_response(error.smtp_code):
161
+ raise MailDeliveryError(
162
+ "provider_temporary", "The provider temporarily refused the message. Try again later."
163
+ ) from None
164
+ raise MailDeliveryError(
165
+ "provider_rejected", "The provider rejected the message content."
166
+ ) from None
167
+ except ssl.SSLCertVerificationError:
168
+ raise MailDeliveryError(
169
+ "tls_failed", "A secure connection to the provider could not be verified."
170
+ ) from None
171
+ except ssl.SSLError:
172
+ if delivery_started:
173
+ raise MailDeliveryError(
174
+ "delivery_unknown",
175
+ "The secure connection ended while sending. Check Sent Mail before trying again.",
176
+ ) from None
177
+ raise MailDeliveryError(
178
+ "tls_failed", "A secure connection to the provider could not be established."
179
+ ) from None
180
+ except (socket.timeout, TimeoutError, smtplib.SMTPServerDisconnected):
181
+ if delivery_started:
182
+ raise MailDeliveryError(
183
+ "delivery_unknown",
184
+ "The connection ended while sending. Check Sent Mail before trying again.",
185
+ ) from None
186
+ raise MailDeliveryError(
187
+ "connection_failed", "The email provider did not respond in time."
188
+ ) from None
189
+ except smtplib.SMTPResponseException as error:
190
+ if _temporary_response(error.smtp_code):
191
+ raise MailDeliveryError(
192
+ "provider_temporary", "The provider is temporarily unavailable. Try again later."
193
+ ) from None
194
+ raise MailDeliveryError(
195
+ "send_failed", "The email provider could not accept this message."
196
+ ) from None
197
+ except smtplib.SMTPException:
198
+ if delivery_started:
199
+ raise MailDeliveryError(
200
+ "delivery_unknown",
201
+ "The connection ended while sending. Check Sent Mail before trying again.",
202
+ ) from None
203
+ raise MailDeliveryError(
204
+ "send_failed", "The email provider could not accept this message."
205
+ ) from None
206
+ except (socket.gaierror, ConnectionError, OSError):
207
+ if delivery_started:
208
+ raise MailDeliveryError(
209
+ "delivery_unknown",
210
+ "The connection ended while sending. Check Sent Mail before trying again.",
211
+ ) from None
212
+ raise MailDeliveryError(
213
+ "connection_failed", "A secure connection to the email provider could not be made."
214
+ ) from None
215
+ finally:
216
+ if client is not None:
217
+ try:
218
+ client.quit()
219
+ except (OSError, smtplib.SMTPException):
220
+ try:
221
+ client.close()
222
+ except OSError:
223
+ pass
224
+
225
+ if result is None:
226
+ raise MailDeliveryError("send_failed", "The message could not be sent.")
227
+ return result
228
+
229
+
230
+ def _temporary_response(code: int | str) -> bool:
231
+ try:
232
+ numeric_code = int(code)
233
+ except (TypeError, ValueError):
234
+ return False
235
+ return 400 <= numeric_code < 500