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 +3 -0
- emailer/__main__.py +287 -0
- emailer/mailer.py +235 -0
- emailer/models.py +283 -0
- emailer/providers.py +73 -0
- emailer/server.py +463 -0
- emailer/static/__init__.py +1 -0
- emailer/static/app.js +475 -0
- emailer/static/icon.svg +6 -0
- emailer/static/index.html +186 -0
- emailer/static/manifest.webmanifest +19 -0
- emailer/static/styles.css +827 -0
- emailer/static/sw.js +50 -0
- simple_emailer-0.2.0.dist-info/METADATA +153 -0
- simple_emailer-0.2.0.dist-info/RECORD +18 -0
- simple_emailer-0.2.0.dist-info/WHEEL +5 -0
- simple_emailer-0.2.0.dist-info/entry_points.txt +2 -0
- simple_emailer-0.2.0.dist-info/top_level.txt +1 -0
emailer/__init__.py
ADDED
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
|