PyEmailerAJM 1.6.1__tar.gz → 1.8__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.
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/PKG-INFO +4 -2
- pyemailerajm-1.8/PyEmailerAJM/__init__.py +33 -0
- pyemailerajm-1.8/PyEmailerAJM/_version.py +1 -0
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/PyEmailerAJM/py_emailer_ajm.py +76 -161
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/PyEmailerAJM.egg-info/PKG-INFO +4 -2
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/PyEmailerAJM.egg-info/SOURCES.txt +3 -4
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/PyEmailerAJM.egg-info/requires.txt +2 -0
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/setup.py +1 -1
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/tests/test_PyEmailerAJM.py +2 -0
- pyemailerajm-1.8/tests/test_logger.py +83 -0
- pyemailerajm-1.8/tests/test_snooze_tracking.py +93 -0
- pyemailerajm-1.6.1/PyEmailerAJM/__init__.py +0 -8
- pyemailerajm-1.6.1/PyEmailerAJM/_version.py +0 -1
- pyemailerajm-1.6.1/PyEmailerAJM/errs.py +0 -6
- pyemailerajm-1.6.1/PyEmailerAJM/helpers.py +0 -40
- pyemailerajm-1.6.1/PyEmailerAJM/msg.py +0 -230
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/LICENSE.txt +0 -0
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/PyEmailerAJM.egg-info/dependency_links.txt +0 -0
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/PyEmailerAJM.egg-info/top_level.txt +0 -0
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/README.md +0 -0
- {pyemailerajm-1.6.1 → pyemailerajm-1.8}/setup.cfg +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyEmailerAJM
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8
|
|
4
4
|
Summary: Allows for automating sending Email with the Outlook Desktop client. Future releases will add more client support
|
|
5
5
|
Home-page: https://github.com/amcsparron2793-Water/PyEmailer
|
|
6
|
-
Download-URL: https://github.com/amcsparron2793-Water/PyEmailer/archive/refs/tags/1.
|
|
6
|
+
Download-URL: https://github.com/amcsparron2793-Water/PyEmailer/archive/refs/tags/1.8.tar.gz
|
|
7
7
|
Author: Amcsparron
|
|
8
8
|
Author-email: amcsparron@albanyny.gov
|
|
9
9
|
License: MIT License
|
|
@@ -13,6 +13,8 @@ Requires-Dist: pywin32
|
|
|
13
13
|
Requires-Dist: extract_msg
|
|
14
14
|
Requires-Dist: email_validator
|
|
15
15
|
Requires-Dist: questionary
|
|
16
|
+
Requires-Dist: EasyLoggerAJM
|
|
17
|
+
Requires-Dist: ColorizerAJM
|
|
16
18
|
Dynamic: author
|
|
17
19
|
Dynamic: author-email
|
|
18
20
|
Dynamic: download-url
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from importlib import import_module
|
|
3
|
+
# TODO: add these to other projects? and or EasyLogger?
|
|
4
|
+
__project_root__ = Path(__file__).parent.parent
|
|
5
|
+
__project_name__ = __project_root__.name
|
|
6
|
+
print(f'logs for {__project_name__} found in {__project_root__ / "logs"}')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_instance_of_dynamic(obj: object, base_class_path: str) -> bool:
|
|
10
|
+
"""
|
|
11
|
+
Check if an object is an instance of a class or its subclass specified by its module path.
|
|
12
|
+
"""
|
|
13
|
+
try:
|
|
14
|
+
module_path, class_name = base_class_path.rsplit('.', 1)
|
|
15
|
+
module = import_module(module_path)
|
|
16
|
+
base_class = getattr(module, class_name)
|
|
17
|
+
return isinstance(obj, base_class)
|
|
18
|
+
except (ImportError, AttributeError):
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
from PyEmailerAJM.backend import deprecated
|
|
23
|
+
from PyEmailerAJM.backend.errs import EmailerNotSetupError, DisplayManualQuit
|
|
24
|
+
from PyEmailerAJM.msg import Msg, FailedMsg
|
|
25
|
+
from PyEmailerAJM.searchers import BaseSearcher, SubjectSearcher
|
|
26
|
+
from PyEmailerAJM.py_emailer_ajm import PyEmailer, EmailerInitializer
|
|
27
|
+
from PyEmailerAJM.continuous_monitor.continuous_monitor import ContinuousMonitor
|
|
28
|
+
|
|
29
|
+
__all__ = ['EmailerNotSetupError', 'DisplayManualQuit', 'deprecated',
|
|
30
|
+
'Msg', 'FailedMsg', 'PyEmailer', 'EmailerInitializer',
|
|
31
|
+
'BaseSearcher', 'SubjectSearcher', 'ContinuousMonitor',
|
|
32
|
+
'__project_root__', '__project_name__', 'is_instance_of_dynamic']
|
|
33
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '1.8'
|
|
@@ -5,11 +5,9 @@ py_emailer_ajm.py
|
|
|
5
5
|
install win32 with pip install pywin32
|
|
6
6
|
"""
|
|
7
7
|
# imports
|
|
8
|
-
from abc import abstractmethod
|
|
9
8
|
from os import environ
|
|
10
9
|
from os.path import isfile, join, isdir
|
|
11
10
|
from tempfile import gettempdir
|
|
12
|
-
from typing import List
|
|
13
11
|
|
|
14
12
|
# install win32 with pip install pywin32
|
|
15
13
|
import win32com.client as win32
|
|
@@ -17,17 +15,18 @@ import win32com.client as win32
|
|
|
17
15
|
# This is installed as part of pywin32
|
|
18
16
|
# noinspection PyUnresolvedReferences
|
|
19
17
|
from pythoncom import com_error
|
|
20
|
-
from logging import Logger, basicConfig, StreamHandler, FileHandler
|
|
18
|
+
from logging import Logger, basicConfig, StreamHandler, FileHandler, getLogger
|
|
21
19
|
from email_validator import validate_email, EmailNotValidError
|
|
22
20
|
import questionary
|
|
23
21
|
# this is usually thrown when questionary is used in the dev/Non Win32 environment
|
|
24
22
|
# noinspection PyProtectedMember
|
|
25
23
|
from prompt_toolkit.output.win32 import NoConsoleScreenBufferError
|
|
26
|
-
from win32com.client import CDispatch
|
|
27
24
|
|
|
28
|
-
from PyEmailerAJM import EmailerNotSetupError, DisplayManualQuit
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
from PyEmailerAJM import (EmailerNotSetupError, DisplayManualQuit,
|
|
26
|
+
deprecated,
|
|
27
|
+
Msg, FailedMsg)
|
|
28
|
+
from PyEmailerAJM.backend import BasicEmailFolderChoices, PyEmailerLogger
|
|
29
|
+
from PyEmailerAJM.searchers import SubjectSearcher
|
|
31
30
|
|
|
32
31
|
|
|
33
32
|
class EmailerInitializer:
|
|
@@ -39,8 +38,11 @@ class EmailerInitializer:
|
|
|
39
38
|
auto_send: bool = False,
|
|
40
39
|
email_app_name: str = DEFAULT_EMAIL_APP_NAME,
|
|
41
40
|
namespace_name: str = DEFAULT_NAMESPACE_NAME, **kwargs):
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
if logger:
|
|
42
|
+
self.logger = logger
|
|
43
|
+
else:
|
|
44
|
+
self._elog = PyEmailerLogger(**kwargs)
|
|
45
|
+
self.logger = self._elog()
|
|
44
46
|
# print("Dummy logger in use!")
|
|
45
47
|
|
|
46
48
|
self.email_app_name = email_app_name
|
|
@@ -52,53 +54,9 @@ class EmailerInitializer:
|
|
|
52
54
|
self.auto_send = auto_send
|
|
53
55
|
self.send_emails = send_emails
|
|
54
56
|
|
|
55
|
-
def _initialize_logger(self, logger=None, **kwargs):
|
|
56
|
-
if logger:
|
|
57
|
-
self._logger = logger
|
|
58
|
-
return self._logger
|
|
59
|
-
else:
|
|
60
|
-
self._logger = Logger(__name__)
|
|
61
|
-
|
|
62
|
-
if self._logger.hasHandlers():
|
|
63
|
-
return self._logger
|
|
64
|
-
if not kwargs.get('use_default_logger', True):
|
|
65
|
-
print("not using default logger")
|
|
66
|
-
return self._logger
|
|
67
|
-
return self._initialize_default_logger()
|
|
68
|
-
|
|
69
|
-
def _initialize_default_logger(self, **kwargs):
|
|
70
|
-
def init_handlers():
|
|
71
|
-
sh = StreamHandler()
|
|
72
|
-
sh.set_name('StreamHandler')
|
|
73
|
-
fh = FileHandler(kwargs.get('log_file_path', join('./', 'PyEmailer.log')))
|
|
74
|
-
fh.set_name('FileHandler')
|
|
75
|
-
return sh, fh
|
|
76
|
-
|
|
77
|
-
def set_handler_levels(**kw):
|
|
78
|
-
fh_level = kw.get('FileHandler_level', 'DEBUG')
|
|
79
|
-
sh_level = kw.get('StreamHandler_level', 'INFO')
|
|
80
|
-
for h in self._logger.handlers:
|
|
81
|
-
if isinstance(h, FileHandler):
|
|
82
|
-
h.setLevel(fh_level)
|
|
83
|
-
elif isinstance(h, StreamHandler):
|
|
84
|
-
h.setLevel(sh_level)
|
|
85
|
-
else:
|
|
86
|
-
h.setLevel(kw.get('handler_default_level', 'DEBUG'))
|
|
87
|
-
|
|
88
|
-
stream_handle, file_handle = init_handlers()
|
|
89
|
-
|
|
90
|
-
if kwargs.get('log_to_stdout', True):
|
|
91
|
-
self._logger.addHandler(stream_handle)
|
|
92
|
-
self._logger.addHandler(file_handle)
|
|
93
|
-
set_handler_levels(**kwargs)
|
|
94
|
-
|
|
95
|
-
basicConfig(level='INFO', handlers=self._logger.handlers)
|
|
96
|
-
self._logger.info("basic logger initialized.")
|
|
97
|
-
return self._logger
|
|
98
|
-
|
|
99
57
|
def initialize_new_email(self):
|
|
100
58
|
if hasattr(self, 'email_app') and self.email_app is not None:
|
|
101
|
-
self.email = Msg(self.email_app.CreateItem(0), logger=self.
|
|
59
|
+
self.email = Msg(self.email_app.CreateItem(0), logger=self.logger)
|
|
102
60
|
return self.email
|
|
103
61
|
raise AttributeError("email_app is not defined. Run 'initialize_email_item_app_and_namespace' first")
|
|
104
62
|
|
|
@@ -107,88 +65,21 @@ class EmailerInitializer:
|
|
|
107
65
|
email_app, namespace = self._setup_email_app_and_namespace()
|
|
108
66
|
email = self.initialize_new_email()
|
|
109
67
|
except com_error as e:
|
|
110
|
-
self.
|
|
68
|
+
self.logger.error(e, exc_info=True)
|
|
111
69
|
raise e
|
|
112
70
|
return email_app, namespace, email
|
|
113
71
|
|
|
114
72
|
def _setup_email_app_and_namespace(self):
|
|
115
73
|
self.email_app = win32.Dispatch(self.email_app_name)
|
|
116
74
|
|
|
117
|
-
self.
|
|
75
|
+
self.logger.debug(f"{self.email_app_name} app in use.")
|
|
118
76
|
self.namespace = self.email_app.GetNamespace(self.namespace_name)
|
|
119
77
|
|
|
120
|
-
self.
|
|
78
|
+
self.logger.debug(f"{self.namespace_name} namespace in use.")
|
|
121
79
|
return self.email_app, self.namespace
|
|
122
80
|
|
|
123
81
|
|
|
124
|
-
class
|
|
125
|
-
# Constants for prefixes
|
|
126
|
-
FW_PREFIXES = ['FW:', 'FWD:']
|
|
127
|
-
RE_PREFIX = 'RE:'
|
|
128
|
-
|
|
129
|
-
@abstractmethod
|
|
130
|
-
def GetMessages(self):
|
|
131
|
-
...
|
|
132
|
-
|
|
133
|
-
def find_messages_by_subject(self, search_subject: str, include_fw: bool = True, include_re: bool = True,
|
|
134
|
-
partial_match_ok: bool = False) -> List[CDispatch]:
|
|
135
|
-
"""Returns a list of messages matching the given subject, ignoring prefixes based on flags."""
|
|
136
|
-
|
|
137
|
-
# Normalize search subject
|
|
138
|
-
normalized_subject = self._normalize_subject(search_subject)
|
|
139
|
-
matched_messages = []
|
|
140
|
-
print("partial match ok: ", partial_match_ok)
|
|
141
|
-
|
|
142
|
-
for message in self.GetMessages():
|
|
143
|
-
normalized_message_subject = self._normalize_subject(message.subject)
|
|
144
|
-
|
|
145
|
-
if (self._is_exact_match(normalized_message_subject, normalized_subject) or
|
|
146
|
-
(partial_match_ok and self._is_partial_match(normalized_message_subject,
|
|
147
|
-
normalized_subject))):
|
|
148
|
-
matched_messages.append(message)
|
|
149
|
-
continue
|
|
150
|
-
|
|
151
|
-
if include_fw and self._matches_prefix(normalized_message_subject,
|
|
152
|
-
self.__class__.FW_PREFIXES,
|
|
153
|
-
normalized_subject,
|
|
154
|
-
partial_match_ok):
|
|
155
|
-
matched_messages.append(message)
|
|
156
|
-
continue
|
|
157
|
-
|
|
158
|
-
if include_re and self._matches_prefix(normalized_message_subject,
|
|
159
|
-
[self.__class__.RE_PREFIX],
|
|
160
|
-
normalized_subject,
|
|
161
|
-
partial_match_ok):
|
|
162
|
-
matched_messages.append(message)
|
|
163
|
-
|
|
164
|
-
return [m() for m in matched_messages]
|
|
165
|
-
|
|
166
|
-
@staticmethod
|
|
167
|
-
def _normalize_subject(subject: str) -> str:
|
|
168
|
-
"""Normalize the given subject by converting to lowercase and stripping whitespace."""
|
|
169
|
-
return subject.lower().strip()
|
|
170
|
-
|
|
171
|
-
def _matches_prefix(self, message_subject: str, prefixes: list, search_subject: str,
|
|
172
|
-
partial_match_ok: bool = False) -> bool:
|
|
173
|
-
"""Checks if the message subject matches the search subject after removing a prefix."""
|
|
174
|
-
for prefix in prefixes:
|
|
175
|
-
if message_subject.startswith(prefix.lower()):
|
|
176
|
-
stripped_subject = message_subject.split(prefix.lower(), 1)[1].strip()
|
|
177
|
-
return (self._is_exact_match(stripped_subject, search_subject) if not partial_match_ok
|
|
178
|
-
else self._is_partial_match(stripped_subject, search_subject))
|
|
179
|
-
return False
|
|
180
|
-
|
|
181
|
-
@staticmethod
|
|
182
|
-
def _is_exact_match(message_subject: str, search_subject: str) -> bool:
|
|
183
|
-
"""Checks if the subject matches exactly."""
|
|
184
|
-
return message_subject == search_subject
|
|
185
|
-
|
|
186
|
-
@staticmethod
|
|
187
|
-
def _is_partial_match(message_subject: str, search_subject: str) -> bool:
|
|
188
|
-
return search_subject in message_subject
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
82
|
+
class PyEmailer(EmailerInitializer, SubjectSearcher):
|
|
192
83
|
# the email tab_char
|
|
193
84
|
tab_char = ' '
|
|
194
85
|
signature_dir_path = join((environ['USERPROFILE']),
|
|
@@ -227,7 +118,7 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
227
118
|
if validate_email(value, check_deliverability=False):
|
|
228
119
|
self._current_user_email = value
|
|
229
120
|
except EmailNotValidError as e:
|
|
230
|
-
self.
|
|
121
|
+
self.logger.error(e, exc_info=True)
|
|
231
122
|
value = None
|
|
232
123
|
self._current_user_email = value
|
|
233
124
|
|
|
@@ -245,7 +136,7 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
245
136
|
try:
|
|
246
137
|
raise NotADirectoryError(f"{self.signature_dir_path} does not exist.")
|
|
247
138
|
except NotADirectoryError as e:
|
|
248
|
-
self.
|
|
139
|
+
self.logger.warning(e)
|
|
249
140
|
self._email_signature = None
|
|
250
141
|
|
|
251
142
|
if isfile(signature_full_path):
|
|
@@ -255,10 +146,11 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
255
146
|
try:
|
|
256
147
|
raise FileNotFoundError(f"{signature_full_path} does not exist.")
|
|
257
148
|
except FileNotFoundError as e:
|
|
258
|
-
self.
|
|
149
|
+
self.logger.warning(e)
|
|
259
150
|
self._email_signature = None
|
|
260
151
|
else:
|
|
261
152
|
self._email_signature = None
|
|
153
|
+
self.logger.info("email_sig_filename not specified, no email signature will be attached.")
|
|
262
154
|
|
|
263
155
|
return self._email_signature
|
|
264
156
|
|
|
@@ -279,12 +171,12 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
279
171
|
except Exception as e:
|
|
280
172
|
# TODO: slated for removal
|
|
281
173
|
# this is here purely as a compatibility thing, to be taken out later.
|
|
282
|
-
self.
|
|
283
|
-
self.
|
|
174
|
+
self.logger.warning(e)
|
|
175
|
+
self.logger.warning("Defaulting to basic y/n prompt.")
|
|
284
176
|
while True:
|
|
285
177
|
q = input(f"{self.DisplayEmailSendTrackingWarning}. Do you understand? (y/n): ").lower().strip()
|
|
286
178
|
if q == 'y':
|
|
287
|
-
self.
|
|
179
|
+
self.logger.warning(self.DisplayEmailSendTrackingWarning)
|
|
288
180
|
return True
|
|
289
181
|
elif q == 'n':
|
|
290
182
|
return False
|
|
@@ -300,10 +192,10 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
300
192
|
try:
|
|
301
193
|
raise DisplayManualQuit("User cancelled operation due to DisplayTrackingWarning.")
|
|
302
194
|
except DisplayManualQuit as e:
|
|
303
|
-
self.
|
|
195
|
+
self.logger.error(e, exc_info=True)
|
|
304
196
|
raise e
|
|
305
197
|
|
|
306
|
-
def
|
|
198
|
+
def _get_default_folder_for_email_dir(self, email_dir_index: int = None, **kwargs):
|
|
307
199
|
# 6 = inbox
|
|
308
200
|
if email_dir_index in self.__class__.VALID_EMAIL_FOLDER_CHOICES:
|
|
309
201
|
self.read_folder = self.namespace.GetDefaultFolder(email_dir_index)
|
|
@@ -312,9 +204,30 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
312
204
|
try:
|
|
313
205
|
raise ValueError(f"email_dir_index must be one of {self.__class__.VALID_EMAIL_FOLDER_CHOICES}")
|
|
314
206
|
except ValueError as e:
|
|
315
|
-
self.
|
|
207
|
+
self.logger.error(e, exc_info=True)
|
|
316
208
|
raise e
|
|
317
209
|
|
|
210
|
+
def _GetReadFolder(self, email_dir_index: int = None, **kwargs):
|
|
211
|
+
"""
|
|
212
|
+
:param email_dir_index: Specifies the email directory index to be accessed. Defaults to None.
|
|
213
|
+
:type email_dir_index: int, optional
|
|
214
|
+
:param kwargs: Additional optional arguments that may be passed. Can include `subfolder_name` to specify a subfolder name, defaulting to 'Inbox'.
|
|
215
|
+
:type kwargs: dict
|
|
216
|
+
:return: The folder specified either by the email directory index or the default folder along with the subfolder if applicable.
|
|
217
|
+
:rtype: object
|
|
218
|
+
"""
|
|
219
|
+
subfolder_name = kwargs.get('subfolder_name', 'Inbox')
|
|
220
|
+
if not email_dir_index:
|
|
221
|
+
email_dir_index = BasicEmailFolderChoices.INBOX
|
|
222
|
+
self.logger.debug(f">>> email_dir_index not specified, defaulting to '{email_dir_index}' folder. <<<")
|
|
223
|
+
if not isinstance(email_dir_index, int):
|
|
224
|
+
self.logger.debug(f">>> email_dir_index is not an int, "
|
|
225
|
+
f"defaulting to {email_dir_index} folder and {subfolder_name} subfolder. <<<")
|
|
226
|
+
return self.namespace.Folders[email_dir_index].Folders[subfolder_name]
|
|
227
|
+
|
|
228
|
+
else:
|
|
229
|
+
return self._get_default_folder_for_email_dir(email_dir_index)
|
|
230
|
+
|
|
318
231
|
def GetMessages(self, folder_index=None):
|
|
319
232
|
if isinstance(folder_index, int):
|
|
320
233
|
self.read_folder = self._GetReadFolder(folder_index)
|
|
@@ -326,9 +239,9 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
326
239
|
try:
|
|
327
240
|
raise TypeError("folder_index must be an integer or self.read_folder must be defined")
|
|
328
241
|
except TypeError as e:
|
|
329
|
-
self.
|
|
242
|
+
self.logger.error(e, exc_info=True)
|
|
330
243
|
raise e
|
|
331
|
-
return [Msg(m, logger=self.
|
|
244
|
+
return [Msg(m, logger=self.logger) for m in self.read_folder.Items]
|
|
332
245
|
|
|
333
246
|
@deprecated("use Msg classes body attribute instead")
|
|
334
247
|
def GetEmailMessageBody(self, msg):
|
|
@@ -340,7 +253,7 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
340
253
|
try:
|
|
341
254
|
raise ValueError("This message has no body.")
|
|
342
255
|
except ValueError as e:
|
|
343
|
-
self.
|
|
256
|
+
self.logger.error(e, exc_info=True)
|
|
344
257
|
raise e
|
|
345
258
|
|
|
346
259
|
@deprecated("use find_messages_by_subject instead")
|
|
@@ -356,15 +269,15 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
356
269
|
full_save_path = join(save_dir_path, str(attachment))
|
|
357
270
|
try:
|
|
358
271
|
attachment.SaveAsFile(full_save_path)
|
|
359
|
-
self.
|
|
272
|
+
self.logger.debug(f"{full_save_path} saved from email with subject {msg.subject}")
|
|
360
273
|
except Exception as e:
|
|
361
|
-
self.
|
|
274
|
+
self.logger.error(e, exc_info=True)
|
|
362
275
|
raise e
|
|
363
276
|
|
|
364
277
|
def SetupEmail(self, recipient: str, subject: str, text: str, attachments: list = None, **kwargs):
|
|
365
278
|
self.email = self.email.SetupMsg(sender=self.current_user_email, email_item=self.email(),
|
|
366
279
|
recipient=recipient, subject=subject, body=text, attachments=attachments,
|
|
367
|
-
logger=self.
|
|
280
|
+
logger=self.logger, **kwargs)
|
|
368
281
|
self._setup_was_run = True
|
|
369
282
|
return self.email
|
|
370
283
|
|
|
@@ -375,34 +288,34 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
375
288
|
self.email.send()
|
|
376
289
|
return
|
|
377
290
|
elif not send:
|
|
378
|
-
self.
|
|
291
|
+
self.logger.info(f"Mail not sent to {self.email.to}")
|
|
379
292
|
print(f"Mail not sent to {self.email.to}")
|
|
380
293
|
q = questionary.confirm("do you want to quit early?", default=False).ask()
|
|
381
294
|
if q:
|
|
382
295
|
print("ok quitting!")
|
|
383
|
-
self.
|
|
296
|
+
self.logger.warning("Quitting early due to user input.")
|
|
384
297
|
exit(-1)
|
|
385
298
|
else:
|
|
386
299
|
return
|
|
387
300
|
except com_error as e:
|
|
388
|
-
self.
|
|
301
|
+
self.logger.error(e, exc_info=True)
|
|
389
302
|
except NoConsoleScreenBufferError as e:
|
|
390
303
|
# TODO: slated for removal
|
|
391
304
|
# this is here purely as a compatibility thing, to be taken out later.
|
|
392
|
-
self.
|
|
393
|
-
self.
|
|
305
|
+
self.logger.warning(e)
|
|
306
|
+
self.logger.warning("defaulting to basic input style...")
|
|
394
307
|
while True:
|
|
395
308
|
yn = input("Send Mail? (y/n/q): ").lower()
|
|
396
309
|
if yn == 'y':
|
|
397
310
|
self.email.send()
|
|
398
311
|
break
|
|
399
312
|
elif yn == 'n':
|
|
400
|
-
self.
|
|
313
|
+
self.logger.info(f"Mail not sent to {self.email.to}")
|
|
401
314
|
print(f"Mail not sent to {self.email.to}")
|
|
402
315
|
break
|
|
403
316
|
elif yn == 'q':
|
|
404
317
|
print("ok quitting!")
|
|
405
|
-
self.
|
|
318
|
+
self.logger.warning("Quitting early due to user input.")
|
|
406
319
|
exit(-1)
|
|
407
320
|
else:
|
|
408
321
|
print("Please choose \'y\', \'n\' or \'q\'")
|
|
@@ -411,18 +324,18 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
411
324
|
if self._setup_was_run:
|
|
412
325
|
if print_ready_msg:
|
|
413
326
|
print(f"Ready to send/display mail to/for {self.email.to}...")
|
|
414
|
-
self.
|
|
327
|
+
self.logger.info(f"Ready to send/display mail to/for {self.email.to}...")
|
|
415
328
|
if self.send_emails and self.display_window:
|
|
416
329
|
send_and_display_warning = ("Sending email while also displaying the email "
|
|
417
330
|
"in the app is not possible. Defaulting to Display only")
|
|
418
331
|
# print(send_and_display_warning)
|
|
419
|
-
self.
|
|
332
|
+
self.logger.warning(send_and_display_warning)
|
|
420
333
|
self.send_emails = False
|
|
421
334
|
self.display_window = True
|
|
422
335
|
|
|
423
336
|
if self.send_emails:
|
|
424
337
|
if self.auto_send:
|
|
425
|
-
self.
|
|
338
|
+
self.logger.info("Sending emails with auto_send...")
|
|
426
339
|
self.email.send()
|
|
427
340
|
|
|
428
341
|
else:
|
|
@@ -433,13 +346,13 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
433
346
|
else:
|
|
434
347
|
both_disabled_warning = ("Both sending and displaying the email are disabled. "
|
|
435
348
|
"No errors were encountered.")
|
|
436
|
-
self.
|
|
349
|
+
self.logger.warning(both_disabled_warning)
|
|
437
350
|
# print(both_disabled_warning)
|
|
438
351
|
else:
|
|
439
352
|
try:
|
|
440
353
|
raise EmailerNotSetupError("Setup has not been run, sending or displaying an email cannot occur.")
|
|
441
354
|
except EmailerNotSetupError as e:
|
|
442
|
-
self.
|
|
355
|
+
self.logger.error(e, exc_info=True)
|
|
443
356
|
raise e
|
|
444
357
|
|
|
445
358
|
@staticmethod
|
|
@@ -456,8 +369,8 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
456
369
|
|
|
457
370
|
if msg_candidates:
|
|
458
371
|
msg_candidates = [FailedMsg(m) for m in msg_candidates]
|
|
459
|
-
self.
|
|
460
|
-
self.
|
|
372
|
+
self.logger.info(f"{len(msg_candidates)} 'failed send' candidates found.")
|
|
373
|
+
self.logger.info("mutating msg_candidates (Msg instances) into FailedMsg instances.")
|
|
461
374
|
|
|
462
375
|
for m in msg_candidates:
|
|
463
376
|
failed_info = m.process_failed_msg(m(), recent_days_cap=recent_days_cap)
|
|
@@ -469,10 +382,10 @@ class PyEmailer(EmailerInitializer, _SubjectSearcher):
|
|
|
469
382
|
'err_info': failed_info})
|
|
470
383
|
results_string = self.__class__.FAILED_SEND_LOGGER_STRING.format(num=len(failed_sends),
|
|
471
384
|
recent_days_cap=recent_days_cap)
|
|
472
|
-
if (not self.
|
|
473
|
-
for x in self.
|
|
385
|
+
if (not self.logger.hasHandlers() or not any([isinstance(x, StreamHandler)
|
|
386
|
+
for x in self.logger.handlers])):
|
|
474
387
|
print(results_string)
|
|
475
|
-
self.
|
|
388
|
+
self.logger.info(results_string)
|
|
476
389
|
return failed_sends
|
|
477
390
|
|
|
478
391
|
|
|
@@ -495,13 +408,15 @@ def __setup_and_send_test(emailer):
|
|
|
495
408
|
if __name__ == "__main__":
|
|
496
409
|
module_name = __file__.split('\\')[-1].split('.py')[0]
|
|
497
410
|
|
|
498
|
-
em = PyEmailer(display_window=False, send_emails=True, auto_send=False, use_default_logger=
|
|
499
|
-
m = em.find_messages_by_subject('Andrew', partial_match_ok=True
|
|
411
|
+
em = PyEmailer(display_window=False, send_emails=True, auto_send=False, use_default_logger=False)
|
|
412
|
+
m = em.find_messages_by_subject('Andrew', partial_match_ok=True)
|
|
500
413
|
print([type(x) for x in m])
|
|
501
414
|
# __setup_and_send_test(em)
|
|
502
415
|
# __failed_sends_test(em)
|
|
503
|
-
|
|
504
|
-
#
|
|
416
|
+
x = em.find_messages_by_subject("GIS Request", partial_match_ok=True)
|
|
417
|
+
# [x.name for x in m.ItemProperties]
|
|
418
|
+
print([(m.__class__, m.sender, m.sender_email_type, m.subject)
|
|
419
|
+
for m in [Msg(y) for y in x]]) # for m in x])
|
|
505
420
|
# property_accessor = x[0].PropertyAccessor
|
|
506
421
|
# print(x[0].Sender.GetExchangeUser().PrimarySmtpAddress)
|
|
507
422
|
# print(property_accessor.GetProperty("PR_EMAIL_ADDRESS"))
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyEmailerAJM
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8
|
|
4
4
|
Summary: Allows for automating sending Email with the Outlook Desktop client. Future releases will add more client support
|
|
5
5
|
Home-page: https://github.com/amcsparron2793-Water/PyEmailer
|
|
6
|
-
Download-URL: https://github.com/amcsparron2793-Water/PyEmailer/archive/refs/tags/1.
|
|
6
|
+
Download-URL: https://github.com/amcsparron2793-Water/PyEmailer/archive/refs/tags/1.8.tar.gz
|
|
7
7
|
Author: Amcsparron
|
|
8
8
|
Author-email: amcsparron@albanyny.gov
|
|
9
9
|
License: MIT License
|
|
@@ -13,6 +13,8 @@ Requires-Dist: pywin32
|
|
|
13
13
|
Requires-Dist: extract_msg
|
|
14
14
|
Requires-Dist: email_validator
|
|
15
15
|
Requires-Dist: questionary
|
|
16
|
+
Requires-Dist: EasyLoggerAJM
|
|
17
|
+
Requires-Dist: ColorizerAJM
|
|
16
18
|
Dynamic: author
|
|
17
19
|
Dynamic: author-email
|
|
18
20
|
Dynamic: download-url
|
|
@@ -4,13 +4,12 @@ setup.cfg
|
|
|
4
4
|
setup.py
|
|
5
5
|
PyEmailerAJM/__init__.py
|
|
6
6
|
PyEmailerAJM/_version.py
|
|
7
|
-
PyEmailerAJM/errs.py
|
|
8
|
-
PyEmailerAJM/helpers.py
|
|
9
|
-
PyEmailerAJM/msg.py
|
|
10
7
|
PyEmailerAJM/py_emailer_ajm.py
|
|
11
8
|
PyEmailerAJM.egg-info/PKG-INFO
|
|
12
9
|
PyEmailerAJM.egg-info/SOURCES.txt
|
|
13
10
|
PyEmailerAJM.egg-info/dependency_links.txt
|
|
14
11
|
PyEmailerAJM.egg-info/requires.txt
|
|
15
12
|
PyEmailerAJM.egg-info/top_level.txt
|
|
16
|
-
tests/test_PyEmailerAJM.py
|
|
13
|
+
tests/test_PyEmailerAJM.py
|
|
14
|
+
tests/test_logger.py
|
|
15
|
+
tests/test_snooze_tracking.py
|
|
@@ -17,7 +17,7 @@ setup(
|
|
|
17
17
|
url='https://github.com/amcsparron2793-Water/PyEmailer',
|
|
18
18
|
download_url=f'https://github.com/amcsparron2793-Water/PyEmailer/archive/refs/tags/{get_property("__version__", project_name)}.tar.gz',
|
|
19
19
|
keywords=["Outlook", "Email", "Automation"],
|
|
20
|
-
install_requires=['pywin32', 'extract_msg', 'email_validator', 'questionary'],
|
|
20
|
+
install_requires=['pywin32', 'extract_msg', 'email_validator', 'questionary','EasyLoggerAJM', 'ColorizerAJM'],
|
|
21
21
|
license='MIT License',
|
|
22
22
|
author='Amcsparron',
|
|
23
23
|
author_email='amcsparron@albanyny.gov',
|
|
@@ -7,8 +7,10 @@ from PyEmailerAJM import PyEmailer, Msg
|
|
|
7
7
|
|
|
8
8
|
class TestPyEmailer(unittest.TestCase):
|
|
9
9
|
TEST_ATTACHMENT_NAMES = ['attachment1', 'attachment2']
|
|
10
|
+
TEST_ADMIN_EMAIL = ['example@example.com']
|
|
10
11
|
|
|
11
12
|
def setUp(self):
|
|
13
|
+
PyEmailer.ADMIN_EMAIL = TestPyEmailer.TEST_ADMIN_EMAIL
|
|
12
14
|
self.emailer = PyEmailer(False, False)
|
|
13
15
|
|
|
14
16
|
@classmethod
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import patch, MagicMock
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from PyEmailerAJM.backend.logger import PyEmailerLogger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# FIXME: some of these tests are flakey
|
|
9
|
+
class TestPyEmailerLogger(unittest.TestCase):
|
|
10
|
+
def setUp(self) -> None:
|
|
11
|
+
self.logger = PyEmailerLogger()
|
|
12
|
+
|
|
13
|
+
def test_call(self):
|
|
14
|
+
self.assertIs(self.logger(), self.logger.logger)
|
|
15
|
+
|
|
16
|
+
@patch('PyEmailerAJM.backend.logger.DupeDebugFilter')
|
|
17
|
+
def test_add_dupe_debug_to_handler(self, mock_dupe_debug_filter):
|
|
18
|
+
handler = MagicMock()
|
|
19
|
+
|
|
20
|
+
# FIX: Ensure handler.level is an integer
|
|
21
|
+
handler.level = logging.WARNING # Set a valid logging level
|
|
22
|
+
|
|
23
|
+
self.logger._add_dupe_debug_to_handler(handler)
|
|
24
|
+
mock_dupe_debug_filter.assert_called_once()
|
|
25
|
+
handler.addFilter.assert_called_once_with(mock_dupe_debug_filter.return_value)
|
|
26
|
+
|
|
27
|
+
@unittest.skip("Skipping this test because it's under development")
|
|
28
|
+
def test_set_logger_class(self):
|
|
29
|
+
result = self.logger._set_logger_class()
|
|
30
|
+
self.assertIs(result, self.logger)
|
|
31
|
+
|
|
32
|
+
@patch('PyEmailerAJM.backend.logger.Logger')
|
|
33
|
+
def test_initialize_logger(self, mock_logger):
|
|
34
|
+
self.logger.initialize_logger(logger=mock_logger)
|
|
35
|
+
self.assertFalse(mock_logger.propagate)
|
|
36
|
+
|
|
37
|
+
@patch('PyEmailerAJM.backend.logger.OutlookEmailHandler')
|
|
38
|
+
def test_setup_email_handler(self, mock_email_handler):
|
|
39
|
+
mock_email_handler_instance = mock_email_handler.return_value
|
|
40
|
+
mock_email_handler_instance.level = logging.ERROR
|
|
41
|
+
|
|
42
|
+
with patch.object(self.logger.logger, 'addHandler', autospec=True) as mock_add_handler:
|
|
43
|
+
self.logger.setup_email_handler(email_msg='Test email', logger_admins=['admin1@gmail.com'])
|
|
44
|
+
|
|
45
|
+
self.assertEqual(mock_email_handler_instance.level, logging.ERROR)
|
|
46
|
+
mock_email_handler.assert_called_once_with(
|
|
47
|
+
email_msg='Test email',
|
|
48
|
+
project_name=self.logger.project_name,
|
|
49
|
+
logger_dir_path=self.logger.log_location,
|
|
50
|
+
recipient=['admin1@gmail.com']
|
|
51
|
+
)
|
|
52
|
+
mock_email_handler_instance.setLevel.assert_called_once_with(logging.ERROR)
|
|
53
|
+
mock_email_handler_instance.setFormatter.assert_called_once_with(
|
|
54
|
+
self.logger.formatter)
|
|
55
|
+
mock_add_handler.assert_called_once_with(mock_email_handler_instance)
|
|
56
|
+
|
|
57
|
+
@patch('PyEmailerAJM.backend.logger.FileHandler')
|
|
58
|
+
def test_add_filter_to_file_handler(self, mock_file_handler):
|
|
59
|
+
mock_file_handler.level = logging.INFO # FIX: Set a valid level
|
|
60
|
+
self.logger._add_filter_to_file_handler(mock_file_handler)
|
|
61
|
+
mock_file_handler.addFilter.assert_called_once()
|
|
62
|
+
|
|
63
|
+
@patch('PyEmailerAJM.backend.logger.StreamHandler')
|
|
64
|
+
def test_add_filter_to_stream_handler(self, mock_stream_handler):
|
|
65
|
+
mock_stream_handler.level = logging.DEBUG # FIX: Set a valid level
|
|
66
|
+
self.logger._add_filter_to_stream_handler(mock_stream_handler)
|
|
67
|
+
mock_stream_handler.addFilter.assert_called_once()
|
|
68
|
+
|
|
69
|
+
def test_project_name_getter(self):
|
|
70
|
+
self.assertEqual(self.logger.project_name, self.logger.project_name)
|
|
71
|
+
|
|
72
|
+
def test_project_name_setter(self):
|
|
73
|
+
self.logger.project_name = 'NewProject'
|
|
74
|
+
self.assertEqual(self.logger.project_name, 'NewProject')
|
|
75
|
+
|
|
76
|
+
@patch('PyEmailerAJM.backend.logger.StreamHandlerIgnoreExecInfo')
|
|
77
|
+
def test_create_stream_handler(self, mock_stream_handler):
|
|
78
|
+
self.logger.create_stream_handler(log_level_to_stream=logging.WARNING)
|
|
79
|
+
mock_stream_handler.assert_called_once()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == '__main__':
|
|
83
|
+
unittest.main()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import unittest
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch, Mock
|
|
6
|
+
|
|
7
|
+
from PyEmailerAJM.continuous_monitor.backend import SnoozeTracking
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestSnoozeTracking(unittest.TestCase):
|
|
11
|
+
TEST_JSON_PATH = Path("./test.json")
|
|
12
|
+
|
|
13
|
+
def setUp(self):
|
|
14
|
+
self.file_path = self.__class__.TEST_JSON_PATH
|
|
15
|
+
|
|
16
|
+
self.logger = logging.getLogger('test_logger')
|
|
17
|
+
self.subject = "Test Email Subject"
|
|
18
|
+
self.snooze_time = datetime.now() + timedelta(days=2)
|
|
19
|
+
self.email_entries = {
|
|
20
|
+
self.subject: self.snooze_time.isoformat()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
self.snooze_tracking = SnoozeTracking(self.file_path, logger=self.logger)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def tearDownClass(cls):
|
|
27
|
+
cls.TEST_JSON_PATH.unlink()
|
|
28
|
+
|
|
29
|
+
@unittest.skip("this test only works if the file "
|
|
30
|
+
"is NOT deleted by tearDownClass, "
|
|
31
|
+
"then the test is run again")
|
|
32
|
+
@patch('json.load')
|
|
33
|
+
@patch('builtins.open')
|
|
34
|
+
def test_json_loaded(self, mock_open, mock_json_load):
|
|
35
|
+
mock_json_load.return_value = self.email_entries
|
|
36
|
+
loaded_json = self.snooze_tracking.json_loaded
|
|
37
|
+
mock_open.assert_called_once_with(self.file_path, 'r')
|
|
38
|
+
self.assertEqual(loaded_json, self.email_entries, 'Should load JSON data from file')
|
|
39
|
+
|
|
40
|
+
@patch('json.load')
|
|
41
|
+
@patch('builtins.open')
|
|
42
|
+
def test_write_entry(self, mock_open, mock_json_load):
|
|
43
|
+
mock_json_load.return_value = {}
|
|
44
|
+
new_snooze_time = datetime.now() + timedelta(days=3)
|
|
45
|
+
self.snooze_tracking.write_entry(self.subject, new_snooze_time)
|
|
46
|
+
self.assertEqual(self.snooze_tracking.json_loaded[self.subject], new_snooze_time,
|
|
47
|
+
'Should write entry to json_loaded')
|
|
48
|
+
|
|
49
|
+
def test_convert_datetime(self):
|
|
50
|
+
non_dt_val = "non_datetime_value"
|
|
51
|
+
dt_val = datetime.now()
|
|
52
|
+
self.assertEqual(self.snooze_tracking._convert_datetime(non_dt_val), non_dt_val,
|
|
53
|
+
'Non datetime value should remain unchanged')
|
|
54
|
+
self.assertEqual(self.snooze_tracking._convert_datetime(dt_val), dt_val.isoformat(),
|
|
55
|
+
'Datetime value should be iso formatted')
|
|
56
|
+
|
|
57
|
+
@patch('json.dump')
|
|
58
|
+
@patch('builtins.open')
|
|
59
|
+
def test_save_json(self, mock_open, mock_json_dump):
|
|
60
|
+
with patch.object(self.logger, 'info') as mock_info: # Mock the logger.info method
|
|
61
|
+
self.snooze_tracking._json_loaded = self.email_entries
|
|
62
|
+
self.snooze_tracking.save_json()
|
|
63
|
+
|
|
64
|
+
# Use the mocked `info` method with assert_called.
|
|
65
|
+
mock_info.assert_called_with(f"json saved to {self.file_path}")
|
|
66
|
+
|
|
67
|
+
mock_open.assert_called_once_with(self.file_path, 'w')
|
|
68
|
+
mock_json_dump.assert_called_once()
|
|
69
|
+
|
|
70
|
+
def test_read_entry(self):
|
|
71
|
+
self.snooze_tracking._json_loaded = self.email_entries
|
|
72
|
+
output_snooze_time = self.snooze_tracking.read_entry(self.subject)
|
|
73
|
+
self.assertEqual(output_snooze_time, self.snooze_time, 'Should correctly read snooze time from json_loaded')
|
|
74
|
+
|
|
75
|
+
def test_snooze_msgs(self):
|
|
76
|
+
mock_msg_1 = Mock()
|
|
77
|
+
mock_msg_1.subject = "msg_1"
|
|
78
|
+
mock_msg_1.msg_snoozed = False
|
|
79
|
+
mock_msg_1.msg_snoozed_time = datetime.now() + timedelta(days=3)
|
|
80
|
+
|
|
81
|
+
mock_msg_2 = Mock()
|
|
82
|
+
mock_msg_2.subject = "msg_2"
|
|
83
|
+
mock_msg_2.msg_snoozed = True
|
|
84
|
+
mock_msg_2.msg_snoozed_time = datetime.now() + timedelta(days=1)
|
|
85
|
+
|
|
86
|
+
msg_list = [mock_msg_1, mock_msg_2]
|
|
87
|
+
output_msg_list = self.snooze_tracking.snooze_msgs(msg_list)
|
|
88
|
+
self.assertEqual(output_msg_list, msg_list, 'Should return the same list')
|
|
89
|
+
self.assertTrue(output_msg_list[0].msg_snoozed, 'Non-snoozed message should be marked as snoozed')
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
unittest.main()
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
from PyEmailerAJM.errs import EmailerNotSetupError, DisplayManualQuit
|
|
2
|
-
from PyEmailerAJM.helpers import deprecated, BasicEmailFolderChoices
|
|
3
|
-
from PyEmailerAJM.msg import Msg, FailedMsg
|
|
4
|
-
from PyEmailerAJM.py_emailer_ajm import PyEmailer, EmailerInitializer
|
|
5
|
-
|
|
6
|
-
__all__ = ['EmailerNotSetupError', 'DisplayManualQuit', 'deprecated',
|
|
7
|
-
'BasicEmailFolderChoices', 'Msg', 'FailedMsg',
|
|
8
|
-
'PyEmailer', 'EmailerInitializer']
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '1.6.1'
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import warnings
|
|
2
|
-
import functools
|
|
3
|
-
from enum import IntEnum
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class BasicEmailFolderChoices(IntEnum):
|
|
7
|
-
INBOX = 6
|
|
8
|
-
SENT_ITEMS = 5
|
|
9
|
-
DRAFTS = 16
|
|
10
|
-
DELETED_ITEMS = 3
|
|
11
|
-
OUTBOX = 4
|
|
12
|
-
|
|
13
|
-
def __str__(self):
|
|
14
|
-
"""Return the enum name as a string."""
|
|
15
|
-
return self.name
|
|
16
|
-
|
|
17
|
-
def __repr__(self):
|
|
18
|
-
return f"<{self.__class__.__name__}.{self.name} ({self.value})>"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def deprecated(reason: str = ""):
|
|
22
|
-
"""
|
|
23
|
-
Decorator that marks a function or method as deprecated.
|
|
24
|
-
|
|
25
|
-
:param reason: Optional message to explain what to use instead
|
|
26
|
-
or when the feature will be removed.
|
|
27
|
-
"""
|
|
28
|
-
|
|
29
|
-
def decorator(func):
|
|
30
|
-
@functools.wraps(func)
|
|
31
|
-
def wrapper(*args, **kwargs):
|
|
32
|
-
message = f"Function '{func.__name__}' is deprecated."
|
|
33
|
-
if reason:
|
|
34
|
-
message += f" {reason}"
|
|
35
|
-
warnings.warn(message, category=DeprecationWarning, stacklevel=2)
|
|
36
|
-
return func(*args, **kwargs)
|
|
37
|
-
|
|
38
|
-
return wrapper
|
|
39
|
-
|
|
40
|
-
return decorator
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
from abc import abstractmethod
|
|
2
|
-
from os.path import isfile, isabs, abspath, join
|
|
3
|
-
from tempfile import gettempdir
|
|
4
|
-
|
|
5
|
-
import win32com.client as win32
|
|
6
|
-
import datetime
|
|
7
|
-
import extract_msg
|
|
8
|
-
from bs4 import BeautifulSoup
|
|
9
|
-
from logging import Logger, getLogger, warning
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class _BasicMsgProperties:
|
|
13
|
-
def __init__(self, email_item: win32.CDispatch):
|
|
14
|
-
self.email_item = email_item
|
|
15
|
-
|
|
16
|
-
@classmethod
|
|
17
|
-
@abstractmethod
|
|
18
|
-
def _validate_and_add_attachments(cls, email_item: win32.CDispatch, attachment_list: list = None):
|
|
19
|
-
...
|
|
20
|
-
|
|
21
|
-
@property
|
|
22
|
-
def sender(self):
|
|
23
|
-
if hasattr(self.email_item, 'SenderEmailType') and self.email_item.SenderEmailType == 'EX':
|
|
24
|
-
return self.email_item.Sender.GetExchangeUser().PrimarySmtpAddress
|
|
25
|
-
# return self.email_item.Sender if hasattr(self.email_item, 'Sender') else self.email_item.sender
|
|
26
|
-
else:
|
|
27
|
-
return self.email_item.SenderEmailAddress
|
|
28
|
-
|
|
29
|
-
@property
|
|
30
|
-
def sender_name(self):
|
|
31
|
-
return self.email_item.Sender if hasattr(self.email_item, 'Sender') else self.email_item.sender
|
|
32
|
-
|
|
33
|
-
@property
|
|
34
|
-
def to(self):
|
|
35
|
-
return self.email_item.To if hasattr(self.email_item, 'To') else self.email_item.to
|
|
36
|
-
|
|
37
|
-
def cc(self):
|
|
38
|
-
return self.email_item.CC if hasattr(self.email_item, 'CC') else self.email_item.cc
|
|
39
|
-
|
|
40
|
-
@property
|
|
41
|
-
def subject(self):
|
|
42
|
-
return self.email_item.Subject if hasattr(self.email_item, 'Subject') else self.email_item.subject
|
|
43
|
-
|
|
44
|
-
@property
|
|
45
|
-
def received_time(self):
|
|
46
|
-
#not_future = self.email_item.ReceivedTime.year < datetime.datetime.now().year
|
|
47
|
-
return self.email_item.ReceivedTime #if not_future else None
|
|
48
|
-
|
|
49
|
-
@property
|
|
50
|
-
def body(self):
|
|
51
|
-
return self.email_item.HTMLBody if hasattr(self.email_item, 'HTMLBody') else self.email_item.htmlBody
|
|
52
|
-
|
|
53
|
-
@property
|
|
54
|
-
def attachments(self):
|
|
55
|
-
return self.email_item.Attachments
|
|
56
|
-
|
|
57
|
-
@attachments.setter
|
|
58
|
-
def attachments(self, value: list):
|
|
59
|
-
self._validate_and_add_attachments(self.email_item, value)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class Msg(_BasicMsgProperties):
|
|
63
|
-
def __init__(self, email_item: win32.CDispatch or extract_msg.Message, **kwargs):
|
|
64
|
-
super().__init__(email_item)
|
|
65
|
-
self._logger: Logger = kwargs.get('logger', getLogger(__name__))
|
|
66
|
-
self.send_success = False
|
|
67
|
-
|
|
68
|
-
def __call__(self, *args, **kwargs):
|
|
69
|
-
return self.email_item
|
|
70
|
-
|
|
71
|
-
@classmethod
|
|
72
|
-
def SetupMsg(cls, sender, recipient, subject, body, email_item: win32.CDispatch, attachments: list = None, **kwargs):
|
|
73
|
-
email_item.To = recipient
|
|
74
|
-
email_item.Sender = sender
|
|
75
|
-
email_item.Subject = subject
|
|
76
|
-
email_item.HtmlBody = body
|
|
77
|
-
email_item.cc = kwargs.get('cc', '')
|
|
78
|
-
email_item.Bcc = kwargs.get('bcc', '')
|
|
79
|
-
|
|
80
|
-
cls._validate_and_add_attachments(email_item, attachments)
|
|
81
|
-
return cls(email_item, **kwargs)
|
|
82
|
-
|
|
83
|
-
@classmethod
|
|
84
|
-
def _validate_and_add_attachments(cls, email_item: win32.CDispatch, attachment_list: list = None):
|
|
85
|
-
""" Validate and attach files to the email_item. """
|
|
86
|
-
if not attachment_list:
|
|
87
|
-
warning("No attachments detected")
|
|
88
|
-
return
|
|
89
|
-
|
|
90
|
-
if not isinstance(attachment_list, list):
|
|
91
|
-
raise TypeError("Attachments must be provided as a list")
|
|
92
|
-
|
|
93
|
-
def _absolute_file_path(file_path):
|
|
94
|
-
"""Returns absolute path if valid; raises FileNotFoundError otherwise."""
|
|
95
|
-
if not isabs(file_path):
|
|
96
|
-
file_path = abspath(file_path)
|
|
97
|
-
if not isfile(file_path):
|
|
98
|
-
raise FileNotFoundError(f"File {file_path} could not be attached.")
|
|
99
|
-
return file_path
|
|
100
|
-
|
|
101
|
-
for attachment in attachment_list:
|
|
102
|
-
email_item.attachments.Add(_absolute_file_path(attachment))
|
|
103
|
-
|
|
104
|
-
def SaveAllEmailAttachments(self, save_dir_path):
|
|
105
|
-
all_attachment_paths = set()
|
|
106
|
-
for attachment in self.attachments:
|
|
107
|
-
full_save_path = join(save_dir_path, str(attachment))
|
|
108
|
-
try:
|
|
109
|
-
attachment.SaveAsFile(full_save_path)
|
|
110
|
-
all_attachment_paths.add(full_save_path)
|
|
111
|
-
self._logger.debug(f"{full_save_path} saved from email with subject {self.subject}")
|
|
112
|
-
except Exception as e:
|
|
113
|
-
self._logger.error(e, exc_info=True)
|
|
114
|
-
raise e
|
|
115
|
-
return all_attachment_paths
|
|
116
|
-
|
|
117
|
-
def display(self):
|
|
118
|
-
# print(f"Displaying the email in {self.email_app_name}, this window might open minimized.")
|
|
119
|
-
# self._logger.info(f"Displaying the email in {self.email_app_name}, this window might open minimized.")
|
|
120
|
-
try:
|
|
121
|
-
self().Display(True)
|
|
122
|
-
except Exception as e:
|
|
123
|
-
self._logger.error(e, exc_info=True)
|
|
124
|
-
raise e
|
|
125
|
-
|
|
126
|
-
def send(self):
|
|
127
|
-
try:
|
|
128
|
-
# if the send fails, self.to is NULL, so this needs to be saved in a local variable
|
|
129
|
-
attempted_recipient = self.to
|
|
130
|
-
self.send_success = False
|
|
131
|
-
self().Send()
|
|
132
|
-
# print(f"Mail sent to {self._recipient}")
|
|
133
|
-
self.send_success = True
|
|
134
|
-
self._logger.info(f"Mail successfully sent to {attempted_recipient}")
|
|
135
|
-
except Exception as e:
|
|
136
|
-
self._logger.error(e, exc_info=True)
|
|
137
|
-
raise e
|
|
138
|
-
|
|
139
|
-
def _ValidateResponseMsg(self):
|
|
140
|
-
if isinstance(self(), win32.CDispatch):
|
|
141
|
-
self._logger.debug("passed in msg is CDispatch instance")
|
|
142
|
-
if hasattr(self(), 'HtmlBody') or hasattr(self(), 'htmlBody'):
|
|
143
|
-
self._logger.debug("passed in msg has 'HtmlBody' or 'htmlBody' attr")
|
|
144
|
-
|
|
145
|
-
if (not isinstance(self(), win32.CDispatch)
|
|
146
|
-
or not hasattr(self(), ('HtmlBody' or 'htmlBody'))):
|
|
147
|
-
raise AttributeError("msg attr must have 'HtmlBody' attr AND be a CDispatch instance")
|
|
148
|
-
return self()
|
|
149
|
-
|
|
150
|
-
def _msg_is_recent(self, recent_days_cap=1):
|
|
151
|
-
if self.received_time is not None:
|
|
152
|
-
abs_diff = abs(self.received_time - datetime.datetime.now(tz=self.received_time.tzinfo))
|
|
153
|
-
return abs_diff <= datetime.timedelta(days=recent_days_cap)
|
|
154
|
-
print(f"msg with subject \'{self.email_item.Subject}\' has no received time. defaulting to false")
|
|
155
|
-
self._logger.debug(f"msg with subject \'{self.email_item.Subject}\' has no received time. defaulting to false")
|
|
156
|
-
return False
|
|
157
|
-
|
|
158
|
-
def return_as_failed_send(self):
|
|
159
|
-
return FailedMsg(self())
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
class FailedMsg(Msg):
|
|
163
|
-
ERR_SKIP_STRING = "err {}: skipping this message"
|
|
164
|
-
DEFAULT_TEMP_SAVE_PATH = gettempdir()
|
|
165
|
-
|
|
166
|
-
def _message_filter_checks(self, **kwargs) -> bool:
|
|
167
|
-
recent_days_cap = kwargs.get('recent_days_cap', 1)
|
|
168
|
-
return self._msg_is_recent(recent_days_cap)
|
|
169
|
-
|
|
170
|
-
def _fetch_failed_msg_details(self, **kwargs):
|
|
171
|
-
temp_attachment_save_path = kwargs.get('temp_attachment_save_path',
|
|
172
|
-
self.__class__.DEFAULT_TEMP_SAVE_PATH)
|
|
173
|
-
try:
|
|
174
|
-
attachment_msg_path = self.SaveAllEmailAttachments(temp_attachment_save_path)
|
|
175
|
-
print('saved_attachments')
|
|
176
|
-
except Exception as e:
|
|
177
|
-
self._logger.warning(self.__class__.ERR_SKIP_STRING.format(f'({e})'))
|
|
178
|
-
return e
|
|
179
|
-
if len(attachment_msg_path) == 1:
|
|
180
|
-
return next(iter(attachment_msg_path))
|
|
181
|
-
return attachment_msg_path
|
|
182
|
-
|
|
183
|
-
def process_failed_msg(self, post_master_msg, **kwargs):
|
|
184
|
-
recent_days_cap = kwargs.get('recent_days_cap', 1)
|
|
185
|
-
try:
|
|
186
|
-
self.email_item = post_master_msg
|
|
187
|
-
self._ValidateResponseMsg()
|
|
188
|
-
except AttributeError as e:
|
|
189
|
-
self._logger.warning(self.__class__.ERR_SKIP_STRING.format(f'({e})'))
|
|
190
|
-
return e, None, None
|
|
191
|
-
|
|
192
|
-
if self._msg_is_recent(recent_days_cap):
|
|
193
|
-
attachment_msg = self._fetch_failed_msg_details()
|
|
194
|
-
if isinstance(attachment_msg, Exception):
|
|
195
|
-
return attachment_msg, None, None
|
|
196
|
-
else:
|
|
197
|
-
if isinstance(attachment_msg, str):
|
|
198
|
-
fmd = _FailedMessageDetails.extract_msg_from_attachment(attachment_msg)
|
|
199
|
-
return fmd.process_failed_details_msg() #self._process_failed_details_msg(attachment_msg)
|
|
200
|
-
return None, None, None
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
class _FailedMessageDetails(FailedMsg):
|
|
204
|
-
@classmethod
|
|
205
|
-
def extract_msg_from_attachment(cls, parent_msg: str):
|
|
206
|
-
return cls(extract_msg.Message(parent_msg))
|
|
207
|
-
|
|
208
|
-
def _extract_from_failed_details_msg(self, para):
|
|
209
|
-
email_of_err = para.findNext('p').get_text().strip().split('(')[0].strip()
|
|
210
|
-
err_reason = para.findNext('p').findNext('p').get_text()
|
|
211
|
-
send_time = self().date.ctime()
|
|
212
|
-
failed_subject = self.subject
|
|
213
|
-
|
|
214
|
-
err_details = {'email_of_err': email_of_err, 'err_reason': err_reason,
|
|
215
|
-
'send_time': send_time, 'failed_subject': failed_subject}
|
|
216
|
-
# print(f"Email of err: {email_of_err},\nErr reason: {err_reason}\nSend time: {send_time}")
|
|
217
|
-
return err_details #email_of_err, err_reason, send_time
|
|
218
|
-
|
|
219
|
-
def process_failed_details_msg(self, **kwargs):
|
|
220
|
-
detail_marker_string = kwargs.get('detail_marker_string',
|
|
221
|
-
"Delivery has failed to these recipients or groups:")
|
|
222
|
-
|
|
223
|
-
soup = BeautifulSoup(self.body, features="html.parser")
|
|
224
|
-
|
|
225
|
-
all_p = soup.find_all(name='p') # , attrs={'class': 'MsoNormal'})
|
|
226
|
-
|
|
227
|
-
for para in all_p:
|
|
228
|
-
if detail_marker_string in para.get_text():
|
|
229
|
-
return {** self._extract_from_failed_details_msg(para)}
|
|
230
|
-
return None, None, None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|