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 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