MailToolsBox 1.0.0__tar.gz → 1.0.1__tar.gz

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,6 @@
1
+ from MailToolsBox.mailSender import EmailSender, SendAgent
2
+ from MailToolsBox.imapClient import ImapAgent
3
+
4
+ __version__ = "1.0.1"
5
+
6
+ __all__ = ["EmailSender", "SendAgent", "ImapAgent", "__version__"]
@@ -2,6 +2,7 @@ import imaplib
2
2
  import email
3
3
  import json
4
4
  import datetime
5
+ import os
5
6
  from typing import List
6
7
 
7
8
 
@@ -12,11 +13,46 @@ class ImapAgent:
12
13
  self.server_address = server_address
13
14
  self.mail = None
14
15
 
16
+ @classmethod
17
+ def from_env(cls) -> "ImapAgent":
18
+ """Create :class:`ImapAgent` from environment variables.
19
+
20
+ Required variables are ``IMAP_EMAIL``, ``IMAP_PASSWORD`` and
21
+ ``IMAP_SERVER``.
22
+ """
23
+ email_account = os.environ["IMAP_EMAIL"]
24
+ password = os.environ["IMAP_PASSWORD"]
25
+ server = os.environ["IMAP_SERVER"]
26
+ return cls(email_account, password, server)
27
+
28
+ def __enter__(self) -> "ImapAgent":
29
+ self.login_account()
30
+ return self
31
+
32
+ def __exit__(self, exc_type, exc, tb) -> None:
33
+ self.logout_account()
34
+
15
35
  def login_account(self):
16
36
  self.mail = imaplib.IMAP4_SSL(self.server_address)
17
37
  self.mail.login(self.email_account, self.password)
18
38
 
39
+ def logout_account(self):
40
+ if self.mail is not None:
41
+ try:
42
+ self.mail.logout()
43
+ finally:
44
+ self.mail = None
45
+
19
46
  def download_mail_text(self, path='', mailbox='INBOX'):
47
+ """Download all emails in ``mailbox`` and write them as plain text.
48
+
49
+ This method now ensures that a connection to the IMAP server has been
50
+ established before attempting to access any mailbox. If no connection
51
+ exists, :py:meth:`login_account` is invoked to create one.
52
+ """
53
+ if self.mail is None:
54
+ self.login_account()
55
+
20
56
  with open(f'{path}email.txt', 'w') as f:
21
57
  self.mail.select(mailbox)
22
58
  _, data = self.mail.uid('search', None, 'ALL')
@@ -48,15 +84,14 @@ class ImapAgent:
48
84
  f.write(
49
85
  f"From: {email_from}\nTo: {email_to}\nDate: {local_message_date}\nSubject: {subject}\n\nBody:\n\n{body.decode('utf-8')}\n\n")
50
86
  self.mail.close()
87
+ self.logout_account()
51
88
 
52
- import json
53
89
 
54
90
  def download_mail_json(self, lookup: str = 'ALL', save: bool = False, path: str = '', file_name: str = 'mail.json') -> str:
55
91
  save_json = save
56
- self.login_account()
57
92
 
58
- with imaplib.IMAP4_SSL(self.imap_server) as mail:
59
- mail.login(self.username, self.password)
93
+ with imaplib.IMAP4_SSL(self.server_address) as mail:
94
+ mail.login(self.email_account, self.password)
60
95
  mail.select("inbox")
61
96
  result, data = mail.uid('search', None, lookup) # (ALL/UNSEEN)
62
97
  uids = data[0].split()
@@ -117,3 +152,5 @@ class ImapAgent:
117
152
  file_name = f"{path}email_{i}.msg"
118
153
  with open(file_name, 'w') as f:
119
154
  f.write(email_message.as_string())
155
+ self.mail.close()
156
+ self.logout_account()
@@ -1,18 +1,20 @@
1
+ import os
1
2
  import smtplib
3
+ import aiosmtplib
2
4
  from email.mime.text import MIMEText
3
5
  from email.mime.multipart import MIMEMultipart
4
6
  from email.mime.application import MIMEApplication
5
7
  from email.utils import COMMASPACE, formatdate
6
- from jinja2 import Template, Environment, FileSystemLoader, select_autoescape
8
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
7
9
  from typing import List, Optional, Iterable
8
10
  from pathlib import Path
9
11
  import logging
10
12
  from ssl import create_default_context
11
13
  from email_validator import validate_email, EmailNotValidError
12
14
 
13
- # Set up logging
15
+ # Set up logging without configuring the root logger
14
16
  logger = logging.getLogger(__name__)
15
- logging.basicConfig(level=logging.INFO)
17
+ logger.addHandler(logging.NullHandler())
16
18
 
17
19
 
18
20
  class EmailSender:
@@ -25,7 +27,7 @@ class EmailSender:
25
27
  user_email_password: str,
26
28
  port: int = 587,
27
29
  timeout: int = 10,
28
- validate_emails: bool = True
30
+ validate_emails: bool = True,
29
31
  ) -> None:
30
32
  self.user_email = self._validate_email(user_email) if validate_emails else user_email
31
33
  self.user_email_password = user_email_password
@@ -33,11 +35,31 @@ class EmailSender:
33
35
  self.port = port
34
36
  self.timeout = timeout
35
37
  self.validate_emails = validate_emails
38
+ template_dir = Path(__file__).resolve().parent / "templates"
36
39
  self.template_env = Environment(
37
- loader=FileSystemLoader('templates'),
40
+ loader=FileSystemLoader(str(template_dir)),
38
41
  autoescape=select_autoescape(['html', 'xml'])
39
42
  )
40
43
  self.ssl_context = create_default_context()
44
+ logger.info("EmailSender initialised for %s", self.user_email)
45
+
46
+ @classmethod
47
+ def from_env(cls) -> "EmailSender":
48
+ """Create :class:`EmailSender` from environment variables.
49
+
50
+ Required variables are ``EMAIL``, ``SMTP_SERVER`` and ``EMAIL_PASSWORD``.
51
+ ``SMTP_PORT`` is optional and defaults to ``587``.
52
+ """
53
+ user_email = os.environ["EMAIL"]
54
+ server = os.environ["SMTP_SERVER"]
55
+ password = os.environ["EMAIL_PASSWORD"]
56
+ port = int(os.getenv("SMTP_PORT", 587))
57
+ return cls(
58
+ user_email=user_email,
59
+ server_smtp_address=server,
60
+ user_email_password=password,
61
+ port=port,
62
+ )
41
63
 
42
64
  def _validate_email(self, email_address: str) -> str:
43
65
  """Validate and normalize email address using email-validator."""
@@ -52,8 +74,7 @@ class EmailSender:
52
74
  self,
53
75
  subject: str,
54
76
  recipients: Iterable[str],
55
- cc: Optional[Iterable[str]] = None,
56
- bcc: Optional[Iterable[str]] = None
77
+ cc: Optional[Iterable[str]] = None
57
78
  ) -> MIMEMultipart:
58
79
  """Create MIME message with proper headers."""
59
80
  msg = MIMEMultipart()
@@ -70,10 +91,6 @@ class EmailSender:
70
91
  validated_cc = [self._validate_email(c) for c in cc] if self.validate_emails else cc
71
92
  msg['Cc'] = COMMASPACE.join(validated_cc)
72
93
 
73
- if bcc:
74
- validated_bcc = [self._validate_email(b) for b in bcc] if self.validate_emails else bcc
75
- msg['Bcc'] = COMMASPACE.join(validated_bcc)
76
-
77
94
  return msg
78
95
 
79
96
  def _add_attachments(self, msg: MIMEMultipart, attachments: Iterable[str]) -> None:
@@ -105,7 +122,12 @@ class EmailSender:
105
122
  html: bool = False
106
123
  ) -> None:
107
124
  """Synchronous email sending with improved error handling."""
108
- msg = self._create_base_message(subject, recipients, cc, bcc)
125
+ logger.info("Sending email to %s", ", ".join(recipients))
126
+ # Build the MIME message without exposing BCC recipients
127
+ msg = self._create_base_message(subject, recipients, cc)
128
+ validated_bcc = None
129
+ if bcc:
130
+ validated_bcc = [self._validate_email(b) for b in bcc] if self.validate_emails else list(bcc)
109
131
  msg.attach(MIMEText(message_body, 'html' if html else 'plain'))
110
132
 
111
133
  if attachments:
@@ -116,7 +138,12 @@ class EmailSender:
116
138
  if use_tls:
117
139
  server.starttls(context=self.ssl_context)
118
140
  server.login(self.user_email, self.user_email_password)
119
- server.send_message(msg)
141
+ all_recipients = list(recipients)
142
+ if cc:
143
+ all_recipients.extend(cc)
144
+ if validated_bcc:
145
+ all_recipients.extend(validated_bcc)
146
+ server.send_message(msg, to_addrs=all_recipients)
120
147
  except smtplib.SMTPException as e:
121
148
  logger.error(f"SMTP error occurred: {str(e)}")
122
149
  raise
@@ -124,6 +151,57 @@ class EmailSender:
124
151
  logger.error(f"Unexpected error: {str(e)}")
125
152
  raise
126
153
 
154
+ def send_bulk(
155
+ self,
156
+ recipients: Iterable[str],
157
+ subject: str,
158
+ message_body: str,
159
+ **kwargs,
160
+ ) -> None:
161
+ """Send the same message to many recipients individually."""
162
+ for recipient in recipients:
163
+ logger.info("Sending bulk email to %s", recipient)
164
+ self.send([recipient], subject, message_body, **kwargs)
165
+
166
+ async def send_async(
167
+ self,
168
+ recipients: Iterable[str],
169
+ subject: str,
170
+ message_body: str,
171
+ cc: Optional[Iterable[str]] = None,
172
+ bcc: Optional[Iterable[str]] = None,
173
+ attachments: Optional[Iterable[str]] = None,
174
+ use_tls: bool = True,
175
+ html: bool = False
176
+ ) -> None:
177
+ """Asynchronous email sending using aiosmtplib."""
178
+ msg = self._create_base_message(subject, recipients, cc)
179
+ validated_bcc = None
180
+ if bcc:
181
+ validated_bcc = [self._validate_email(b) for b in bcc] if self.validate_emails else list(bcc)
182
+ msg.attach(MIMEText(message_body, 'html' if html else 'plain'))
183
+
184
+ if attachments:
185
+ self._add_attachments(msg, attachments)
186
+
187
+ try:
188
+ async with aiosmtplib.SMTP(hostname=self.server_smtp_address, port=self.port, timeout=self.timeout) as server:
189
+ if use_tls:
190
+ await server.starttls(context=self.ssl_context)
191
+ await server.login(self.user_email, self.user_email_password)
192
+ all_recipients = list(recipients)
193
+ if cc:
194
+ all_recipients.extend(cc)
195
+ if validated_bcc:
196
+ all_recipients.extend(validated_bcc)
197
+ await server.send_message(msg, to_addrs=all_recipients)
198
+ except aiosmtplib.errors.SMTPException as e:
199
+ logger.error(f"SMTP error occurred: {str(e)}")
200
+ raise
201
+ except Exception as e:
202
+ logger.error(f"Unexpected error: {str(e)}")
203
+ raise
204
+
127
205
  def send_template(
128
206
  self,
129
207
  recipient: str,
@@ -135,6 +213,7 @@ class EmailSender:
135
213
  use_tls: bool = True
136
214
  ) -> None:
137
215
  """Send email using Jinja2 template with autoescaping."""
216
+ logger.info("Sending templated email to %s using %s", recipient, template_name)
138
217
  template = self.template_env.get_template(template_name)
139
218
  html_content = template.render(**context)
140
219
  self.send([recipient], subject, html_content, cc=cc, attachments=attachments, use_tls=use_tls, html=True)
@@ -153,8 +232,7 @@ class SendAgent(EmailSender):
153
232
  cc: Optional[List[str]] = None,
154
233
  bcc: Optional[List[str]] = None,
155
234
  attachments: Optional[List[str]] = None,
156
- tls: bool = True,
157
- server_quit: bool = False
235
+ tls: bool = True
158
236
  ) -> None:
159
237
  logger.warning("SendAgent is deprecated, use EmailSender instead")
160
238
 
@@ -169,8 +247,6 @@ class SendAgent(EmailSender):
169
247
  use_tls=tls
170
248
  )
171
249
 
172
- if server_quit:
173
- self.server.quit()
174
250
 
175
251
  def send_mail_with_template(
176
252
  self,
@@ -180,8 +256,7 @@ class SendAgent(EmailSender):
180
256
  template_vars: dict,
181
257
  cc: Optional[List[str]] = None,
182
258
  attachments: Optional[List[str]] = None,
183
- tls: bool = True,
184
- server_quit: bool = False
259
+ tls: bool = True
185
260
  ) -> None:
186
261
  logger.warning("send_mail_with_template is deprecated, use send_template instead")
187
262
 
@@ -195,24 +270,3 @@ class SendAgent(EmailSender):
195
270
  use_tls=tls
196
271
  )
197
272
 
198
- if server_quit:
199
- self.server.quit()
200
-
201
-
202
- # Example usage
203
- if __name__ == "__main__":
204
- sender = EmailSender(
205
- user_email="your@email.com",
206
- server_smtp_address="smtp.example.com",
207
- user_email_password="password",
208
- port=587
209
- )
210
-
211
- # Sync send
212
- sender.send(
213
- recipients=["gh.rambod@gmail.com"],
214
- subject="Modern Email",
215
- message_body="<h1>HTML Content</h1>",
216
- html=True,
217
- attachments=["important.pdf"]
218
- )
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: MailToolsBox
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: A modern and efficient Python library for sending emails with SMTP, Jinja2 templates, and attachments.
5
5
  Home-page: https://github.com/rambod/MailToolsBox
6
6
  Download-URL: https://github.com/rambod/MailToolsBox/archive/refs/tags/v1.0.0.tar.gz
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
24
24
  License-File: LICENSE.txt
25
25
  Requires-Dist: Jinja2>=3.0.2
26
26
  Requires-Dist: email-validator>=2.0.0
27
+ Requires-Dist: aiosmtplib>=2.0.0
27
28
  Dynamic: author
28
29
  Dynamic: author-email
29
30
  Dynamic: classifier
@@ -33,6 +34,7 @@ Dynamic: download-url
33
34
  Dynamic: home-page
34
35
  Dynamic: keywords
35
36
  Dynamic: license
37
+ Dynamic: license-file
36
38
  Dynamic: requires-dist
37
39
  Dynamic: requires-python
38
40
  Dynamic: summary
@@ -49,8 +51,11 @@ MailToolsBox is a modern, feature-rich Python package designed for sending and m
49
51
  - **Attachment handling**
50
52
  - **Template-based email rendering using Jinja2**
51
53
  - **Secure email transactions with TLS/SSL**
54
+ - **Asynchronous email sending via `send_async`**
52
55
  - **Email address validation**
53
56
  - **Logging for debugging and monitoring**
57
+ - **Bulk email sending with `send_bulk`**
58
+ - **Convenient configuration via environment variables using `from_env`**
54
59
  - **Backward compatibility with `SendAgent`**
55
60
 
56
61
  ---
@@ -157,7 +162,40 @@ sender.send(
157
162
 
158
163
  ---
159
164
 
160
- ### 5. **Backward Compatibility with `SendAgent`**
165
+ ### 5. **Asynchronous Email Sending**
166
+
167
+ Send emails without blocking using `send_async` and `asyncio`:
168
+
169
+ ```python
170
+ import asyncio
171
+
172
+ async def main():
173
+ await sender.send_async(
174
+ recipients=["recipient@example.com"],
175
+ subject="Async Example",
176
+ message_body="This email was sent asynchronously!"
177
+ )
178
+
179
+ asyncio.run(main())
180
+ ```
181
+
182
+ ---
183
+
184
+ ### 6. **Bulk Email Sending**
185
+
186
+ Send the same message to many recipients individually, ensuring privacy:
187
+
188
+ ```python
189
+ sender.send_bulk(
190
+ recipients=["user1@example.com", "user2@example.com"],
191
+ subject="Announcement",
192
+ message_body="This email is sent separately to each recipient!",
193
+ )
194
+ ```
195
+
196
+ ---
197
+
198
+ ### 7. **Backward Compatibility with `SendAgent`**
161
199
 
162
200
  For those migrating from earlier versions, `SendAgent` ensures seamless compatibility:
163
201
 
@@ -174,10 +212,29 @@ legacy_sender = SendAgent(
174
212
  legacy_sender.send_mail(
175
213
  recipient_email=["recipient@example.com"],
176
214
  subject="Legacy Compatibility Test",
177
- message_body="Testing backward compatibility."
215
+ message_body="Testing backward compatibility."
178
216
  )
179
217
  ```
180
218
 
219
+ ---
220
+
221
+ ### 8. **Retrieving Emails via IMAP**
222
+
223
+ `ImapAgent` can be used to fetch messages from an IMAP mailbox. It supports
224
+ loading configuration from environment variables with `from_env` and can be
225
+ used as a context manager to ensure connections are closed properly.
226
+
227
+ ```python
228
+ from MailToolsBox import ImapAgent
229
+
230
+ # Using environment variables IMAP_EMAIL, IMAP_PASSWORD and IMAP_SERVER
231
+ agent = ImapAgent.from_env()
232
+
233
+ with agent as imap:
234
+ imap.download_mail_text(path="/tmp/")
235
+ ```
236
+
237
+
181
238
  ---
182
239
 
183
240
  ## Configuration & Security Best Practices
@@ -189,14 +246,7 @@ legacy_sender.send_mail(
189
246
  Example using environment variables:
190
247
 
191
248
  ```python
192
- import os
193
-
194
- sender = EmailSender(
195
- user_email=os.getenv("EMAIL"),
196
- server_smtp_address=os.getenv("SMTP_SERVER"),
197
- user_email_password=os.getenv("EMAIL_PASSWORD"),
198
- port=int(os.getenv("SMTP_PORT", 587))
199
- )
249
+ sender = EmailSender.from_env()
200
250
  ```
201
251
 
202
252
  ---
@@ -234,7 +284,7 @@ MailToolsBox is an open-source project. Contributions are welcome! To contribute
234
284
  3. Implement changes and write tests.
235
285
  4. Submit a pull request for review.
236
286
 
237
- For discussions, visit **[rambod.net](https://www.rambod.net)**.
287
+ For discussions, visit **[rambod.net](https://www.rambod.net)**. **[mailtoolsbox](https://rambod.net/portfolio/mailtoolsbox/)**
238
288
 
239
289
  ---
240
290
 
@@ -8,4 +8,7 @@ MailToolsBox.egg-info/PKG-INFO
8
8
  MailToolsBox.egg-info/SOURCES.txt
9
9
  MailToolsBox.egg-info/dependency_links.txt
10
10
  MailToolsBox.egg-info/requires.txt
11
- MailToolsBox.egg-info/top_level.txt
11
+ MailToolsBox.egg-info/top_level.txt
12
+ tests/__init__.py
13
+ tests/test_imap_agent.py
14
+ tests/test_mail_sender.py
@@ -1,2 +1,3 @@
1
1
  Jinja2>=3.0.2
2
2
  email-validator>=2.0.0
3
+ aiosmtplib>=2.0.0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: MailToolsBox
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: A modern and efficient Python library for sending emails with SMTP, Jinja2 templates, and attachments.
5
5
  Home-page: https://github.com/rambod/MailToolsBox
6
6
  Download-URL: https://github.com/rambod/MailToolsBox/archive/refs/tags/v1.0.0.tar.gz
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
24
24
  License-File: LICENSE.txt
25
25
  Requires-Dist: Jinja2>=3.0.2
26
26
  Requires-Dist: email-validator>=2.0.0
27
+ Requires-Dist: aiosmtplib>=2.0.0
27
28
  Dynamic: author
28
29
  Dynamic: author-email
29
30
  Dynamic: classifier
@@ -33,6 +34,7 @@ Dynamic: download-url
33
34
  Dynamic: home-page
34
35
  Dynamic: keywords
35
36
  Dynamic: license
37
+ Dynamic: license-file
36
38
  Dynamic: requires-dist
37
39
  Dynamic: requires-python
38
40
  Dynamic: summary
@@ -49,8 +51,11 @@ MailToolsBox is a modern, feature-rich Python package designed for sending and m
49
51
  - **Attachment handling**
50
52
  - **Template-based email rendering using Jinja2**
51
53
  - **Secure email transactions with TLS/SSL**
54
+ - **Asynchronous email sending via `send_async`**
52
55
  - **Email address validation**
53
56
  - **Logging for debugging and monitoring**
57
+ - **Bulk email sending with `send_bulk`**
58
+ - **Convenient configuration via environment variables using `from_env`**
54
59
  - **Backward compatibility with `SendAgent`**
55
60
 
56
61
  ---
@@ -157,7 +162,40 @@ sender.send(
157
162
 
158
163
  ---
159
164
 
160
- ### 5. **Backward Compatibility with `SendAgent`**
165
+ ### 5. **Asynchronous Email Sending**
166
+
167
+ Send emails without blocking using `send_async` and `asyncio`:
168
+
169
+ ```python
170
+ import asyncio
171
+
172
+ async def main():
173
+ await sender.send_async(
174
+ recipients=["recipient@example.com"],
175
+ subject="Async Example",
176
+ message_body="This email was sent asynchronously!"
177
+ )
178
+
179
+ asyncio.run(main())
180
+ ```
181
+
182
+ ---
183
+
184
+ ### 6. **Bulk Email Sending**
185
+
186
+ Send the same message to many recipients individually, ensuring privacy:
187
+
188
+ ```python
189
+ sender.send_bulk(
190
+ recipients=["user1@example.com", "user2@example.com"],
191
+ subject="Announcement",
192
+ message_body="This email is sent separately to each recipient!",
193
+ )
194
+ ```
195
+
196
+ ---
197
+
198
+ ### 7. **Backward Compatibility with `SendAgent`**
161
199
 
162
200
  For those migrating from earlier versions, `SendAgent` ensures seamless compatibility:
163
201
 
@@ -174,10 +212,29 @@ legacy_sender = SendAgent(
174
212
  legacy_sender.send_mail(
175
213
  recipient_email=["recipient@example.com"],
176
214
  subject="Legacy Compatibility Test",
177
- message_body="Testing backward compatibility."
215
+ message_body="Testing backward compatibility."
178
216
  )
179
217
  ```
180
218
 
219
+ ---
220
+
221
+ ### 8. **Retrieving Emails via IMAP**
222
+
223
+ `ImapAgent` can be used to fetch messages from an IMAP mailbox. It supports
224
+ loading configuration from environment variables with `from_env` and can be
225
+ used as a context manager to ensure connections are closed properly.
226
+
227
+ ```python
228
+ from MailToolsBox import ImapAgent
229
+
230
+ # Using environment variables IMAP_EMAIL, IMAP_PASSWORD and IMAP_SERVER
231
+ agent = ImapAgent.from_env()
232
+
233
+ with agent as imap:
234
+ imap.download_mail_text(path="/tmp/")
235
+ ```
236
+
237
+
181
238
  ---
182
239
 
183
240
  ## Configuration & Security Best Practices
@@ -189,14 +246,7 @@ legacy_sender.send_mail(
189
246
  Example using environment variables:
190
247
 
191
248
  ```python
192
- import os
193
-
194
- sender = EmailSender(
195
- user_email=os.getenv("EMAIL"),
196
- server_smtp_address=os.getenv("SMTP_SERVER"),
197
- user_email_password=os.getenv("EMAIL_PASSWORD"),
198
- port=int(os.getenv("SMTP_PORT", 587))
199
- )
249
+ sender = EmailSender.from_env()
200
250
  ```
201
251
 
202
252
  ---
@@ -234,7 +284,7 @@ MailToolsBox is an open-source project. Contributions are welcome! To contribute
234
284
  3. Implement changes and write tests.
235
285
  4. Submit a pull request for review.
236
286
 
237
- For discussions, visit **[rambod.net](https://www.rambod.net)**.
287
+ For discussions, visit **[rambod.net](https://www.rambod.net)**. **[mailtoolsbox](https://rambod.net/portfolio/mailtoolsbox/)**
238
288
 
239
289
  ---
240
290
 
@@ -10,8 +10,11 @@ MailToolsBox is a modern, feature-rich Python package designed for sending and m
10
10
  - **Attachment handling**
11
11
  - **Template-based email rendering using Jinja2**
12
12
  - **Secure email transactions with TLS/SSL**
13
+ - **Asynchronous email sending via `send_async`**
13
14
  - **Email address validation**
14
15
  - **Logging for debugging and monitoring**
16
+ - **Bulk email sending with `send_bulk`**
17
+ - **Convenient configuration via environment variables using `from_env`**
15
18
  - **Backward compatibility with `SendAgent`**
16
19
 
17
20
  ---
@@ -118,7 +121,40 @@ sender.send(
118
121
 
119
122
  ---
120
123
 
121
- ### 5. **Backward Compatibility with `SendAgent`**
124
+ ### 5. **Asynchronous Email Sending**
125
+
126
+ Send emails without blocking using `send_async` and `asyncio`:
127
+
128
+ ```python
129
+ import asyncio
130
+
131
+ async def main():
132
+ await sender.send_async(
133
+ recipients=["recipient@example.com"],
134
+ subject="Async Example",
135
+ message_body="This email was sent asynchronously!"
136
+ )
137
+
138
+ asyncio.run(main())
139
+ ```
140
+
141
+ ---
142
+
143
+ ### 6. **Bulk Email Sending**
144
+
145
+ Send the same message to many recipients individually, ensuring privacy:
146
+
147
+ ```python
148
+ sender.send_bulk(
149
+ recipients=["user1@example.com", "user2@example.com"],
150
+ subject="Announcement",
151
+ message_body="This email is sent separately to each recipient!",
152
+ )
153
+ ```
154
+
155
+ ---
156
+
157
+ ### 7. **Backward Compatibility with `SendAgent`**
122
158
 
123
159
  For those migrating from earlier versions, `SendAgent` ensures seamless compatibility:
124
160
 
@@ -135,10 +171,29 @@ legacy_sender = SendAgent(
135
171
  legacy_sender.send_mail(
136
172
  recipient_email=["recipient@example.com"],
137
173
  subject="Legacy Compatibility Test",
138
- message_body="Testing backward compatibility."
174
+ message_body="Testing backward compatibility."
139
175
  )
140
176
  ```
141
177
 
178
+ ---
179
+
180
+ ### 8. **Retrieving Emails via IMAP**
181
+
182
+ `ImapAgent` can be used to fetch messages from an IMAP mailbox. It supports
183
+ loading configuration from environment variables with `from_env` and can be
184
+ used as a context manager to ensure connections are closed properly.
185
+
186
+ ```python
187
+ from MailToolsBox import ImapAgent
188
+
189
+ # Using environment variables IMAP_EMAIL, IMAP_PASSWORD and IMAP_SERVER
190
+ agent = ImapAgent.from_env()
191
+
192
+ with agent as imap:
193
+ imap.download_mail_text(path="/tmp/")
194
+ ```
195
+
196
+
142
197
  ---
143
198
 
144
199
  ## Configuration & Security Best Practices
@@ -150,14 +205,7 @@ legacy_sender.send_mail(
150
205
  Example using environment variables:
151
206
 
152
207
  ```python
153
- import os
154
-
155
- sender = EmailSender(
156
- user_email=os.getenv("EMAIL"),
157
- server_smtp_address=os.getenv("SMTP_SERVER"),
158
- user_email_password=os.getenv("EMAIL_PASSWORD"),
159
- port=int(os.getenv("SMTP_PORT", 587))
160
- )
208
+ sender = EmailSender.from_env()
161
209
  ```
162
210
 
163
211
  ---
@@ -195,7 +243,7 @@ MailToolsBox is an open-source project. Contributions are welcome! To contribute
195
243
  3. Implement changes and write tests.
196
244
  4. Submit a pull request for review.
197
245
 
198
- For discussions, visit **[rambod.net](https://www.rambod.net)**.
246
+ For discussions, visit **[rambod.net](https://www.rambod.net)**. **[mailtoolsbox](https://rambod.net/portfolio/mailtoolsbox/)**
199
247
 
200
248
  ---
201
249
 
@@ -3,7 +3,7 @@ from setuptools import setup, find_packages
3
3
  setup(
4
4
  name='MailToolsBox',
5
5
  packages=find_packages(),
6
- version='1.0.0', # Increased version for major revamp
6
+ version='1.0.1', # Increased version for major revamp
7
7
  license='MIT',
8
8
  description='A modern and efficient Python library for sending emails with SMTP, Jinja2 templates, and attachments.',
9
9
  long_description=open('README.md', encoding='utf-8').read(),
@@ -15,7 +15,8 @@ setup(
15
15
  keywords=['Mail', 'SMTP', 'email', 'tools', 'attachments', 'Jinja2', 'Python', 'email-validation'],
16
16
  install_requires=[
17
17
  "Jinja2>=3.0.2",
18
- "email-validator>=2.0.0"
18
+ "email-validator>=2.0.0",
19
+ "aiosmtplib>=2.0.0"
19
20
  ],
20
21
  classifiers=[
21
22
  'Development Status :: 5 - Production/Stable',
File without changes
@@ -0,0 +1,146 @@
1
+ import json
2
+ import email
3
+ from email.mime.text import MIMEText
4
+ from unittest import mock
5
+ import os
6
+ import types
7
+ import sys
8
+
9
+ import pytest
10
+
11
+ # Provide dummy aiosmtplib for indirect imports via mailSender
12
+ sys.modules.setdefault(
13
+ "aiosmtplib",
14
+ types.SimpleNamespace(SMTP=None, errors=types.SimpleNamespace(SMTPException=Exception)),
15
+ )
16
+ # Minimal stub for jinja2 used by EmailSender imports
17
+ sys.modules.setdefault(
18
+ "jinja2",
19
+ types.SimpleNamespace(
20
+ Environment=lambda **kwargs: types.SimpleNamespace(get_template=lambda name: types.SimpleNamespace(render=lambda **kw: "")),
21
+ FileSystemLoader=lambda *args, **kwargs: None,
22
+ select_autoescape=lambda x: None,
23
+ ),
24
+ )
25
+ sys.modules.setdefault(
26
+ "email_validator",
27
+ types.SimpleNamespace(
28
+ validate_email=lambda email, check_deliverability=False: types.SimpleNamespace(normalized=email),
29
+ EmailNotValidError=Exception,
30
+ ),
31
+ )
32
+
33
+ from MailToolsBox.imapClient import ImapAgent
34
+
35
+
36
+ class DummyMail:
37
+ def __init__(self, message_bytes):
38
+ self.message_bytes = message_bytes
39
+ self.closed = False
40
+ self.logged_in = False
41
+ self.selected = None
42
+ def login(self, user, password):
43
+ self.logged_in = True
44
+ def select(self, mailbox):
45
+ self.selected = mailbox
46
+ return ('OK', [b''])
47
+ def uid(self, command, *args):
48
+ if command == 'search':
49
+ return ('OK', [b'1'])
50
+ elif command == 'fetch':
51
+ return ('OK', [(None, self.message_bytes)])
52
+ def close(self):
53
+ self.closed = True
54
+ def logout(self):
55
+ self.closed = True
56
+ def __enter__(self):
57
+ return self
58
+ def __exit__(self, exc_type, exc, tb):
59
+ pass
60
+
61
+
62
+ def sample_message_bytes():
63
+ msg = MIMEText('body text')
64
+ msg['From'] = 'from@example.com'
65
+ msg['To'] = 'to@example.com'
66
+ msg['Subject'] = 'Test'
67
+ msg['Date'] = 'Wed, 20 Sep 2023 12:00:00 -0000'
68
+ return msg.as_bytes()
69
+
70
+
71
+ def test_login_account(monkeypatch):
72
+ dummy = DummyMail(sample_message_bytes())
73
+ monkeypatch.setattr('imaplib.IMAP4_SSL', lambda addr: dummy)
74
+ agent = ImapAgent('user', 'pass', 'imap.example.com')
75
+ agent.login_account()
76
+ assert agent.mail is dummy
77
+ assert dummy.logged_in
78
+
79
+
80
+ def test_download_mail_text(tmp_path, monkeypatch):
81
+ message_bytes = sample_message_bytes()
82
+ dummy = DummyMail(message_bytes)
83
+ agent = ImapAgent('user', 'pass', 'imap.example.com')
84
+ agent.mail = dummy
85
+
86
+ agent.download_mail_text(path=str(tmp_path) + os.sep)
87
+
88
+ file_path = tmp_path / 'email.txt'
89
+ assert file_path.exists()
90
+ content = file_path.read_text()
91
+ assert 'body text' in content
92
+ assert dummy.closed
93
+
94
+
95
+ def test_download_mail_json(tmp_path, monkeypatch):
96
+ message_bytes = sample_message_bytes()
97
+ dummy = DummyMail(message_bytes)
98
+ monkeypatch.setattr(ImapAgent, 'login_account', lambda self: None)
99
+ monkeypatch.setattr('imaplib.IMAP4_SSL', lambda addr: dummy)
100
+
101
+ agent = ImapAgent('user', 'pass', 'imap.example.com')
102
+ result = agent.download_mail_json(save=True, path=str(tmp_path) + os.sep)
103
+
104
+ data = json.loads(result)
105
+ assert isinstance(data, list)
106
+ assert data[0]['subject'] == 'Test'
107
+ file_path = tmp_path / 'mail.json'
108
+ assert file_path.exists()
109
+ assert dummy.closed
110
+
111
+
112
+ def test_download_mail_msg(tmp_path):
113
+ message_bytes = sample_message_bytes()
114
+ dummy = DummyMail(message_bytes)
115
+ agent = ImapAgent('user', 'pass', 'imap.example.com')
116
+ agent.login_account = lambda: setattr(agent, 'mail', dummy)
117
+
118
+ agent.download_mail_msg(path=str(tmp_path) + os.sep)
119
+
120
+ file_path = tmp_path / 'email_0.msg'
121
+ assert file_path.exists()
122
+
123
+
124
+ def test_from_env(monkeypatch):
125
+ monkeypatch.setenv("IMAP_EMAIL", "env@example.com")
126
+ monkeypatch.setenv("IMAP_PASSWORD", "secret")
127
+ monkeypatch.setenv("IMAP_SERVER", "imap.env.com")
128
+
129
+ agent = ImapAgent.from_env()
130
+
131
+ assert agent.email_account == "env@example.com"
132
+ assert agent.password == "secret"
133
+ assert agent.server_address == "imap.env.com"
134
+
135
+
136
+ def test_context_manager(monkeypatch):
137
+ dummy = DummyMail(sample_message_bytes())
138
+ monkeypatch.setattr('imaplib.IMAP4_SSL', lambda addr: dummy)
139
+
140
+ with ImapAgent('user', 'pass', 'imap.example.com') as agent:
141
+ assert agent.mail is dummy
142
+ assert dummy.logged_in
143
+ assert dummy.closed
144
+ assert agent.mail is None
145
+
146
+
@@ -0,0 +1,100 @@
1
+ import builtins
2
+ import smtplib
3
+ from unittest import mock
4
+ import types
5
+ import sys
6
+ import pytest
7
+
8
+ # Provide dummy aiosmtplib to satisfy imports
9
+ sys.modules.setdefault(
10
+ "aiosmtplib",
11
+ types.SimpleNamespace(SMTP=None, errors=types.SimpleNamespace(SMTPException=Exception)),
12
+ )
13
+ # Minimal stub for jinja2
14
+ sys.modules.setdefault(
15
+ "jinja2",
16
+ types.SimpleNamespace(
17
+ Environment=lambda **kwargs: types.SimpleNamespace(get_template=lambda name: types.SimpleNamespace(render=lambda **kw: "")),
18
+ FileSystemLoader=lambda *args, **kwargs: None,
19
+ select_autoescape=lambda x: None,
20
+ ),
21
+ )
22
+ sys.modules.setdefault(
23
+ "email_validator",
24
+ types.SimpleNamespace(
25
+ validate_email=lambda email, check_deliverability=False: types.SimpleNamespace(normalized=email),
26
+ EmailNotValidError=Exception,
27
+ ),
28
+ )
29
+
30
+ from MailToolsBox.mailSender import EmailSender
31
+
32
+
33
+ def test_email_sender_send(monkeypatch):
34
+ smtp_instance = mock.MagicMock()
35
+ smtp_instance.__enter__.return_value = smtp_instance
36
+ smtp_instance.__exit__.return_value = None
37
+
38
+ smtp_class = mock.MagicMock(return_value=smtp_instance)
39
+ monkeypatch.setattr(smtplib, "SMTP", smtp_class)
40
+
41
+ sender = EmailSender(
42
+ user_email="user@example.com",
43
+ server_smtp_address="smtp.example.com",
44
+ user_email_password="pass",
45
+ port=25,
46
+ )
47
+
48
+ sender.send(
49
+ recipients=["to@example.com"],
50
+ subject="Subj",
51
+ message_body="Body",
52
+ cc=["cc@example.com"],
53
+ bcc=["bcc@example.com"],
54
+ use_tls=True,
55
+ )
56
+
57
+ smtp_class.assert_called_with("smtp.example.com", 25, timeout=10)
58
+ smtp_instance.starttls.assert_called()
59
+ smtp_instance.login.assert_called_with("user@example.com", "pass")
60
+ smtp_instance.send_message.assert_called()
61
+
62
+ args, kwargs = smtp_instance.send_message.call_args
63
+ msg = args[0]
64
+ to_addrs = kwargs["to_addrs"]
65
+ assert set(to_addrs) == {"to@example.com", "cc@example.com", "bcc@example.com"}
66
+ assert msg["To"] == "to@example.com"
67
+ assert msg["Cc"] == "cc@example.com"
68
+
69
+
70
+ def test_email_sender_from_env(monkeypatch):
71
+ monkeypatch.setenv("EMAIL", "env@example.com")
72
+ monkeypatch.setenv("SMTP_SERVER", "smtp.env.com")
73
+ monkeypatch.setenv("EMAIL_PASSWORD", "secret")
74
+ monkeypatch.setenv("SMTP_PORT", "2525")
75
+
76
+ sender = EmailSender.from_env()
77
+
78
+ assert sender.user_email == "env@example.com"
79
+ assert sender.server_smtp_address == "smtp.env.com"
80
+ assert sender.user_email_password == "secret"
81
+ assert sender.port == 2525
82
+
83
+
84
+ def test_send_bulk(monkeypatch):
85
+ sent = []
86
+
87
+ class DummySender(EmailSender):
88
+ def __init__(self):
89
+ pass
90
+
91
+ def send(self, recipients, subject, message_body, **kwargs):
92
+ sent.append(recipients[0])
93
+
94
+ sender = DummySender()
95
+
96
+ sender.send_bulk(["a@example.com", "b@example.com"], "subj", "body")
97
+
98
+ assert sent == ["a@example.com", "b@example.com"]
99
+
100
+
@@ -1,2 +0,0 @@
1
- from MailToolsBox.mailSender import SendAgent
2
- from MailToolsBox.imapClient import ImapAgent
File without changes
File without changes