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.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyEmailerAJM
3
- Version: 1.6.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.1.tar.gz
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
- from PyEmailerAJM import BasicEmailFolderChoices, deprecated
30
- from PyEmailerAJM import Msg, FailedMsg
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
- self._logger = self._initialize_logger(logger, use_default_logger=kwargs.get('use_default_logger', False))
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._logger)
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._logger.error(e, exc_info=True)
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._logger.debug(f"{self.email_app_name} app in use.")
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._logger.debug(f"{self.namespace_name} namespace in use.")
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 _SubjectSearcher:
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._logger.error(e, exc_info=True)
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._logger.warning(e)
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._logger.warning(e)
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._logger.warning(e)
283
- self._logger.warning("Defaulting to basic y/n prompt.")
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._logger.warning(self.DisplayEmailSendTrackingWarning)
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._logger.error(e, exc_info=True)
195
+ self.logger.error(e, exc_info=True)
304
196
  raise e
305
197
 
306
- def _GetReadFolder(self, email_dir_index: int = BasicEmailFolderChoices.INBOX):
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._logger.error(e, exc_info=True)
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._logger.error(e, exc_info=True)
242
+ self.logger.error(e, exc_info=True)
330
243
  raise e
331
- return [Msg(m, logger=self._logger) for m in self.read_folder.Items]
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._logger.error(e, exc_info=True)
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._logger.debug(f"{full_save_path} saved from email with subject {msg.subject}")
272
+ self.logger.debug(f"{full_save_path} saved from email with subject {msg.subject}")
360
273
  except Exception as e:
361
- self._logger.error(e, exc_info=True)
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._logger, **kwargs)
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._logger.info(f"Mail not sent to {self.email.to}")
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._logger.warning("Quitting early due to user input.")
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._logger.error(e, exc_info=True)
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._logger.warning(e)
393
- self._logger.warning("defaulting to basic input style...")
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._logger.info(f"Mail not sent to {self.email.to}")
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._logger.warning("Quitting early due to user input.")
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._logger.info(f"Ready to send/display mail to/for {self.email.to}...")
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._logger.warning(send_and_display_warning)
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._logger.info("Sending emails with auto_send...")
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._logger.warning(both_disabled_warning)
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._logger.error(e, exc_info=True)
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._logger.info(f"{len(msg_candidates)} 'failed send' candidates found.")
460
- self._logger.info("mutating msg_candidates (Msg instances) into FailedMsg instances.")
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._logger.hasHandlers() or not any([isinstance(x, StreamHandler)
473
- for x in self._logger.handlers])):
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._logger.info(results_string)
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=True)
499
- m = em.find_messages_by_subject('Andrew', partial_match_ok=True, include_re=True, include_fw=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
- # x = emailer.find_messages_by_subject("Timecard", partial_match_ok=False, include_re=False)
504
- # #print([(m.SenderEmailAddress, m.SenderEmailType, [x.name for x in m.ItemProperties]) for m in x])
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.6.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.1.tar.gz
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
@@ -2,3 +2,5 @@ pywin32
2
2
  extract_msg
3
3
  email_validator
4
4
  questionary
5
+ EasyLoggerAJM
6
+ ColorizerAJM
@@ -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,6 +0,0 @@
1
- class EmailerNotSetupError(Exception):
2
- ...
3
-
4
-
5
- class DisplayManualQuit(Exception):
6
- ...
@@ -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