class Exchange: """`Exchange` is a library for sending, reading, and deleting emails. `Exchange` is interfacing with Exchange Web Services (EWS). For more information about server settings, see `this Microsoft support article <https://support.microsoft.com/en-us/office/server-settings-you-ll-need-from-your-email-provider-c82de912-adcc-4787-8283-45a1161f3cc3>`_. **Examples** **Robot Framework** .. code-block:: robotframework *** Settings *** Library RPA.Email.Exchange Task Setup Authorize username=${ACCOUNT} password=${PASSWORD} *** Variables *** ${ACCOUNT} ACCOUNT_NAME ${PASSWORD} ACCOUNT_PASSWORD ${RECIPIENT_ADDRESS} RECIPIENT ${IMAGES} myimage.png ${ATTACHMENTS} C:${/}files${/}mydocument.pdf *** Tasks *** Task of sending email Send Message recipients=${RECIPIENT_ADDRESS} ... subject=Exchange Message from RPA Robot ... body=<p>Exchange RPA Robot message body<br><img src='myimage.png'/></p> ... save=${TRUE} ... html=${TRUE} ... images=${IMAGES} ... cc=EMAIL_ADDRESS ... bcc=EMAIL_ADDRESS ... attachments=${ATTACHMENTS} Task of listing messages # Attachments are saved specifically with a keyword Save Attachments ${messages}= List Messages FOR ${msg} IN @{messages} Log Many ${msg} ${attachments}= Run Keyword If "${msg}[subject]"=="about my orders" ... Save Attachments ... ${msg} ... save_dir=${CURDIR}${/}savedir END # Using save_dir all attachments in listed messages are saved ${messages}= List Messages ... INBOX/Problems/sub1 ... criterion=subject:about my orders ... save_dir=${CURDIR}${/}savedir2 FOR ${msg} IN @{messages} Log Many ${msg} END Task of moving messages Move Messages criterion=subject:about my orders ... source=INBOX/Processed Purchase Invoices/sub2 ... target=INBOX/Problems/sub1 **Python** .. code-block:: python from RPA.Email.Exchange import Exchange ex_account = "ACCOUNT_NAME" ex_password = "******" mail = Exchange() mail.authorize(username=ex_account, password=ex_password) mail.send_message( recipients="RECIPIENT", subject="Message from RPA Python", body="RPA Python message body", ) """ # noqa: E501 ROBOT_LIBRARY_SCOPE = "GLOBAL" ROBOT_LIBRARY_DOC_FORMAT = "REST" def __init__(self) -> None: self.logger = logging.getLogger(__name__) self.credentials = None self.config = None self.account = None def authorize( self, username: str, password: str, autodiscover: bool = True, access_type: str = "DELEGATE", server: str = None, primary_smtp_address: str = None, ) -> None: """Connect to Exchange account :param username: account username :param password: account password :param autodiscover: use autodiscover or set it off :param accesstype: default "DELEGATE", other option "IMPERSONATION" :param server: required for configuration options :param primary_smtp_address: by default set to username, but can be set to be different than username """ kwargs = {} kwargs["autodiscover"] = autodiscover kwargs["access_type"] = (DELEGATE if access_type.upper() == "DELEGATE" else IMPERSONATION) kwargs["primary_smtp_address"] = (primary_smtp_address if primary_smtp_address else username) self.credentials = Credentials(username, password) if server: self.config = Configuration(server=server, credentials=self.credentials) kwargs["config"] = self.config else: kwargs["credentials"] = self.credentials self.account = Account(**kwargs) def list_messages( self, folder_name: str = None, criterion: str = None, contains: bool = False, count: int = 100, save_dir: str = None, ) -> list: """List messages in the account inbox. Order by descending received time. :param folder_name: name of the email folder, default INBOX :param criterion: list messages matching criterion :param contains: if matching should be done using `contains` matching and not `equals` matching, default `False` is means `equals` matching :param count: number of messages to list :param save_dir: set to path where attachments should be saved, default None (attachments are not saved) """ # pylint: disable=no-member messages = [] source_folder = self._get_folder_object(folder_name) if criterion: filter_dict = self._get_filter_key_value(criterion, contains) items = source_folder.filter(**filter_dict) else: items = source_folder.all() for item in items.order_by("-datetime_received")[:count]: attachments = [] if save_dir and len(item.attachments) > 0: attachments = self._save_attachments(item, save_dir) messages.append(self._get_email_details(item, attachments)) return messages def list_unread_messages( self, folder_name: str = None, criterion: str = None, contains: bool = False, count: int = 100, save_dir: str = None, ) -> list: """List unread messages in the account inbox. Order by descending received time. :param folder_name: name of the email folder, default INBOX :param criterion: list messages matching criterion :param contains: if matching should be done using `contains` matching and not `equals` matching, default `False` is means `equals` matching :param count: number of messages to list :param save_dir: set to path where attachments should be saved, default None (attachments are not saved) """ messages = self.list_messages(folder_name, criterion, contains, count, save_dir) return [m for m in messages if not m["is_read"]] def _get_all_items_in_folder(self, folder_name=None, parent_folder=None) -> list: if parent_folder is None or parent_folder is self.account.inbox: target_folder = self.account.inbox / folder_name else: target_folder = self.account.inbox / parent_folder / folder_name return target_folder.all() def send_message( self, recipients: str, subject: str = "", body: str = "", attachments: str = None, html: bool = False, images: str = None, cc: str = None, bcc: str = None, save: bool = False, ): """Keyword for sending message through connected Exchange account. :param recipients: list of email addresses, defaults to [] :param subject: message subject, defaults to "" :param body: message body, defaults to "" :param attachments: list of filepaths to attach, defaults to [] :param html: if message content is in HTML, default `False` :param images: list of filepaths for inline use, defaults to [] :param cc: list of email addresses, defaults to [] :param bcc: list of email addresses, defaults to [] :param save: is sent message saved to Sent messages folder or not, defaults to False Email addresses can be prefixed with ``ex:`` to indicate an Exchange account address. Recipients is a `required` parameter. """ recipients, cc, bcc, attachments, images = self._handle_message_parameters( recipients, cc, bcc, attachments, images) self.logger.info("Sending message to %s", ",".join(recipients)) m = Message( account=self.account, subject=subject, body=body, to_recipients=recipients, cc_recipients=cc, bcc_recipients=bcc, ) self._add_attachments_to_msg(attachments, m) self._add_images_inline_to_msg(images, html, body, m) if html: m.body = HTMLBody(body) else: m.body = body if save: m.folder = self.account.sent m.send_and_save() else: m.send() return True def _handle_message_parameters(self, recipients, cc, bcc, attachments, images): if cc is None: cc = [] if bcc is None: bcc = [] if attachments is None: attachments = [] if images is None: images = [] if not isinstance(recipients, list): recipients = recipients.split(",") if not isinstance(cc, list): cc = [cc] if not isinstance(bcc, list): bcc = [bcc] if not isinstance(attachments, list): attachments = str(attachments).split(",") if not isinstance(images, list): images = str(images).split(",") recipients, cc, bcc = self._handle_recipients(recipients, cc, bcc) return recipients, cc, bcc, attachments, images def _handle_recipients(self, recipients, cc, bcc): recipients = [ Mailbox(email_address=p.split("ex:")[1]) if "ex:" in p else p for p in recipients ] cc = [ Mailbox(email_address=p.split("ex:")[1]) if "ex:" in p else p for p in cc ] bcc = [ Mailbox(email_address=p.split("ex:")[1]) if "ex:" in p else p for p in bcc ] return recipients, cc, bcc def _add_attachments_to_msg(self, attachments, msg): for attachment in attachments: attachment = attachment.strip() with open(attachment, "rb") as f: atname = str(Path(attachment).name) fileat = FileAttachment(name=atname, content=f.read()) msg.attach(fileat) def _add_images_inline_to_msg(self, images, html, body, msg): for image in images: image = image.strip() with open(image, "rb") as f: imname = str(Path(image).name) fileat = FileAttachment(name=imname, content=f.read(), content_id=imname) msg.attach(fileat) if html: body = body.replace(imname, f"cid:{imname}") def create_folder(self, folder_name: str = None, parent_folder: str = None) -> bool: """Create email folder :param folder_name: name for the new folder :param parent_folder: name for the parent folder, by default INBOX :return: True if operation was successful, False if not """ if folder_name is None: raise KeyError("'folder_name' is required for create folder") if parent_folder is None or parent_folder is self.account.inbox: parent = self.account.inbox else: parent = self.account.inbox / parent_folder / folder_name self.logger.info( "Create folder '%s'", folder_name, ) new_folder = Folder(parent=parent, name=folder_name) new_folder.save() def delete_folder(self, folder_name: str = None, parent_folder: str = None) -> bool: """Delete email folder :param folder_name: current folder name :param parent_folder: name for the parent folder, by default INBOX :return: True if operation was successful, False if not """ if folder_name is None: raise KeyError("'folder_name' is required for delete folder") if parent_folder is None or parent_folder is self.account.inbox: folder_to_delete = self.account.inbox / folder_name else: folder_to_delete = self.account.inbox / parent_folder / folder_name self.logger.info( "Delete folder '%s'", folder_name, ) folder_to_delete.delete() def rename_folder(self, oldname: str = None, newname: str = None, parent_folder: str = None) -> bool: """Rename email folder :param oldname: current folder name :param newname: new name for the folder :param parent_folder: name for the parent folder, by default INBOX :return: True if operation was successful, False if not """ if oldname is None or newname is None: raise KeyError( "'oldname' and 'newname' are required for rename folder") if parent_folder is None or parent_folder is self.account.inbox: parent = self.account.inbox else: parent = self.account.inbox / parent_folder self.logger.info( "Rename folder '%s' to '%s'", oldname, newname, ) items = self._get_all_items_in_folder(oldname, parent_folder) old_folder = Folder(parent=parent, name=oldname) old_folder.name = newname old_folder.save() items.move(to_folder=parent / newname) self.delete_folder(oldname, parent_folder) def empty_folder( self, folder_name: str = None, parent_folder: str = None, delete_sub_folders: bool = False, ) -> bool: """Empty email folder of all items :param folder_name: current folder name :param parent_folder: name for the parent folder, by default INBOX :param delete_sub_folders: delete sub folders or not, by default False :return: True if operation was successful, False if not """ if folder_name is None: raise KeyError("'folder_name' is required for empty folder") if parent_folder is None: empty_folder = self._get_folder_object(folder_name) else: empty_folder = self._get_folder_object( f"{parent_folder} / {folder_name}") self.logger.info("Empty folder '%s'", empty_folder) empty_folder.empty(delete_sub_folders=delete_sub_folders) def move_messages( self, criterion: str = "", source: str = None, target: str = None, contains: bool = False, ) -> bool: """Move message(s) from source folder to target folder :param criterion: move messages matching this criterion :param source: source folder :param target: target folder :param contains: if matching should be done using `contains` matching and not `equals` matching, default `False` is means `equals` matching :return: boolean result of operation, True if 1+ items were moved else False Criterion examples: - subject:my message subject - body:something in body - sender:[email protected] """ source_folder = self._get_folder_object(source) target_folder = self._get_folder_object(target) if source_folder == target_folder: raise KeyError("Source folder is same as target folder") filter_dict = self._get_filter_key_value(criterion, contains) items = source_folder.filter(**filter_dict) if items and items.count() > 0: items.move(to_folder=target_folder) return True else: self.logger.warning("No items match criterion '%s'", criterion) return False def move_message( self, msg: dict, target: str, ): """Move a message into target folder :param msg: dictionary of the message :param target: path to target folder :raises AttributeError: if `msg` is not a dictionary containing `id` and `changekey` attributes Example: .. code-block:: robotframework ${messages}= List Messages ... INBOX ... criterion=subject:about my orders FOR ${msg} IN @{messages} Run Keyword If "${msg}[sender][email_address]"=="${priority_account}" ... Move Message ${msg} target=INBOX / Problems / priority END """ if not all(k in msg for k in ["id", "changekey"]): raise AttributeError( "Move Message keyword expects message dictionary " 'containing "id" and "changekey" attributes') message_id = [(msg["id"], msg["changekey"])] target_folder = self._get_folder_object(target) self.account.bulk_move(ids=message_id, to_folder=target_folder) def _get_folder_object(self, folder_name): if not folder_name: return self.account.inbox folders = folder_name.split("/") if "inbox" in folders[0].lower(): folders[0] = self.account.inbox folder_object = None for folder in folders: if folder_object: folder_object = folder_object / folder.strip() else: folder_object = folder return folder_object def _get_filter_key_value(self, criterion, contains): if criterion.startswith("subject:"): search_key = "subject" elif criterion.startswith("body:"): search_key = "body" elif criterion.startswith("sender:"): search_key = "sender" else: raise KeyError("Unknown criterion for filtering items '%s'" % criterion) if contains: search_key += "__contains" _, search_val = criterion.split(":", 1) return {search_key: search_val} def wait_for_message( self, criterion: str = "", timeout: float = 5.0, interval: float = 1.0, contains: bool = False, save_dir: str = None, ) -> Any: """Wait for email matching `criterion` to arrive into INBOX. :param criterion: wait for message matching criterion :param timeout: total time in seconds to wait for email, defaults to 5.0 :param interval: time in seconds for new check, defaults to 1.0 :param contains: if matching should be done using `contains` matching and not `equals` matching, default `False` is means `equals` matching :param save_dir: set to path where attachments should be saved, default None (attachments are not saved) :return: list of messages """ self.logger.info("Wait for messages") end_time = time.time() + float(timeout) filter_dict = self._get_filter_key_value(criterion, contains) items = None tz = EWSTimeZone.localzone() right_now = tz.localize(EWSDateTime.now()) # pylint: disable=E1101 while time.time() < end_time: items = self.account.inbox.filter( # pylint: disable=E1101 **filter_dict, datetime_received__gte=right_now) if items.count() > 0: break time.sleep(interval) messages = [] for item in items: attachments = [] if save_dir and len(item.attachments) > 0: attachments = self._save_attachments(item, save_dir) messages.append(self._get_email_details(item, attachments)) if len(messages) == 0: self.logger.info("Did not receive any matching items") return messages def _save_attachments(self, item, save_dir): attachments = [] incoming_items = item.attachments if hasattr(item, "attachments") else item for attachment in incoming_items: if isinstance(attachment, FileAttachment): local_path = os.path.join(save_dir, attachment.name) with open(local_path, "wb") as f, attachment.fp as fp: buffer = fp.read(1024) while buffer: f.write(buffer) buffer = fp.read(1024) self.logger.info("Attachment saved to: %s", local_path) attachments.append({ "name": attachment.name, "content_type": attachment.content_type, "size": attachment.size, "is_contact_photo": attachment.is_contact_photo, "local_path": local_path, }) return attachments def _get_email_details(self, email, attachments): return { "subject": email.subject, "sender": mailbox_to_email_address(email.sender), "datetime_received": email.datetime_received, "folder": str(self._get_folder_object(email.folder)), "body": email.body, "text_body": email.text_body, "received_by": mailbox_to_email_address(email.received_by), "cc_recipients": [mailbox_to_email_address(cc) for cc in email.cc_recipients] if email.cc_recipients else [], "bcc_recipients": [mailbox_to_email_address(bcc) for bcc in email.bcc_recipients] if email.bcc_recipients else [], "is_read": email.is_read, "importance": email.importance, "message_id": email.message_id, "size": email.size, "categories": email.categories, "attachments": attachments, "attachments_object": email.attachments, "id": email.id, "changekey": email.changekey, } def save_attachments(self, message: dict, save_dir: str = None) -> list: """Save attachments in message into given directory :param message: dictionary containing message details :param save_dir: filepath where attachments will be saved :return: list of saved attachments """ return self._save_attachments(message["attachments_object"], save_dir)
# Create all items at once return_ids = a.bulk_create(folder=a.calendar, items=calendar_items) # Bulk fetch, when you have a list of item IDs and want the full objects. Returns a generator. calendar_ids = [(i.id, i.changekey) for i in calendar_items] items_iter = a.fetch(ids=calendar_ids) # If you only want some fields, use the 'only_fields' attribute items_iter = a.fetch(ids=calendar_ids, only_fields=['start', 'subject']) # Bulk update items. Each item must be accompanied by a list of attributes to update updated_ids = a.bulk_update(items=[(i, ('start', 'subject')) for i in calendar_items]) # Move many items to a new folder new_ids = a.bulk_move(ids=calendar_ids, to_folder=a.other_calendar) # Send draft messages in bulk message_ids = a.drafts.all().only('id', 'changekey') new_ids = a.bulk_send(ids=message_ids, save_copy=False) # Delete in bulk delete_results = a.bulk_delete(ids=calendar_ids) # Archive in bulk delete_results = a.bulk_archive(ids=calendar_ids, to_folder=DistinguishedFolderId('inbox')) # Bulk delete items found as a queryset a.inbox.filter(subject__startswith='Invoice').delete()
class ExchangeConnector: """ Connector to Outlook Exchange Mailboxs. This class contains methods suited for automated emails routing. """ def __init__( self, mailbox_address: str, credentials: Credentials, config: Configuration, routing_folder_path: str = None, correction_folder_path: str = None, done_folder_path: str = None, target_column: str = "target", account_args: Dict[str, Any] = None, sender_address: str = None, ): """ Parameters ---------- mailbox_address: str Email address of the mailbox. By default, the login address is used credentials: Credentials Exchangelib credentials to connect to an Exchange mailbox config: Configuration Exchangelib configuration object routing_folder_path: str Path of the base routing folder correction_folder_path: str Path of the base correction folder done_folder_path: str Path of the Done folder target_column: str Name of the DataFrame column containing target folder names account_args: dict Dict containing arguments to instantiate an exchangelib "Account" object. sender_address: str Email address used to send emails. """ self.sender_address = sender_address self.mailbox_address = mailbox_address self.folder_list = None self.target_column = target_column # Default Account parameters if not account_args: account_args = {"autodiscover": True} # Connect to mailbox self.credentials = credentials self.exchangelib_config = config # Mailbox account (Routing, Corrections, etc) self.mailbox_account = Account( self.mailbox_address, credentials=self.credentials, config=self.exchangelib_config, **account_args, ) # Sender accounts (send emails) if sender_address: self.sender_account = Account( self.sender_address, credentials=self.credentials, config=self.exchangelib_config, **account_args, ) logger.info( f"Address {self.sender_address} is set up to send emails." ) else: self.sender_account = None logger.info( f"Sender address not specified, email sending is disabled." ) # Setup correction folder and done folder self.routing_folder_path = routing_folder_path self.correction_folder_path = correction_folder_path self.done_folder_path = done_folder_path logger.info(f"Connected to mailbox {self.mailbox_address}.") def _get_mailbox_path(self, path: str) -> Folder: """ Utils function to get a mailbox Folder from a path string. Ex: - input string : ROUTING - output Folder : Folder at root/Haut de la banque d'informations/Boîte de réception/ROUTING Parameters ---------- path : str String describing the desired path to a mailbox folder Returns ------- mailbox_path: Folder Mailbox Folder corresponding to the input path """ # Default to inbox if not path: return self.mailbox_account.inbox # Start mailbox path from root folder if re.match("/?root/", path, flags=re.I): path = re.split("/?root/", path, flags=re.I)[1] mailbox_path = self.mailbox_account.root # Start mailbox path from inbox folder else: mailbox_path = self.mailbox_account.inbox # Build mailbox path folders = path.split("/") for folder in folders: if folder == "..": mailbox_path = mailbox_path.parent else: mailbox_path = mailbox_path / folder return mailbox_path @staticmethod def _get_folder_path(folder: Folder) -> Union[str, None]: """ Utils function to get the full mailbox path of a folder. - input Folder : Folder("Routing") - output string : root/Haut de la banque d'informations/Boîte de réception/ROUTING Parameters ---------- folder : Folder Mailbox folder Returns ------- path: str Full mailbox path of the input Folder """ if not isinstance(folder, Folder): return None path = folder.name while folder.name != "root": folder = folder.parent path = folder.name + "/" + path return path @property def routing_folder_path(self) -> Union[str, None]: """ Get the path to the Routing folder. Returns ------- path: str Path to the Routing folder """ path = self._get_folder_path(self.routing_folder) return path @routing_folder_path.setter def routing_folder_path(self, routing_folder_path: str): """ Setter for the routing folder. """ self.routing_folder = self._get_mailbox_path(routing_folder_path) folder_path = self._get_folder_path(self.routing_folder) logger.info(f"Routing folder path set to '{folder_path}'") @property def done_folder_path(self) -> Union[str, None]: """ Get the path to the Done folder. Returns ------- path: str Path to the Done folder """ path = self._get_folder_path(self.done_folder) return path @done_folder_path.setter def done_folder_path(self, done_folder_path: str): """ Setter for the done folder. """ if not done_folder_path: self.done_folder = None logger.info(f"Done folder path not set") else: self.done_folder = self._get_mailbox_path(done_folder_path) folder_path = self._get_folder_path(self.done_folder) logger.info(f"Done folder path set to '{folder_path}'") @property def correction_folder_path(self) -> Union[str, None]: """ Get the path to the Correction folder. Returns ------- path: str Path to the Correction folder """ path = self._get_folder_path(self.correction_folder) return path @correction_folder_path.setter def correction_folder_path(self, correction_folder_path: str): """ Setter for the correction folder. """ if not correction_folder_path: self.correction_folder = None logger.info(f"Correction folder path not set") else: self.correction_folder = self._get_mailbox_path(correction_folder_path) folder_path = self._get_folder_path(self.correction_folder) logger.info(f"Correction folder path set to '{folder_path}'") def create_folders(self, folder_list: List[str], base_folder_path: str = None): """Create folders in the mailbox. Parameters ---------- folder_list : list Create folders in the mailbox base_folder_path : str New folders will be created inside at path base_folder_path (Defaults to inbox) """ self.folder_list = folder_list # Setup base folder base_folder = self._get_mailbox_path(base_folder_path) # Check existing folders existing_folders = [f.name for f in base_folder.children] # Create new folders base_folder_name = base_folder_path or "Inbox" for folder_name in folder_list: if folder_name not in existing_folders: f = Folder(parent=base_folder, name=folder_name) f.save() logger.info( f"Created subfolder {folder_name} in folder {base_folder_name}" ) def get_emails( self, max_emails: int = 100, base_folder_path: str = None, ascending: bool = True, ) -> pd.DataFrame: """ Load emails in the inbox. Parameters ---------- max_emails: int Maximum number of emails to load base_folder_path: str Path to folder to fetch ascending: bool Whether emails should be returned in ascending reception date order Returns ------- df_new_emails: pandas.DataFrame DataFrame containing nex emails """ logger.info(f"Reading new emails for mailbox '{self.mailbox_address}'") base_folder = self._get_mailbox_path(base_folder_path) if ascending: order = "datetime_received" else: order = "-datetime_received" all_new_data = ( base_folder.all() .only( "message_id", "datetime_sent", "sender", "to_recipients", "subject", "text_body", "attachments", ) .order_by(order)[:max_emails] ) new_emails = [ self._extract_email_attributes(x) for x in all_new_data if isinstance(x, Message) ] df_new_emails = pd.DataFrame(new_emails) logger.info(f"Read '{len(new_emails)}' new emails") return df_new_emails @staticmethod def _extract_email_attributes(email_item: Message) -> dict: """ Load email attributes of interest such as: - `message_id` field - `body` field - `header` field - `date` field - `from` field - `to` field - `attachment` field Parameters ---------- email_item: exchangelib.Message Exchange Message object Returns ------- email_dict: dict Dict with email attributes of interest """ if not email_item.to_recipients: to_list = list() else: to_list = [i.email_address for i in email_item.to_recipients] if not email_item.attachments: attachments_list = None else: attachments_list = [i.name for i in email_item.attachments] email_dict = { "message_id": email_item.message_id, "body": email_item.text_body or "", "header": email_item.subject or "", "date": email_item.datetime_sent.isoformat(), "from": email_item.sender.email_address or None, "to": to_list, "attachment": attachments_list, } return email_dict def route_emails( self, classified_emails: pd.DataFrame, raise_missing_folder_error: bool = False, id_column: str = "message_id", ): """ Function to route emails to mailbox folders. Parameters ---------- classified_emails: pandas.DataFrame DataFrame containing emails message_id and target folder raise_missing_folder_error: bool Whether an error should be raised when a target folder is missing id_column: str Name of the DataFrame column containing message ids """ target_column = self.target_column target_folders = classified_emails[target_column].unique().tolist() base_folder = self.routing_folder for folder in target_folders: try: destination_folder = base_folder / folder except ErrorFolderNotFound: if raise_missing_folder_error: logger.exception(f"Mailbox (sub)folder '{folder}' not found") raise else: logger.warning(f"Mailbox (sub)folder '{folder}' not found") continue mask = classified_emails[target_column] == folder mids_to_move = classified_emails[mask][id_column] items = self.mailbox_account.inbox.filter(message_id__in=mids_to_move).only( "id", "changekey" ) self.mailbox_account.bulk_move( ids=items, to_folder=destination_folder, chunk_size=5 ) logger.info(f"Moving {mids_to_move.size} emails to folder '{folder}'") def get_corrections( self, max_emails: int = 100, ignore_list: List[str] = tuple(), correction_column_name: str = "correction", ) -> pd.DataFrame: """ When mailbox users find misclassified emails, they should move them to correction folders. This method collects the emails placed in the correction folders. Parameters ---------- max_emails: int Maximum number of emails to fetch at once ignore_list: list List of folders that should be ignored when fetching emails correction_column_name: str Name of the column containing the correction folder in the returned DataFrame Returns ------- df_corrected_emails: pandas.DataFrame DataFrame containing the misclassified emails ids and associated correction folder """ if self.correction_folder is None: raise AttributeError( "You need to set the class attribute `correction_folder_path` to use `get_corrections`." ) logger.info( f"Reading corrected emails from folder and {self.correction_folder}" ) # Get correction folders categories = [ e.name for e in self.correction_folder.children if e.name not in ignore_list ] # Load corrected emails all_corrected_emails = list() for folder_name in categories: folder = self.correction_folder / folder_name messages = ( folder.all() .only( "message_id", "datetime_sent", "sender", "to_recipients", "subject", "text_body", "attachments", ) .order_by("datetime_received")[:max_emails] ) emails = [ self._extract_email_attributes(m) for m in messages if isinstance(m, Message) ] # Add correction folder to email attributes for item in emails: item.update({correction_column_name: folder_name}) all_corrected_emails.extend(emails) logger.info(f"Found {len(emails)} corrected emails in folder {folder}") logger.info(f"Found {len(all_corrected_emails)} corrected emails in total") df_corrected_emails = pd.DataFrame(all_corrected_emails) return df_corrected_emails def move_to_done(self, emails_id: List[str]): """ Once the corrected emails have been processed, they can be moved to a "Done" folder. Parameters ---------- emails_id: list List of emails IDs to be moved to the done folder. """ if (self.correction_folder is None) or (self.done_folder is None): raise AttributeError( "You need to set the class attribute `done_folder_path` " "and the class attribute `correction_folder_path` to use `move_to_done`." ) # Collect corrected emails items = self.correction_folder.children.filter(message_id__in=emails_id).only( "id", "changekey" ) n_items = items.count() # Move to done folder self.mailbox_account.bulk_move( ids=items, to_folder=self.done_folder, chunk_size=5 ) logger.info( f"Moved {n_items} corrected emails to the folder {self.done_folder_path}" ) def list_subfolders(self, base_folder_path: str = None): """ List the sub-folders of the specified folder. Parameters ---------- base_folder_path: str Path to folder to be inspected """ base_folder = self._get_mailbox_path(base_folder_path) return [f.name for f in base_folder.children] def send_email( self, to: Union[str, List[str]], header: str, body: str, attachments: dict ): """ This method sends an email from the login address (attribute login_address). Parameters ---------- to: str or list Address or list of addresses of email recipients header: str Email header body: str Email body attachments: dict Dict containing attachment names as key and attachment file contents as values. Currently, the code is tested for DataFrame attachments only. """ if self.sender_account is None: raise AttributeError( "To send emails, you need to specify a `sender_address` when initializing " "the ExchangeConnector class." ) if isinstance(to, str): to = [to] # Prepare Message object m = Message( account=self.sender_account, subject=header, body=HTMLBody(body), to_recipients=to, ) if attachments: for key, value in attachments.items(): m.attach(FileAttachment(name=key, content=bytes(value, "utf-8"))) # Send email m.send() logger.info(f"Email sent from address '{self.sender_address}'")
class Exchange: """`Exchange` is a library for sending, reading, and deleting emails. `Exchange` is interfacing with Exchange Web Services (EWS). For more information about server settings, see `this Microsoft support article <https://support.microsoft.com/en-us/office/server-settings-you-ll-need-from-your-email-provider-c82de912-adcc-4787-8283-45a1161f3cc3>`_. **Examples** **Robot Framework** .. code-block:: robotframework *** Settings *** Library RPA.Email.Exchange Task Setup Authorize username=${ACCOUNT} password=${PASSWORD} *** Variables *** ${ACCOUNT} ACCOUNT_NAME ${PASSWORD} ACCOUNT_PASSWORD ${RECIPIENT_ADDRESS} RECIPIENT ${IMAGES} myimage.png ${ATTACHMENTS} C:${/}files${/}mydocument.pdf *** Tasks *** Task of sending email Send Message recipients=${RECIPIENT_ADDRESS} ... subject=Exchange Message from RPA Robot ... body=<p>Exchange RPA Robot message body<br><img src='myimage.png'/></p> ... save=${TRUE} ... html=${TRUE} ... images=${IMAGES} ... cc=EMAIL_ADDRESS ... bcc=EMAIL_ADDRESS ... attachments=${ATTACHMENTS} Task of listing messages # Attachments are saved specifically with a keyword Save Attachments ${messages}= List Messages FOR ${msg} IN @{messages} Log Many ${msg} ${attachments}= Run Keyword If "${msg}[subject]"=="about my orders" ... Save Attachments ... ${msg} ... save_dir=${CURDIR}${/}savedir END # Using save_dir all attachments in listed messages are saved ${messages}= List Messages ... INBOX/Problems/sub1 ... criterion=subject:'about my orders' ... save_dir=${CURDIR}${/}savedir2 FOR ${msg} IN @{messages} Log Many ${msg} END Task of moving messages Move Messages criterion=subject:'about my orders' ... source=INBOX/Processed Purchase Invoices/sub2 ... target=INBOX/Problems/sub1 **Python** .. code-block:: python from RPA.Email.Exchange import Exchange ex_account = "ACCOUNT_NAME" ex_password = "******" mail = Exchange() mail.authorize(username=ex_account, password=ex_password) mail.send_message( recipients="RECIPIENT", subject="Message from RPA Python", body="RPA Python message body", ) **About criterion parameter** Following table shows possible criterion keys that can be used to filter emails. There apply to all keywords which have ``criterion`` parameter. ================= ================ Key Effective search ================= ================ subject subject to match subject_contains subject to contain body body to match body_contains body to contain sender sender (from) to match sender_contains sender (from) to contain before received time before this time after received time after this time between received time between start and end category categories to match category_contains categories to contain importance importance to match ================= ================ Keys `before`, `after` and `between` at the moment support two different timeformats either `%d-%m-%Y %H:%M` or `%d-%m-%Y`. These keys also support special string `NOW` which can be used especially together with keyword ``Wait for message criterion=after:NOW``. When giving time which includes hours and minutes then the whole time string needs to be enclosed into single quotes. .. code-block:: bash before:25-02-2022 after:NOW between:'31-12-2021 23:50 and 01-01-2022 00:10' Different criterion keys can be combined. .. code-block:: bash subject_contains:'new year' between:'31-12-2021 23:50 and 01-01-2022 00:10' Please **note** that all values in the criterion that contain spaces need to be enclosed within single quotes. In the following example the email `subject` is going to matched only against `new` not `new year`. .. code-block:: bash subject_contains:new year """ # noqa: E501 ROBOT_LIBRARY_SCOPE = "GLOBAL" ROBOT_LIBRARY_DOC_FORMAT = "REST" def __init__(self) -> None: self.logger = logging.getLogger(__name__) self.credentials = None self.config = None self.account = None self._saved_attachments = [] def authorize( self, username: str, password: str, autodiscover: Optional[bool] = True, access_type: Optional[str] = "DELEGATE", server: Optional[str] = None, primary_smtp_address: Optional[str] = None, ) -> None: """Connect to Exchange account :param username: account username :param password: account password :param autodiscover: use autodiscover or set it off :param accesstype: default "DELEGATE", other option "IMPERSONATION" :param server: required for configuration options :param primary_smtp_address: by default set to username, but can be set to be different than username """ kwargs = {} kwargs["autodiscover"] = autodiscover kwargs["access_type"] = (DELEGATE if access_type.upper() == "DELEGATE" else IMPERSONATION) kwargs["primary_smtp_address"] = (primary_smtp_address if primary_smtp_address else username) self.credentials = Credentials(username, password) if server: self.config = Configuration(server=server, credentials=self.credentials) kwargs["config"] = self.config else: kwargs["credentials"] = self.credentials self.account = Account(**kwargs) def list_messages( self, folder_name: Optional[str] = None, criterion: Optional[str] = None, contains: Optional[bool] = False, # pylint: disable=unused-argument count: Optional[int] = 100, save_dir: Optional[str] = None, ) -> list: """List messages in the account inbox. Order by descending received time. :param folder_name: name of the email folder, default INBOX :param criterion: list messages matching criterion :param contains: if matching should be done using `contains` matching and not `equals` matching, default `False` is means `equals` matching :param count: number of messages to list :param save_dir: set to path where attachments should be saved, default None (attachments are not saved) """ # pylint: disable=no-member messages = [] source_folder = self._get_folder_object(folder_name) if criterion: filter_dict = self._get_filter_key_value(criterion) items = source_folder.filter(**filter_dict) else: items = source_folder.all() for item in items.order_by("-datetime_received")[:count]: attachments = [] if save_dir and len(item.attachments) > 0: attachments = self._save_attachments(item, save_dir) messages.append(self._get_email_details(item, attachments)) return messages def list_unread_messages( self, folder_name: Optional[str] = None, criterion: Optional[str] = None, contains: Optional[bool] = False, count: Optional[int] = 100, save_dir: Optional[str] = None, ) -> list: """List unread messages in the account inbox. Order by descending received time. :param folder_name: name of the email folder, default INBOX :param criterion: list messages matching criterion :param contains: if matching should be done using `contains` matching and not `equals` matching, default `False` is means `equals` matching :param count: number of messages to list :param save_dir: set to path where attachments should be saved, default None (attachments are not saved) """ messages = self.list_messages(folder_name, criterion, contains, count, save_dir) return [m for m in messages if not m["is_read"]] def _get_all_items_in_folder(self, folder_name=None, parent_folder=None) -> list: if parent_folder is None or parent_folder is self.account.inbox: target_folder = self.account.inbox / folder_name else: target_folder = self.account.inbox / parent_folder / folder_name return target_folder.all() def send_message( self, recipients: Optional[Union[List[str], str]] = None, subject: Optional[str] = "", body: Optional[str] = "", attachments: Optional[Union[List[str], str]] = None, html: Optional[bool] = False, images: Optional[Union[List[str], str]] = None, cc: Optional[Union[List[str], str]] = None, bcc: Optional[Union[List[str], str]] = None, save: Optional[bool] = False, ) -> None: """Keyword for sending message through connected Exchange account. :param recipients: list of email addresses :param subject: message subject, defaults to "" :param body: message body, defaults to "" :param attachments: list of filepaths to attach, defaults to `None` :param html: if message content is in HTML, default `False` :param images: list of filepaths for inline use, defaults to `None` :param cc: list of email addresses :param bcc: list of email addresses :param save: is sent message saved to Sent messages folder or not, defaults to False Email addresses can be prefixed with ``ex:`` to indicate an Exchange account address. At least one target needs to exist for `recipients`, `cc` or `bcc`. """ if not self.account: raise AuthenticationError("Not authorized to any Exchange account") recipients, cc, bcc, attachments, images = self._handle_message_parameters( recipients, cc, bcc, attachments, images) if not recipients and not cc and not bcc: raise NoRecipientsError( "Atleast one address is required for 'recipients', 'cc' or 'bcc' parameter" # noqa: E501 ) self.logger.info("Sending message to %s", ",".join(recipients)) m = Message( account=self.account, subject=subject, body=body, to_recipients=recipients, cc_recipients=cc, bcc_recipients=bcc, ) self._add_attachments_to_msg(attachments, m) self._add_images_inline_to_msg(images, html, body, m) if html: m.body = HTMLBody(body) else: m.body = body # TODO. The exchangelib does not seem to provide any straightforward way of # verifying if message was sent or not if save: m.folder = self.account.sent m.send_and_save() else: m.send() def _handle_message_parameters(self, recipients, cc, bcc, attachments, images): recipients = recipients or [] cc = cc or [] bcc = bcc or [] attachments = attachments or [] images = images or [] if not isinstance(recipients, list): recipients = recipients.split(",") if not isinstance(cc, list): cc = cc.split(",") if not isinstance(bcc, list): bcc = bcc.split(",") if not isinstance(attachments, list): attachments = str(attachments).split(",") if not isinstance(images, list): images = str(images).split(",") recipients, cc, bcc = self._handle_recipients(recipients, cc, bcc) return recipients, cc, bcc, attachments, images def _handle_recipients(self, recipients, cc, bcc): recipients = [ Mailbox(email_address=p.split("ex:")[1]) if "ex:" in p else p for p in recipients ] cc = [ Mailbox(email_address=p.split("ex:")[1]) if "ex:" in p else p for p in cc ] bcc = [ Mailbox(email_address=p.split("ex:")[1]) if "ex:" in p else p for p in bcc ] return recipients, cc, bcc def _add_attachments_to_msg(self, attachments, msg): for attachment in attachments: attachment = attachment.strip() with open(attachment, "rb") as f: atname = str(Path(attachment).name) fileat = FileAttachment(name=atname, content=f.read()) msg.attach(fileat) def _add_images_inline_to_msg(self, images, html, body, msg): for image in images: image = image.strip() with open(image, "rb") as f: imname = str(Path(image).name) fileat = FileAttachment(name=imname, content=f.read(), content_id=imname) msg.attach(fileat) if html: body = body.replace(imname, f"cid:{imname}") def create_folder(self, folder_name: Optional[str] = None, parent_folder: Optional[str] = None) -> bool: """Create email folder :param folder_name: name for the new folder :param parent_folder: name for the parent folder, by default INBOX :return: True if operation was successful, False if not """ if folder_name is None: raise KeyError("'folder_name' is required for create folder") if parent_folder is None or parent_folder is self.account.inbox: parent = self.account.inbox else: parent = self.account.inbox / parent_folder / folder_name self.logger.info( "Create folder '%s'", folder_name, ) new_folder = Folder(parent=parent, name=folder_name) new_folder.save() def delete_folder(self, folder_name: Optional[str] = None, parent_folder: Optional[str] = None) -> bool: """Delete email folder :param folder_name: current folder name :param parent_folder: name for the parent folder, by default INBOX :return: True if operation was successful, False if not """ if folder_name is None: raise KeyError("'folder_name' is required for delete folder") if parent_folder is None or parent_folder is self.account.inbox: folder_to_delete = self.account.inbox / folder_name else: folder_to_delete = self.account.inbox / parent_folder / folder_name self.logger.info( "Delete folder '%s'", folder_name, ) folder_to_delete.delete() def rename_folder( self, oldname: Optional[str] = None, newname: Optional[str] = None, parent_folder: Optional[str] = None, ) -> bool: """Rename email folder :param oldname: current folder name :param newname: new name for the folder :param parent_folder: name for the parent folder, by default INBOX :return: True if operation was successful, False if not """ if oldname is None or newname is None: raise KeyError( "'oldname' and 'newname' are required for rename folder") if parent_folder is None or parent_folder is self.account.inbox: parent = self.account.inbox else: parent = self.account.inbox / parent_folder self.logger.info( "Rename folder '%s' to '%s'", oldname, newname, ) items = self._get_all_items_in_folder(oldname, parent_folder) old_folder = Folder(parent=parent, name=oldname) old_folder.name = newname old_folder.save() items.move(to_folder=parent / newname) self.delete_folder(oldname, parent_folder) def empty_folder( self, folder_name: Optional[str] = None, parent_folder: Optional[str] = None, delete_sub_folders: Optional[bool] = False, ) -> bool: """Empty email folder of all items :param folder_name: current folder name :param parent_folder: name for the parent folder, by default INBOX :param delete_sub_folders: delete sub folders or not, by default False :return: True if operation was successful, False if not """ if folder_name is None: raise KeyError("'folder_name' is required for empty folder") if parent_folder is None: empty_folder = self._get_folder_object(folder_name) else: empty_folder = self._get_folder_object( f"{parent_folder} / {folder_name}") self.logger.info("Empty folder '%s'", empty_folder) empty_folder.empty(delete_sub_folders=delete_sub_folders) def move_messages( self, criterion: Optional[str] = "", source: Optional[str] = None, target: Optional[str] = None, contains: Optional[bool] = False, # pylint: disable=unused-argument ) -> bool: """Move message(s) from source folder to target folder :param criterion: move messages matching this criterion :param source: source folder :param target: target folder :param contains: if matching should be done using `contains` matching and not `equals` matching, default `False` is means `equals` matching :return: boolean result of operation, True if 1+ items were moved else False Criterion examples: - subject:my message subject - body:something in body - sender:[email protected] """ source_folder = self._get_folder_object(source) target_folder = self._get_folder_object(target) if source_folder == target_folder: raise KeyError("Source folder is same as target folder") filter_dict = self._get_filter_key_value(criterion) items = source_folder.filter(**filter_dict) if items and items.count() > 0: items.move(to_folder=target_folder) return True else: self.logger.warning("No items match criterion '%s'", criterion) return False def move_message( self, msg: Optional[dict], target: Optional[str], ): """Move a message into target folder :param msg: dictionary of the message :param target: path to target folder :raises AttributeError: if `msg` is not a dictionary containing `id` and `changekey` attributes Example: .. code-block:: robotframework ${messages}= List Messages ... INBOX ... criterion=subject:about my orders FOR ${msg} IN @{messages} Run Keyword If "${msg}[sender][email_address]"=="${priority_account}" ... Move Message ${msg} target=INBOX / Problems / priority END """ if not all(k in msg for k in ["id", "changekey"]): raise AttributeError( "Move Message keyword expects message dictionary " 'containing "id" and "changekey" attributes') message_id = [(msg["id"], msg["changekey"])] target_folder = self._get_folder_object(target) self.account.bulk_move(ids=message_id, to_folder=target_folder) def _get_folder_object(self, folder_name): if not folder_name: return self.account.inbox folders = folder_name.split("/") if "inbox" in folders[0].lower(): folders[0] = self.account.inbox folder_object = None for folder in folders: if folder_object: folder_object = folder_object / folder.strip() else: folder_object = folder return folder_object def _get_filter_key_value(self, criterion): regex1 = rf"({':|'.join(EMAIL_CRITERIA_KEYS)}:|or|and)'(.*?)'" regex2 = rf"({':|'.join(EMAIL_CRITERIA_KEYS)}:|or|and)(\S*)\s*" parts = re.findall(regex1, criterion, re.IGNORECASE) valid_filters = {} for part in parts: res = self._parse_email_criteria(part) if not res or len(res) != 2: continue self.logger.debug("First regex pass: %s %s", res[0], res[1]) if res[0] not in valid_filters.keys(): valid_filters[res[0]] = res[1] parts = re.findall(regex2, criterion, re.IGNORECASE) for part in parts: res = self._parse_email_criteria(part) if not res or len(res) != 2: continue self.logger.debug("Second regex pass: %s %s", res[0], res[1]) if res[0] not in valid_filters.keys(): valid_filters[res[0]] = res[1] if criterion and criterion != "" and len(valid_filters) == 0: raise KeyError("Invalid criterion '%s'" % criterion) self.logger.info( "Using filter: %s", ",".join(["{}:{}".format(k, v) for k, v in valid_filters.items()]), ) return valid_filters def _parse_email_criteria(self, part): original_key, value = part original_key = original_key.replace(":", "") if original_key in ["and", "or", "contains"]: # Note. Not implemented yet return None key = None if original_key in EMAIL_CRITERIA_KEYS.keys(): key = EMAIL_CRITERIA_KEYS[original_key] else: raise KeyError("Unknown email criteria key '%s'" % original_key) if value.startswith("'") and value.endswith("'"): value = value[1:-1] value = self._handle_specific_keys(original_key, value) self.logger.debug("Returning parsed criteria (%s, %s)", key, value) return key, value def _parse_date_from_string(self, date_string): date = None if date_string.upper() == "NOW": right_now = datetime.datetime.now() return right_now.astimezone(pytz.utc) try: date = datetime.datetime.strptime(date_string, "%d-%m-%Y %H:%M") return self._date_for_exchange(date) except ValueError: pass try: date = datetime.datetime.strptime(date_string, "%d-%m-%Y") return self._date_for_exchange(date) except ValueError: pass return date def _date_for_exchange(self, date): return datetime.datetime( date.year, date.month, date.day, date.hour, date.minute, tzinfo=UTC, ) def _handle_specific_keys(self, key, value): if key.upper() == "BEFORE": start = datetime.datetime(1972, 1, 1, tzinfo=UTC) end = self._parse_date_from_string(value) value = (start, end) if key.upper() == "AFTER": start = self._parse_date_from_string(value) end = datetime.datetime(2050, 1, 1, tzinfo=UTC) value = (start, end) if key.upper() == "BETWEEN": ranges = value.upper().split(" AND ") if len(ranges) == 2: start = self._parse_date_from_string(ranges[0]) end = self._parse_date_from_string(ranges[1]) value = (start, end) else: return None if key.upper() == "IMPORTANCE": try: value = value.capitalize() except ValueError: return None return value def wait_for_message( self, criterion: Optional[str] = "", timeout: Optional[float] = 5.0, interval: Optional[float] = 1.0, contains: Optional[bool] = False, # pylint: disable=unused-argument save_dir: Optional[str] = None, ) -> Any: """Wait for email matching `criterion` to arrive into INBOX. :param criterion: wait for message matching criterion :param timeout: total time in seconds to wait for email, defaults to 5.0 :param interval: time in seconds for new check, defaults to 1.0 (minimum) :param contains: if matching should be done using `contains` matching and not `equals` matching, default `False` is means `equals` matching THIS PARAMETER IS DEPRECATED AS OF rpaframework 12.9.0 :param save_dir: set to path where attachments should be saved, default None (attachments are not saved) :return: list of messages """ self.logger.info("Wait for messages") end_time = time.time() + float(timeout) filter_dict = self._get_filter_key_value(criterion) items = None # minimum interval is 1.0 seconds interval = max(interval, 1.0) while time.time() < end_time: items = self.account.inbox.filter(**filter_dict) # pylint: disable=E1101 if items.count() > 0: break time.sleep(interval) messages = [] for item in items: attachments = [] if save_dir and len(item.attachments) > 0: attachments = self._save_attachments(item, save_dir) messages.append(self._get_email_details(item, attachments)) if len(messages) == 0: self.logger.info("Did not receive any matching items") return messages def _save_attachments(self, item, save_dir, attachments_from_emls: bool = False): self._saved_attachments = self._saved_attachments or [] incoming_items = item.attachments if hasattr(item, "attachments") else item for attachment in incoming_items: self.logger.info("Attachment type: %s", type(attachment)) if isinstance(attachment, FileAttachment): local_path = os.path.join(save_dir, attachment.name) with open(local_path, "wb") as f, attachment.fp as fp: buffer = fp.read(1024) while buffer: f.write(buffer) buffer = fp.read(1024) self.logger.info("Attachment saved to: %s", local_path) self._saved_attachments.append( self._new_attachment_dictionary(attachment, local_path)) elif isinstance(attachment, ItemAttachment): local_path = os.path.join(save_dir, attachment.name) with open(local_path, "wb") as message_out: message_out.write(attachment.item.mime_content) self.logger.info("Attachment saved to: %s", local_path) self._saved_attachments.append( self._new_attachment_dictionary(attachment, local_path)) if attachments_from_emls: self._save_attachments(attachment.item, save_dir, False) return self._saved_attachments def _new_attachment_dictionary(self, attachment, local_path): return { "name": attachment.name, "content_type": attachment.content_type, "size": attachment.size, "is_contact_photo": attachment.is_contact_photo if hasattr(attachment, "is_contact_photo") else False, "local_path": local_path, } def _get_email_details(self, item, attachments): return { "subject": item.subject, "sender": mailbox_to_email_address(item.sender), "datetime_received": item.datetime_received, "folder": str(self._get_folder_object(item.folder)), "body": item.body, "text_body": item.text_body, "received_by": mailbox_to_email_address(item.received_by), "cc_recipients": [mailbox_to_email_address(cc) for cc in item.cc_recipients] if item.cc_recipients else [], "bcc_recipients": [mailbox_to_email_address(bcc) for bcc in item.bcc_recipients] if item.bcc_recipients else [], "is_read": item.is_read, "importance": item.importance, "message_id": item.message_id, "size": item.size, "categories": item.categories, "has_attachments": len(item.attachments) > 0, "attachments": attachments, "attachments_object": item.attachments, "id": item.id, "changekey": item.changekey, "mime_content": item.mime_content, } def save_attachments( self, message: Union[dict, str], save_dir: Optional[str] = None, attachments_from_emls: Optional[bool] = False, ) -> list: """Save attachments in message into given directory :param message: dictionary or .eml filepath containing message details :param save_dir: filepath where attachments will be saved :param attachments_from_emls: if attachment is a EML file, set to True to save attachments from that EML file, default False :return: list of saved attachments Example. .. code:: robotframework ${messages}= List Messages FOR ${msg} IN @{messages}} Save Attachments ${msg} %{ROBOT_ARTIFACTS} True END ${attachments}= Save Attachments ${CURDIR}${/}saved.eml %{ROBOT_ARTIFACTS} """ # noqa: E501 self._saved_attachments = [] if isinstance(message, dict): return self._save_attachments(message["attachments_object"], save_dir, attachments_from_emls) else: # extract attachments from .eml file absolute_filepath = Path(message).resolve() if absolute_filepath.suffix != ".eml": raise ValueError("Filename extension needs to be '.eml'") return self._save_attachments_from_file(message, save_dir) def _save_attachments_from_file(self, filename: str, save_dir: str): """ Try to extract the attachments from given .eml file """ # ensure that an output dir exists attachments = [] with open(filename, "r") as f: # pylint: disable=unspecified-encoding msg = email.message_from_file( # pylint: disable=no-member f, policy=policy.default) for attachment in msg.iter_attachments(): output_filename = attachment.get_filename() # If no attachments are found, skip this file if output_filename: local_path = os.path.join(save_dir, output_filename) with open(local_path, "wb") as of: payload = attachment.get_payload(decode=True) of.write(payload) attachments.append({ "name": output_filename, "content_type": None, "size": len(payload), "is_contact_photo": None, "local_path": local_path, }) if len(attachments) == 0: self.logger.warning("No attachment found for file %s!", f.name) return attachments def save_message(self, message: dict, filename: str) -> list: """Save email as .eml file :param message: dictionary containing message details :param filename: name of the file to save message into """ absolute_filepath = Path(filename).resolve() if absolute_filepath.suffix != ".eml": raise ValueError("Filename extension needs to be '.eml'") with open(absolute_filepath, "wb") as message_out: message_out.write(message["mime_content"])