mailmail 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.
- mailmail/__init__.py +268 -0
- mailmail/__main__.py +11 -0
- mailmail/account.py +31 -0
- mailmail/attachment.py +593 -0
- mailmail/cli.py +358 -0
- mailmail/config.py +229 -0
- mailmail/contacts.py +78 -0
- mailmail/credentials.py +270 -0
- mailmail/errors.py +140 -0
- mailmail/mailer.py +396 -0
- mailmail/message.py +283 -0
- mailmail/provider.py +124 -0
- mailmail/py.typed +0 -0
- mailmail-0.1.0.dist-info/METADATA +487 -0
- mailmail-0.1.0.dist-info/RECORD +18 -0
- mailmail-0.1.0.dist-info/WHEEL +4 -0
- mailmail-0.1.0.dist-info/entry_points.txt +2 -0
- mailmail-0.1.0.dist-info/licenses/LICENSE +21 -0
mailmail/__init__.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Send mail from Python through Gmail or Naver SMTP.
|
|
2
|
+
|
|
3
|
+
from mailmail import send
|
|
4
|
+
|
|
5
|
+
send(
|
|
6
|
+
to = "lead",
|
|
7
|
+
subject = "Weekly report",
|
|
8
|
+
body = "Hi,\\n\\nThis week's report is attached.\\n\\nBest regards,\\n",
|
|
9
|
+
attachments = ["report.xlsx"],
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
`to` takes addresses, address-book aliases, or both, and is required -- nothing
|
|
13
|
+
is ever addressed on your behalf. Anything the provider would reject -- a blocked
|
|
14
|
+
file type, an archive it cannot scan, a message over the server's size limit --
|
|
15
|
+
raises before a connection is opened, so failures land at the call site instead
|
|
16
|
+
of arriving later as a bounce.
|
|
17
|
+
|
|
18
|
+
Sending as a particular mailbox is `account="gmail"`; reusing one connection for
|
|
19
|
+
a batch is `Mailer`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from collections.abc import Iterable, Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from mailmail.account import SmtpAccount
|
|
26
|
+
from mailmail.attachment import Attachment
|
|
27
|
+
from mailmail.config import (
|
|
28
|
+
STARTER_CONFIG,
|
|
29
|
+
Config,
|
|
30
|
+
default_config_path,
|
|
31
|
+
load_config,
|
|
32
|
+
)
|
|
33
|
+
from mailmail.contacts import AddressBook, resolve_recipients
|
|
34
|
+
from mailmail.credentials import (
|
|
35
|
+
PASSWORD_ENV_VAR,
|
|
36
|
+
default_credentials_path,
|
|
37
|
+
delete_password,
|
|
38
|
+
resolve_password,
|
|
39
|
+
store_password,
|
|
40
|
+
)
|
|
41
|
+
from mailmail.errors import (
|
|
42
|
+
AttachmentError,
|
|
43
|
+
AuthenticationFailedError,
|
|
44
|
+
BlockedAttachmentError,
|
|
45
|
+
ConfigError,
|
|
46
|
+
ContactCycleError,
|
|
47
|
+
ContactError,
|
|
48
|
+
CredentialsError,
|
|
49
|
+
EncryptedArchiveError,
|
|
50
|
+
InsecureCredentialsError,
|
|
51
|
+
InvalidMessageError,
|
|
52
|
+
MailmailError,
|
|
53
|
+
MessageTooLargeError,
|
|
54
|
+
MissingPasswordError,
|
|
55
|
+
RecipientRefusedError,
|
|
56
|
+
UnknownAccountError,
|
|
57
|
+
UnknownContactError,
|
|
58
|
+
UnknownProviderError,
|
|
59
|
+
UnscannableArchiveError,
|
|
60
|
+
)
|
|
61
|
+
from mailmail.mailer import Mailer, SendReceipt
|
|
62
|
+
from mailmail.message import Mail, Message, compose_message
|
|
63
|
+
from mailmail.provider import GMAIL, NAVER, MailProvider, SmtpSecurity
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"GMAIL",
|
|
67
|
+
"NAVER",
|
|
68
|
+
"PASSWORD_ENV_VAR",
|
|
69
|
+
"STARTER_CONFIG",
|
|
70
|
+
"AddressBook",
|
|
71
|
+
"Attachment",
|
|
72
|
+
"AttachmentError",
|
|
73
|
+
"AuthenticationFailedError",
|
|
74
|
+
"BlockedAttachmentError",
|
|
75
|
+
"Config",
|
|
76
|
+
"ConfigError",
|
|
77
|
+
"ContactCycleError",
|
|
78
|
+
"ContactError",
|
|
79
|
+
"CredentialsError",
|
|
80
|
+
"EncryptedArchiveError",
|
|
81
|
+
"InsecureCredentialsError",
|
|
82
|
+
"InvalidMessageError",
|
|
83
|
+
"Mail",
|
|
84
|
+
"MailProvider",
|
|
85
|
+
"Mailer",
|
|
86
|
+
"MailmailError",
|
|
87
|
+
"Message",
|
|
88
|
+
"MessageTooLargeError",
|
|
89
|
+
"MissingPasswordError",
|
|
90
|
+
"RecipientRefusedError",
|
|
91
|
+
"SendReceipt",
|
|
92
|
+
"SmtpAccount",
|
|
93
|
+
"SmtpSecurity",
|
|
94
|
+
"UnknownAccountError",
|
|
95
|
+
"UnknownContactError",
|
|
96
|
+
"UnknownProviderError",
|
|
97
|
+
"UnscannableArchiveError",
|
|
98
|
+
"compose_message",
|
|
99
|
+
"default_config_path",
|
|
100
|
+
"default_credentials_path",
|
|
101
|
+
"delete_password",
|
|
102
|
+
"load_config",
|
|
103
|
+
"resolve_password",
|
|
104
|
+
"resolve_recipients",
|
|
105
|
+
"send",
|
|
106
|
+
"send_bulk",
|
|
107
|
+
"store_password",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
__version__ = "0.1.0"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def send(
|
|
114
|
+
*,
|
|
115
|
+
subject: str,
|
|
116
|
+
body: str,
|
|
117
|
+
to: str | Iterable[str],
|
|
118
|
+
html: str | None = None,
|
|
119
|
+
cc: str | Iterable[str] = (),
|
|
120
|
+
bcc: str | Iterable[str] = (),
|
|
121
|
+
attachments: Iterable[Path | str] = (),
|
|
122
|
+
account: str | None = None,
|
|
123
|
+
config: Config | None = None,
|
|
124
|
+
) -> SendReceipt:
|
|
125
|
+
"""Send one message and close the connection.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
to, cc, bcc
|
|
130
|
+
Email addresses, address-book aliases, or a mix. A lone string is one
|
|
131
|
+
recipient. `bcc` addresses are delivered without appearing in any header.
|
|
132
|
+
`to` is required: nothing is ever addressed on your behalf.
|
|
133
|
+
subject, body
|
|
134
|
+
`body` is the plain-text part, sent verbatim -- newlines stay newlines.
|
|
135
|
+
html
|
|
136
|
+
Optional HTML alternative. Clients that render it show this; the rest
|
|
137
|
+
fall back to `body`.
|
|
138
|
+
attachments
|
|
139
|
+
Paths to attach. MIME types are guessed from the suffix.
|
|
140
|
+
account
|
|
141
|
+
Which configured mailbox to send as. Defaults to `default_account`.
|
|
142
|
+
config
|
|
143
|
+
Loaded configuration. Read from disk when omitted.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
SendReceipt
|
|
148
|
+
Which recipients the server took, and which it refused. Check
|
|
149
|
+
`receipt.is_complete` -- a partial refusal is reported here, not raised.
|
|
150
|
+
|
|
151
|
+
Raises
|
|
152
|
+
------
|
|
153
|
+
Everything below descends from `MailmailError` except the last entry, which is
|
|
154
|
+
the standard library's own and is passed through untranslated. A caller who
|
|
155
|
+
must catch every way a send can fail writes
|
|
156
|
+
`except (MailmailError, smtplib.SMTPException, OSError)`.
|
|
157
|
+
|
|
158
|
+
ConfigError, UnknownAccountError
|
|
159
|
+
The configuration is missing or does not define the account.
|
|
160
|
+
UnknownContactError, ContactCycleError
|
|
161
|
+
A recipient is neither an address nor a resolvable alias.
|
|
162
|
+
InvalidMessageError
|
|
163
|
+
Every recipient resolved to nobody, the subject is blank, or an address
|
|
164
|
+
contains a line break. Also a `ValueError`.
|
|
165
|
+
AttachmentError
|
|
166
|
+
An attachment path does not exist or is not a regular file.
|
|
167
|
+
BlockedAttachmentError, UnscannableArchiveError, EncryptedArchiveError
|
|
168
|
+
The provider would reject the attachment -- a blocked file type, or an
|
|
169
|
+
archive that cannot be scanned to the bottom. Nothing was sent.
|
|
170
|
+
MessageTooLargeError
|
|
171
|
+
The message is over the server's limit; nothing was sent.
|
|
172
|
+
MissingPasswordError, InsecureCredentialsError
|
|
173
|
+
No password is stored for the account, or the credentials file is
|
|
174
|
+
readable by someone other than its owner.
|
|
175
|
+
AuthenticationFailedError
|
|
176
|
+
The server rejected the password; the message says what it wants instead.
|
|
177
|
+
RecipientRefusedError
|
|
178
|
+
The server refused every recipient.
|
|
179
|
+
smtplib.SMTPException, OSError
|
|
180
|
+
The session or the network failed -- the server hung up, DNS did not
|
|
181
|
+
answer, the connection timed out, or the server's certificate is not
|
|
182
|
+
trusted (`ssl.SSLCertVerificationError`, an `OSError`). Not a
|
|
183
|
+
`MailmailError`: these are the standard library's own, and wrapping them
|
|
184
|
+
would say less than they already do.
|
|
185
|
+
"""
|
|
186
|
+
config = config if config is not None else load_config()
|
|
187
|
+
smtp_account = config.resolve_account(account)
|
|
188
|
+
mail = Mail(
|
|
189
|
+
subject = subject,
|
|
190
|
+
body = body,
|
|
191
|
+
html = html,
|
|
192
|
+
to = to,
|
|
193
|
+
cc = cc,
|
|
194
|
+
bcc = bcc,
|
|
195
|
+
attachments = attachments,
|
|
196
|
+
)
|
|
197
|
+
message = compose_message(mail, address_book=config.address_book)
|
|
198
|
+
return Mailer(smtp_account).send(message)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def send_bulk(
|
|
202
|
+
mails: Sequence[Mail],
|
|
203
|
+
*,
|
|
204
|
+
account: str | None = None,
|
|
205
|
+
config: Config | None = None,
|
|
206
|
+
) -> list[SendReceipt]:
|
|
207
|
+
"""Send many messages over one connection, and report each one's result.
|
|
208
|
+
|
|
209
|
+
A mail merge in one call: one `Mail` per recipient, personalised however the
|
|
210
|
+
caller built it, all sent as the same account over a single login. The two
|
|
211
|
+
things a plain `with Mailer(...)` loop does not give you are here -- every
|
|
212
|
+
mail is checked before the connection opens, and one refused recipient does
|
|
213
|
+
not sink the rest of the batch.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
mails
|
|
218
|
+
One `Mail` per message, held as a sequence so the returned receipts can
|
|
219
|
+
be paired back with it by position. Recipients may be addresses or
|
|
220
|
+
aliases and attachments are paths, exactly as `send` takes them.
|
|
221
|
+
account
|
|
222
|
+
Which configured mailbox to send as. Defaults to `default_account`.
|
|
223
|
+
config
|
|
224
|
+
Loaded configuration. Read from disk when omitted.
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
list[SendReceipt]
|
|
229
|
+
One receipt per mail, in the order given, so `zip(mails, receipts)` pairs
|
|
230
|
+
each with its outcome. A message the server refused for every recipient
|
|
231
|
+
is a receipt with empty `accepted`, not a missing entry -- check
|
|
232
|
+
`receipt.is_complete` per row.
|
|
233
|
+
|
|
234
|
+
Raises
|
|
235
|
+
------
|
|
236
|
+
These are raised before the connection opens, so nothing is sent -- one bad
|
|
237
|
+
row stops the whole batch:
|
|
238
|
+
|
|
239
|
+
ConfigError, UnknownAccountError
|
|
240
|
+
The configuration is missing or does not define the account.
|
|
241
|
+
UnknownContactError, ContactCycleError, InvalidMessageError
|
|
242
|
+
A row's recipient is not resolvable, or a row has no recipient or a
|
|
243
|
+
blank subject.
|
|
244
|
+
BlockedAttachmentError, UnscannableArchiveError, EncryptedArchiveError
|
|
245
|
+
A row's attachment would be rejected by the provider.
|
|
246
|
+
MessageTooLargeError
|
|
247
|
+
A row's attachments already exceed the size limit.
|
|
248
|
+
|
|
249
|
+
These can land mid-batch, after earlier messages have gone out -- the
|
|
250
|
+
exception propagates, the receipts collected so far are lost, and what was
|
|
251
|
+
already sent cannot be unsent:
|
|
252
|
+
|
|
253
|
+
MessageTooLargeError
|
|
254
|
+
A row crosses the size limit only once fully assembled; the up-front
|
|
255
|
+
screen weighs attachments alone, not the finished MIME.
|
|
256
|
+
smtplib.SMTPException, OSError
|
|
257
|
+
The session or the network dropped partway through the batch.
|
|
258
|
+
|
|
259
|
+
What the server merely refuses per recipient stays in the receipts and never
|
|
260
|
+
raises.
|
|
261
|
+
"""
|
|
262
|
+
config = config if config is not None else load_config()
|
|
263
|
+
smtp_account = config.resolve_account(account)
|
|
264
|
+
address_book = config.address_book
|
|
265
|
+
# Compose every message first, so an unresolvable alias or blank subject in
|
|
266
|
+
# any row fails here, before send_many opens a connection.
|
|
267
|
+
messages = [compose_message(mail, address_book=address_book) for mail in mails]
|
|
268
|
+
return Mailer(smtp_account).send_many(messages)
|
mailmail/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Entry point for `python -m mailmail`.
|
|
2
|
+
|
|
3
|
+
The console script `mailmail` calls `main` directly; this lets the package be
|
|
4
|
+
run without the script being on `PATH`. Running `main` here is the one place the
|
|
5
|
+
package acts on import, and only when invoked as `__main__` -- `import mailmail`
|
|
6
|
+
never reaches it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from mailmail.cli import main
|
|
10
|
+
|
|
11
|
+
raise SystemExit(main())
|
mailmail/account.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The mailbox mail is sent as.
|
|
2
|
+
|
|
3
|
+
An account is the pairing of an address with the service that carries it. Where
|
|
4
|
+
its password lives is a separate concern -- see `credentials`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from mailmail.provider import MailProvider
|
|
10
|
+
|
|
11
|
+
__all__ = ["SmtpAccount"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
15
|
+
class SmtpAccount:
|
|
16
|
+
"""A mailbox to send from: the address, and the service that carries it.
|
|
17
|
+
|
|
18
|
+
Attributes
|
|
19
|
+
----------
|
|
20
|
+
name
|
|
21
|
+
The key this account has in the configuration (`"naver"`, `"gmail"`).
|
|
22
|
+
Distinct from `provider.name`: two accounts may share one provider.
|
|
23
|
+
username
|
|
24
|
+
The full email address, used both to authenticate and as `From`.
|
|
25
|
+
provider
|
|
26
|
+
Where to connect, and what the service will carry.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
username: str
|
|
31
|
+
provider: MailProvider
|