Ejemplo n.º 1
0
    def __init__(self, *, parent=None, con=None, **kwargs):
        """ Create a contact API component

        :param parent: parent account for this folder
        :type parent: Account
        :param Connection con: connection to use if no parent specified
        :param Protocol protocol: protocol to use if no parent specified
         (kwargs)
        :param str main_resource: use this resource instead of parent resource
         (kwargs)
        """
        assert parent or con, 'Need a parent or a connection'
        self.con = parent.con if parent else con

        # Choose the main_resource passed in kwargs over parent main_resource
        main_resource = kwargs.pop('main_resource', None) or getattr(
            parent, 'main_resource', None) if parent else None
        super().__init__(
            protocol=parent.protocol if parent else kwargs.get('protocol'),
            main_resource=main_resource)

        cloud_data = kwargs.get(self._cloud_data_key, {})
        cc = self._cc  # alias to shorten the code

        # internal to know which properties need to be updated on the server
        self._track_changes = TrackerSet(casing=cc)

        self.object_id = cloud_data.get(cc('id'), None)
        self.__created = cloud_data.get(cc('createdDateTime'), None)
        self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None)

        local_tz = self.protocol.timezone
        self.__created = parse(
            self.created).astimezone(local_tz) if self.__created else None
        self.__modified = parse(
            self.modified).astimezone(local_tz) if self.__modified else None

        self.__display_name = cloud_data.get(cc('displayName'), '')
        self.__name = cloud_data.get(cc('givenName'), '')
        self.__surname = cloud_data.get(cc('surname'), '')

        self.__title = cloud_data.get(cc('title'), '')
        self.__job_title = cloud_data.get(cc('jobTitle'), '')
        self.__company_name = cloud_data.get(cc('companyName'), '')
        self.__department = cloud_data.get(cc('department'), '')
        self.__office_location = cloud_data.get(cc('officeLocation'), '')
        self.__business_phones = cloud_data.get(cc('businessPhones'), []) or []
        self.__mobile_phone = cloud_data.get(cc('mobilePhone'), '')
        self.__home_phones = cloud_data.get(cc('homePhones'), []) or []

        emails = cloud_data.get(cc('emailAddresses'), [])
        self.__emails = Recipients(recipients=[(rcp.get(cc('name'), ''),
                                                rcp.get(cc('address'), ''))
                                               for rcp in emails],
                                   parent=self,
                                   field=cc('emailAddresses'))
        email = cloud_data.get(cc('email'))
        self.__emails.untrack = True
        if email and email not in self.__emails:
            # a Contact from OneDrive?
            self.__emails.add(email)
        self.__business_address = cloud_data.get(cc('businessAddress'), {})
        self.__home_address = cloud_data.get(cc('homesAddress'), {})
        self.__other_address = cloud_data.get(cc('otherAddress'), {})
        self.__preferred_language = cloud_data.get(cc('preferredLanguage'),
                                                   None)

        self.__categories = cloud_data.get(cc('categories'), [])
        self.__folder_id = cloud_data.get(cc('parentFolderId'), None)

        # When using Users endpoints (GAL)
        # Missing keys: ['mail', 'userPrincipalName']
        mail = cloud_data.get(cc('mail'), None)
        user_principal_name = cloud_data.get(cc('userPrincipalName'), None)
        if mail and mail not in self.emails:
            self.emails.add(mail)
        if user_principal_name and user_principal_name not in self.emails:
            self.emails.add(user_principal_name)
        self.__emails.untrack = False
Ejemplo n.º 2
0
class Contact(ApiComponent, AttachableMixin):
    """ Contact manages lists of events on associated contact on office365. """

    _endpoints = {
        'contact': '/contacts',
        'root_contact': '/contacts/{id}',
        'child_contact': '/contactFolders/{folder_id}/contacts'
    }

    message_constructor = Message

    def __init__(self, *, parent=None, con=None, **kwargs):
        """ Create a contact API component

        :param parent: parent account for this folder
        :type parent: Account
        :param Connection con: connection to use if no parent specified
        :param Protocol protocol: protocol to use if no parent specified
         (kwargs)
        :param str main_resource: use this resource instead of parent resource
         (kwargs)
        """
        assert parent or con, 'Need a parent or a connection'
        self.con = parent.con if parent else con

        # Choose the main_resource passed in kwargs over parent main_resource
        main_resource = kwargs.pop('main_resource', None) or getattr(
            parent, 'main_resource', None) if parent else None
        super().__init__(
            protocol=parent.protocol if parent else kwargs.get('protocol'),
            main_resource=main_resource)

        cloud_data = kwargs.get(self._cloud_data_key, {})
        cc = self._cc  # alias to shorten the code

        # internal to know which properties need to be updated on the server
        self._track_changes = TrackerSet(casing=cc)

        self.object_id = cloud_data.get(cc('id'), None)
        self.__created = cloud_data.get(cc('createdDateTime'), None)
        self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None)

        local_tz = self.protocol.timezone
        self.__created = parse(
            self.created).astimezone(local_tz) if self.__created else None
        self.__modified = parse(
            self.modified).astimezone(local_tz) if self.__modified else None

        self.__display_name = cloud_data.get(cc('displayName'), '')
        self.__name = cloud_data.get(cc('givenName'), '')
        self.__surname = cloud_data.get(cc('surname'), '')

        self.__title = cloud_data.get(cc('title'), '')
        self.__job_title = cloud_data.get(cc('jobTitle'), '')
        self.__company_name = cloud_data.get(cc('companyName'), '')
        self.__department = cloud_data.get(cc('department'), '')
        self.__office_location = cloud_data.get(cc('officeLocation'), '')
        self.__business_phones = cloud_data.get(cc('businessPhones'), []) or []
        self.__mobile_phone = cloud_data.get(cc('mobilePhone'), '')
        self.__home_phones = cloud_data.get(cc('homePhones'), []) or []

        emails = cloud_data.get(cc('emailAddresses'), [])
        self.__emails = Recipients(recipients=[(rcp.get(cc('name'), ''),
                                                rcp.get(cc('address'), ''))
                                               for rcp in emails],
                                   parent=self,
                                   field=cc('emailAddresses'))
        email = cloud_data.get(cc('email'))
        self.__emails.untrack = True
        if email and email not in self.__emails:
            # a Contact from OneDrive?
            self.__emails.add(email)
        self.__business_address = cloud_data.get(cc('businessAddress'), {})
        self.__home_address = cloud_data.get(cc('homesAddress'), {})
        self.__other_address = cloud_data.get(cc('otherAddress'), {})
        self.__preferred_language = cloud_data.get(cc('preferredLanguage'),
                                                   None)

        self.__categories = cloud_data.get(cc('categories'), [])
        self.__folder_id = cloud_data.get(cc('parentFolderId'), None)

        # When using Users endpoints (GAL)
        # Missing keys: ['mail', 'userPrincipalName']
        mail = cloud_data.get(cc('mail'), None)
        user_principal_name = cloud_data.get(cc('userPrincipalName'), None)
        if mail and mail not in self.emails:
            self.emails.add(mail)
        if user_principal_name and user_principal_name not in self.emails:
            self.emails.add(user_principal_name)
        self.__emails.untrack = False

    @property
    def created(self):
        """ Created Time

        :rtype: datetime
        """
        return self.__created

    @property
    def modified(self):
        """ Last Modified Time

        :rtype: datetime
        """
        return self.__modified

    @property
    def display_name(self):
        """ Display Name

        :getter: Get the display name of the contact
        :setter: Update the display name
        :type: str
        """
        return self.__display_name

    @display_name.setter
    def display_name(self, value):
        self.__display_name = value
        self._track_changes.add(self._cc('displayName'))

    @property
    def name(self):
        """ First Name

        :getter: Get the name of the contact
        :setter: Update the name
        :type: str
        """
        return self.__name

    @name.setter
    def name(self, value):
        self.__name = value
        self._track_changes.add(self._cc('givenName'))

    @property
    def surname(self):
        """ Surname of Contact

        :getter: Get the surname of the contact
        :setter: Update the surname
        :type: str
        """
        return self.__surname

    @surname.setter
    def surname(self, value):
        self.__surname = value
        self._track_changes.add(self._cc('surname'))

    @property
    def full_name(self):
        """ Full Name (Name + Surname)

        :rtype: str
        """
        return '{} {}'.format(self.name, self.surname).strip()

    @property
    def title(self):
        """ Title (Mr., Ms., etc..)

        :getter: Get the title of the contact
        :setter: Update the title
        :type: str
        """
        return self.__title

    @title.setter
    def title(self, value):
        self.__title = value
        self._track_changes.add(self._cc('title'))

    @property
    def job_title(self):
        """ Job Title

        :getter: Get the job title of contact
        :setter: Update the job title
        :type: str
        """
        return self.__job_title

    @job_title.setter
    def job_title(self, value):
        self.__job_title = value
        self._track_changes.add(self._cc('jobTitle'))

    @property
    def company_name(self):
        """ Name of the company

        :getter: Get the company name of contact
        :setter: Update the company name
        :type: str
        """
        return self.__company_name

    @company_name.setter
    def company_name(self, value):
        self.__company_name = value
        self._track_changes.add(self._cc('companyName'))

    @property
    def department(self):
        """ Department

        :getter: Get the department of contact
        :setter: Update the department
        :type: str
        """
        return self.__department

    @department.setter
    def department(self, value):
        self.__department = value
        self._track_changes.add(self._cc('department'))

    @property
    def office_location(self):
        """ Office Location

        :getter: Get the office location of contact
        :setter: Update the office location
        :type: str
        """
        return self.__office_location

    @office_location.setter
    def office_location(self, value):
        self.__office_location = value
        self._track_changes.add(self._cc('officeLocation'))

    @property
    def business_phones(self):
        """ Business Contact numbers

        :getter: Get the contact numbers of contact
        :setter: Update the contact numbers
        :type: list[str]
        """
        return self.__business_phones

    @business_phones.setter
    def business_phones(self, value):
        if isinstance(value, tuple):
            value = list(value)
        if not isinstance(value, list):
            value = [value]
        self.__business_phones = value
        self._track_changes.add(self._cc('businessPhones'))

    @property
    def mobile_phone(self):
        """ Personal Contact numbers

        :getter: Get the contact numbers of contact
        :setter: Update the contact numbers
        :type: list[str]
        """
        return self.__mobile_phone

    @mobile_phone.setter
    def mobile_phone(self, value):
        self.__mobile_phone = value
        self._track_changes.add(self._cc('mobilePhone'))

    @property
    def home_phones(self):
        """ Home Contact numbers

        :getter: Get the contact numbers of contact
        :setter: Update the contact numbers
        :type: list[str]
        """
        return self.__home_phones

    @home_phones.setter
    def home_phones(self, value):
        if isinstance(value, list):
            self.__home_phones = value
        elif isinstance(value, str):
            self.__home_phones = [value]
        elif isinstance(value, tuple):
            self.__home_phones = list(value)
        else:
            raise ValueError('home_phones must be a list')
        self._track_changes.add(self._cc('homePhones'))

    @property
    def emails(self):
        """ List of email ids of the Contact

        :rtype: Recipients
        """
        return self.__emails

    @property
    def main_email(self):
        """ Primary(First) email id of the Contact

        :rtype: str
        """
        if not self.emails:
            return None
        return self.emails[0].address

    @property
    def business_address(self):
        """ Business Address

        :getter: Get the address of contact
        :setter: Update the address
        :type: dict
        """
        return self.__business_address

    @business_address.setter
    def business_address(self, value):
        if not isinstance(value, dict):
            raise ValueError('"business_address" must be dict')
        self.__business_address = value
        self._track_changes.add(self._cc('businessAddress'))

    @property
    def home_address(self):
        """ Home Address

        :getter: Get the address of contact
        :setter: Update the address
        :type: dict
        """
        return self.__home_address

    @home_address.setter
    def home_address(self, value):
        if not isinstance(value, dict):
            raise ValueError('"home_address" must be dict')
        self.__home_address = value
        self._track_changes.add(self._cc('homesAddress'))

    @property
    def other_address(self):
        """ Other Address

        :getter: Get the address of contact
        :setter: Update the address
        :type: dict
        """
        return self.__other_address

    @other_address.setter
    def other_address(self, value):
        if not isinstance(value, dict):
            raise ValueError('"other_address" must be dict')
        self.__other_address = value
        self._track_changes.add(self._cc('otherAddress'))

    @property
    def preferred_language(self):
        """ Preferred Language

        :getter: Get the language of contact
        :setter: Update the language
        :type: str
        """
        return self.__preferred_language

    @preferred_language.setter
    def preferred_language(self, value):
        self.__preferred_language = value
        self._track_changes.add(self._cc('preferredLanguage'))

    @property
    def categories(self):
        """ Assigned Categories

        :getter: Get the categories
        :setter: Update the categories
        :type: list[str]
        """
        return self.__categories

    @categories.setter
    def categories(self, value):
        if isinstance(value, list):
            self.__categories = value
        elif isinstance(value, str):
            self.__categories = [value]
        elif isinstance(value, tuple):
            self.__categories = list(value)
        else:
            raise ValueError('categories must be a list')
        self._track_changes.add(self._cc('categories'))

    @property
    def folder_id(self):
        """ ID of the folder

        :rtype: str
        """
        return self.__folder_id

    def __str__(self):
        return self.__repr__()

    def __repr__(self):
        return self.display_name or self.full_name or 'Unknown Name'

    def to_api_data(self, restrict_keys=None):
        """ Returns a dictionary in cloud format

        :param restrict_keys: a set of keys to restrict the returned data to.
        """
        cc = self._cc  # alias

        data = {
            cc('displayName'):
            self.__display_name,
            cc('givenName'):
            self.__name,
            cc('surname'):
            self.__surname,
            cc('title'):
            self.__title,
            cc('jobTitle'):
            self.__job_title,
            cc('companyName'):
            self.__company_name,
            cc('department'):
            self.__department,
            cc('officeLocation'):
            self.__office_location,
            cc('businessPhones'):
            self.__business_phones,
            cc('mobilePhone'):
            self.__mobile_phone,
            cc('homePhones'):
            self.__home_phones,
            cc('emailAddresses'): [{
                self._cc('name'): recipient.name or '',
                self._cc('address'): recipient.address
            } for recipient in self.emails],
            cc('businessAddress'):
            self.__business_address,
            cc('homesAddress'):
            self.__home_address,
            cc('otherAddress'):
            self.__other_address,
            cc('categories'):
            self.__categories
        }

        if restrict_keys:
            restrict_keys.add(cc(
                'givenName'))  # GivenName is required by the api all the time.
            for key in list(data.keys()):
                if key not in restrict_keys:
                    del data[key]
        return data

    def delete(self):
        """ Deletes this contact

        :return: Success or Failure
        :rtype: bool
        :raises RuntimeError: if contact is not yet saved to cloud
        """
        if not self.object_id:
            raise RuntimeError('Attempting to delete an unsaved Contact')

        url = self.build_url(
            self._endpoints.get('root_contact').format(id=self.object_id))

        response = self.con.delete(url)

        return bool(response)

    def save(self):
        """ Saves this contact to the cloud (create or update existing one
        based on what values have changed)

        :return: Saved or Not
        :rtype: bool
        """
        if self.object_id:
            # Update Contact
            if not self._track_changes:
                return True  # there's nothing to update
            url = self.build_url(
                self._endpoints.get('root_contact').format(id=self.object_id))
            method = self.con.patch
            data = self.to_api_data(restrict_keys=self._track_changes)
        else:
            # Save new Contact
            if self.__folder_id:
                url = self.build_url(
                    self._endpoints.get('child_contact').format(
                        folder_id=self.__folder_id))
            else:
                url = self.build_url(self._endpoints.get('contact'))
            method = self.con.post
            data = self.to_api_data(restrict_keys=self._track_changes)
        response = method(url, data=data)

        if not response:
            return False

        if not self.object_id:
            # New Contact
            contact = response.json()

            self.object_id = contact.get(self._cc('id'), None)

            self.__created = contact.get(self._cc('createdDateTime'), None)
            self.__modified = contact.get(self._cc('lastModifiedDateTime'),
                                          None)

            local_tz = self.protocol.timezone
            self.__created = parse(
                self.created).astimezone(local_tz) if self.__created else None
            self.__modified = parse(self.modified).astimezone(
                local_tz) if self.__modified else None
        else:
            self.__modified = self.protocol.timezone.localize(
                dt.datetime.now())

        return True

    def new_message(self, recipient=None, *, recipient_type=RecipientType.TO):
        """ This method returns a new draft Message instance with
        contacts first email as a recipient

        :param Recipient recipient: a Recipient instance where to send this
         message. If None first email of this contact will be used
        :param RecipientType recipient_type: section to add recipient into
        :return: newly created message
        :rtype: Message or None
        """
        if self.main_resource == GAL_MAIN_RESOURCE:
            # preventing the contact lookup to explode for big organizations..
            raise RuntimeError('Sending a message to all users within an '
                               'Organization is not allowed')

        if isinstance(recipient_type, str):
            recipient_type = RecipientType(recipient_type)

        recipient = recipient or self.emails.get_first_recipient_with_address()
        if not recipient:
            return None

        new_message = self.message_constructor(parent=self, is_draft=True)

        target_recipients = getattr(new_message, str(recipient_type.value))
        target_recipients.add(recipient)

        return new_message
Ejemplo n.º 3
0
    def __init__(self, *, parent=None, con=None, **kwargs):
        """
        Makes a new message wrapper for sending and receiving messages.

        :param parent: the parent object
        :param con: the id of this message if it exists
        """
        assert parent or con, 'Need a parent or a connection'
        self.con = parent.con if parent else con

        # Choose the main_resource passed in kwargs over the parent main_resource
        main_resource = kwargs.pop('main_resource', None) or getattr(
            parent, 'main_resource', None) if parent else None
        super().__init__(
            protocol=parent.protocol if parent else kwargs.get('protocol'),
            main_resource=main_resource,
            attachment_name_property='subject',
            attachment_type='message_type')

        download_attachments = kwargs.get('download_attachments')

        cloud_data = kwargs.get(self._cloud_data_key, {})
        cc = self._cc  # alias to shorten the code

        self._track_changes = TrackerSet(
            casing=cc
        )  # internal to know which properties need to be updated on the server
        self.object_id = cloud_data.get(cc('id'), None)

        self.__created = cloud_data.get(cc('createdDateTime'), None)
        self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None)
        self.__received = cloud_data.get(cc('receivedDateTime'), None)
        self.__sent = cloud_data.get(cc('sentDateTime'), None)

        local_tz = self.protocol.timezone
        self.__created = parse(
            self.__created).astimezone(local_tz) if self.__created else None
        self.__modified = parse(
            self.__modified).astimezone(local_tz) if self.__modified else None
        self.__received = parse(
            self.__received).astimezone(local_tz) if self.__received else None
        self.__sent = parse(
            self.__sent).astimezone(local_tz) if self.__sent else None

        self.__attachments = MessageAttachments(parent=self, attachments=[])
        self.has_attachments = cloud_data.get(cc('hasAttachments'), 0)
        if self.has_attachments and download_attachments:
            self.attachments.download_attachments()
        self.__subject = cloud_data.get(cc('subject'), '')
        body = cloud_data.get(cc('body'), {})
        self.__body = body.get(cc('content'), '')
        self.body_type = body.get(cc('contentType'),
                                  'HTML')  # default to HTML for new messages
        self.__sender = self._recipient_from_cloud(cloud_data.get(
            cc('from'), None),
                                                   field=cc('from'))
        self.__to = self._recipients_from_cloud(cloud_data.get(
            cc('toRecipients'), []),
                                                field=cc('toRecipients'))
        self.__cc = self._recipients_from_cloud(cloud_data.get(
            cc('ccRecipients'), []),
                                                field=cc('ccRecipients'))
        self.__bcc = self._recipients_from_cloud(cloud_data.get(
            cc('bccRecipients'), []),
                                                 field=cc('bccRecipients'))
        self.__reply_to = self._recipients_from_cloud(cloud_data.get(
            cc('replyTo'), []),
                                                      field=cc('replyTo'))
        self.__categories = cloud_data.get(cc('categories'), [])
        self.__importance = ImportanceLevel(
            (cloud_data.get(cc('importance'), 'normal')
             or 'normal').lower())  # lower because of office365 v1.0
        self.__is_read = cloud_data.get(cc('isRead'), None)
        self.__is_draft = cloud_data.get(
            cc('isDraft'), kwargs.get('is_draft',
                                      True))  # a message is a draft by default
        self.conversation_id = cloud_data.get(cc('conversationId'), None)
        self.folder_id = cloud_data.get(cc('parentFolderId'), None)
Ejemplo n.º 4
0
 def _clear_tracker(self):
     # reset the tracked changes. Usually after a server update
     self._track_changes = TrackerSet(casing=self._cc)
Ejemplo n.º 5
0
class Message(ApiComponent, AttachableMixin, HandleRecipientsMixin):
    """ Management of the process of sending, receiving, reading, and editing emails. """

    _endpoints = {
        'create_draft': '/messages',
        'create_draft_folder': '/mailFolders/{id}/messages',
        'send_mail': '/sendMail',
        'send_draft': '/messages/{id}/send',
        'get_message': '/messages/{id}',
        'move_message': '/messages/{id}/move',
        'copy_message': '/messages/{id}/copy',
        'create_reply': '/messages/{id}/createReply',
        'create_reply_all': '/messages/{id}/createReplyAll',
        'forward_message': '/messages/{id}/createForward'
    }

    def __init__(self, *, parent=None, con=None, **kwargs):
        """
        Makes a new message wrapper for sending and receiving messages.

        :param parent: the parent object
        :param con: the id of this message if it exists
        """
        assert parent or con, 'Need a parent or a connection'
        self.con = parent.con if parent else con

        # Choose the main_resource passed in kwargs over the parent main_resource
        main_resource = kwargs.pop('main_resource', None) or getattr(
            parent, 'main_resource', None) if parent else None
        super().__init__(
            protocol=parent.protocol if parent else kwargs.get('protocol'),
            main_resource=main_resource,
            attachment_name_property='subject',
            attachment_type='message_type')

        download_attachments = kwargs.get('download_attachments')

        cloud_data = kwargs.get(self._cloud_data_key, {})
        cc = self._cc  # alias to shorten the code

        self._track_changes = TrackerSet(
            casing=cc
        )  # internal to know which properties need to be updated on the server
        self.object_id = cloud_data.get(cc('id'), None)

        self.__created = cloud_data.get(cc('createdDateTime'), None)
        self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None)
        self.__received = cloud_data.get(cc('receivedDateTime'), None)
        self.__sent = cloud_data.get(cc('sentDateTime'), None)

        local_tz = self.protocol.timezone
        self.__created = parse(
            self.__created).astimezone(local_tz) if self.__created else None
        self.__modified = parse(
            self.__modified).astimezone(local_tz) if self.__modified else None
        self.__received = parse(
            self.__received).astimezone(local_tz) if self.__received else None
        self.__sent = parse(
            self.__sent).astimezone(local_tz) if self.__sent else None

        self.__attachments = MessageAttachments(parent=self, attachments=[])
        self.has_attachments = cloud_data.get(cc('hasAttachments'), 0)
        if self.has_attachments and download_attachments:
            self.attachments.download_attachments()
        self.__subject = cloud_data.get(cc('subject'), '')
        body = cloud_data.get(cc('body'), {})
        self.__body = body.get(cc('content'), '')
        self.body_type = body.get(cc('contentType'),
                                  'HTML')  # default to HTML for new messages
        self.__sender = self._recipient_from_cloud(cloud_data.get(
            cc('from'), None),
                                                   field=cc('from'))
        self.__to = self._recipients_from_cloud(cloud_data.get(
            cc('toRecipients'), []),
                                                field=cc('toRecipients'))
        self.__cc = self._recipients_from_cloud(cloud_data.get(
            cc('ccRecipients'), []),
                                                field=cc('ccRecipients'))
        self.__bcc = self._recipients_from_cloud(cloud_data.get(
            cc('bccRecipients'), []),
                                                 field=cc('bccRecipients'))
        self.__reply_to = self._recipients_from_cloud(cloud_data.get(
            cc('replyTo'), []),
                                                      field=cc('replyTo'))
        self.__categories = cloud_data.get(cc('categories'), [])
        self.__importance = ImportanceLevel(
            (cloud_data.get(cc('importance'), 'normal')
             or 'normal').lower())  # lower because of office365 v1.0
        self.__is_read = cloud_data.get(cc('isRead'), None)
        self.__is_draft = cloud_data.get(
            cc('isDraft'), kwargs.get('is_draft',
                                      True))  # a message is a draft by default
        self.conversation_id = cloud_data.get(cc('conversationId'), None)
        self.folder_id = cloud_data.get(cc('parentFolderId'), None)

    def _clear_tracker(self):
        # reset the tracked changes. Usually after a server update
        self._track_changes = TrackerSet(casing=self._cc)

    @property
    def is_read(self):
        return self.__is_read

    @is_read.setter
    def is_read(self, value):
        self.__is_read = value
        self._track_changes.add('isRead')

    @property
    def is_draft(self):
        return self.__is_draft

    @property
    def subject(self):
        return self.__subject

    @subject.setter
    def subject(self, value):
        self.__subject = value
        self._track_changes.add('subject')

    @property
    def body(self):
        return self.__body

    @body.setter
    def body(self, value):
        if self.__body:
            if not value:
                self.__body = ''
            else:
                soup = bs(self.__body, 'html.parser')
                soup.body.insert(0, bs(value, 'html.parser'))
                self.__body = str(soup)
        else:
            self.__body = value
        self._track_changes.add('body')

    @property
    def created(self):
        return self.__created

    @property
    def modified(self):
        return self.__modified

    @property
    def received(self):
        return self.__received

    @property
    def sent(self):
        return self.__sent

    @property
    def attachments(self):
        """ Just to avoid api misuse by assigning to 'attachments' """
        return self.__attachments

    @property
    def sender(self):
        """ sender is a property to force to be allways a Recipient class """
        return self.__sender

    @sender.setter
    def sender(self, value):
        """ sender is a property to force to be allways a Recipient class """
        if isinstance(value, Recipient):
            if value._parent is None:
                value._parent = self
                value._field = 'from'
            self.__sender = value
        elif isinstance(value, str):
            self.__sender.address = value
            self.__sender.name = ''
        else:
            raise ValueError(
                'sender must be an address string or a Recipient object')
        self._track_changes.add('from')

    @property
    def to(self):
        """ Just to avoid api misuse by assigning to 'to' """
        return self.__to

    @property
    def cc(self):
        """ Just to avoid api misuse by assigning to 'cc' """
        return self.__cc

    @property
    def bcc(self):
        """ Just to avoid api misuse by assigning to 'bcc' """
        return self.__bcc

    @property
    def reply_to(self):
        """ Just to avoid api misuse by assigning to 'reply_to' """
        return self.__reply_to

    @property
    def categories(self):
        return self.__categories

    @categories.setter
    def categories(self, value):
        if isinstance(value, list):
            self.__categories = value
        elif isinstance(value, str):
            self.__categories = [value]
        elif isinstance(value, tuple):
            self.__categories = list(value)
        else:
            raise ValueError('categories must be a list')
        self._track_changes.add('categories')

    @property
    def importance(self):
        return self.__importance

    @importance.setter
    def importance(self, value):
        self.__importance = value if isinstance(
            value, ImportanceLevel) else ImportanceLevel(value.lower())
        self._track_changes.add('importance')

    def to_api_data(self, restrict_keys=None):
        """ Returns a dict representation of this message prepared to be send to the cloud
        :param restrict_keys: a set of keys to restrict the returned data to.
        """

        cc = self._cc  # alias to shorten the code

        message = {
            cc('subject'): self.subject,
            cc('body'): {
                cc('contentType'): self.body_type,
                cc('content'): self.body
            },
            cc('importance'): self.importance.value
        }

        if self.to:
            message[cc('toRecipients')] = [
                self._recipient_to_cloud(recipient) for recipient in self.to
            ]
        if self.cc:
            message[cc('ccRecipients')] = [
                self._recipient_to_cloud(recipient) for recipient in self.cc
            ]
        if self.bcc:
            message[cc('bccRecipients')] = [
                self._recipient_to_cloud(recipient) for recipient in self.bcc
            ]
        if self.reply_to:
            message[cc('replyTo')] = [
                self._recipient_to_cloud(recipient)
                for recipient in self.reply_to
            ]
        if self.attachments:
            message[cc('attachments')] = self.attachments.to_api_data()
        if self.sender and self.sender.address:
            message[cc('from')] = self._recipient_to_cloud(self.sender)

        if self.object_id and not self.__is_draft:
            # return the whole signature of this message

            message[cc('id')] = self.object_id
            message[cc('createdDateTime')] = self.created.astimezone(
                pytz.utc).isoformat()
            message[cc('receivedDateTime')] = self.received.astimezone(
                pytz.utc).isoformat()
            message[cc('sentDateTime')] = self.sent.astimezone(
                pytz.utc).isoformat()
            message[cc('hasAttachments')] = len(self.attachments) > 0
            message[cc('categories')] = self.categories
            message[cc('isRead')] = self.is_read
            message[cc('isDraft')] = self.__is_draft
            message[cc('conversationId')] = self.conversation_id
            message[cc(
                'parentFolderId'
            )] = self.folder_id  # this property does not form part of the message itself

        if restrict_keys:
            for key in list(message.keys()):
                if key not in restrict_keys:
                    del message[key]

        return message

    def send(self, save_to_sent_folder=True):
        """ Sends this message. """

        if self.object_id and not self.__is_draft:
            return RuntimeError(
                'Not possible to send a message that is not new or a draft. Use Reply or Forward instead.'
            )

        if self.__is_draft and self.object_id:
            url = self.build_url(
                self._endpoints.get('send_draft').format(id=self.object_id))
            data = None
        else:
            url = self.build_url(self._endpoints.get('send_mail'))
            data = {self._cc('message'): self.to_api_data()}
            if save_to_sent_folder is False:
                data[self._cc('saveToSentItems')] = False

        response = self.con.post(url, data=data)
        if not response:  # response evaluates to false if 4XX or 5XX status codes are returned
            return False

        self.object_id = 'sent_message' if not self.object_id else self.object_id
        self.__is_draft = False

        return True

    def reply(self, to_all=True):
        """
        Creates a new message that is a reply to this message.
        :param to_all: replies to all the recipients instead to just the sender
        """
        if not self.object_id or self.__is_draft:
            raise RuntimeError("Can't reply to this message")

        if to_all:
            url = self.build_url(
                self._endpoints.get('create_reply_all').format(
                    id=self.object_id))
        else:
            url = self.build_url(
                self._endpoints.get('create_reply').format(id=self.object_id))

        response = self.con.post(url)
        if not response:
            return None

        message = response.json()

        # Everything received from the cloud must be passed with self._cloud_data_key
        return self.__class__(parent=self, **{self._cloud_data_key: message})

    def forward(self):
        """
        Creates a new message that is a forward of this message.
        """
        if not self.object_id or self.__is_draft:
            raise RuntimeError("Can't forward this message")

        url = self.build_url(
            self._endpoints.get('forward_message').format(id=self.object_id))

        response = self.con.post(url)
        if not response:
            return None

        message = response.json()

        # Everything received from the cloud must be passed with self._cloud_data_key
        return self.__class__(parent=self, **{self._cloud_data_key: message})

    def delete(self):
        """ Deletes a stored message """
        if self.object_id is None:
            raise RuntimeError('Attempting to delete an unsaved Message')

        url = self.build_url(
            self._endpoints.get('get_message').format(id=self.object_id))

        response = self.con.delete(url)

        return bool(response)

    def mark_as_read(self):
        """ Marks this message as read in the cloud."""
        if self.object_id is None or self.__is_draft:
            raise RuntimeError('Attempting to mark as read an unsaved Message')

        data = {self._cc('isRead'): True}

        url = self.build_url(
            self._endpoints.get('get_message').format(id=self.object_id))

        response = self.con.patch(url, data=data)
        if not response:
            return False

        self.__is_read = True

        return True

    def move(self, folder):
        """
        Move the message to a given folder

        :param folder: Folder object or Folder id or Well-known name to move this message to
        :returns: True on success
        """
        if self.object_id is None:
            raise RuntimeError('Attempting to move an unsaved Message')

        url = self.build_url(
            self._endpoints.get('move_message').format(id=self.object_id))

        if isinstance(folder, str):
            folder_id = folder
        else:
            folder_id = getattr(folder, 'folder_id', None)

        if not folder_id:
            raise RuntimeError('Must Provide a valid folder_id')

        data = {self._cc('destinationId'): folder_id}

        response = self.con.post(url, data=data)
        if not response:
            return False

        self.folder_id = folder_id

        return True

    def copy(self, folder):
        """
        Copy the message to a given folder

        :param folder: Folder object or Folder id or Well-known name to move this message to
        :returns: the copied message
        """
        if self.object_id is None:
            raise RuntimeError('Attempting to move an unsaved Message')

        url = self.build_url(
            self._endpoints.get('copy_message').format(id=self.object_id))

        if isinstance(folder, str):
            folder_id = folder
        else:
            folder_id = getattr(folder, 'folder_id', None)

        if not folder_id:
            raise RuntimeError('Must Provide a valid folder_id')

        data = {self._cc('destinationId'): folder_id}

        response = self.con.post(url, data=data)
        if not response:
            return None

        message = response.json()

        # Everything received from the cloud must be passed with self._cloud_data_key
        return self.__class__(parent=self, **{self._cloud_data_key: message})

    def save_draft(self, target_folder=OutlookWellKnowFolderNames.DRAFTS):
        """ Save this message as a draft on the cloud """

        if self.object_id:
            # update message. Attachments are NOT included nor saved.
            if not self.__is_draft:
                raise RuntimeError('Only draft messages can be updated')
            if not self._track_changes:
                return True  # there's nothing to update
            url = self.build_url(
                self._endpoints.get('get_message').format(id=self.object_id))
            method = self.con.patch
            data = self.to_api_data(restrict_keys=self._track_changes)

            data.pop(self._cc('attachments'),
                     None)  # attachments are handled by the next method call
            self.attachments._update_attachments_to_cloud()
        else:
            # new message. Attachments are included and saved.
            if not self.__is_draft:
                raise RuntimeError(
                    'Only draft messages can be saved as drafts')

            target_folder = target_folder or OutlookWellKnowFolderNames.DRAFTS
            if isinstance(target_folder, OutlookWellKnowFolderNames):
                target_folder = target_folder.value
            elif not isinstance(target_folder, str):
                # a Folder instance
                target_folder = getattr(
                    target_folder, 'folder_id',
                    OutlookWellKnowFolderNames.DRAFTS.value)

            url = self.build_url(
                self._endpoints.get('create_draft_folder').format(
                    id=target_folder))
            method = self.con.post
            data = self.to_api_data()

        self._clear_tracker(
        )  # reset the tracked changes as they are all saved.
        if not data:
            return True

        response = method(url, data=data)
        if not response:
            return False

        if not self.object_id:
            # new message
            message = response.json()

            self.object_id = message.get(self._cc('id'), None)
            self.folder_id = message.get(self._cc('parentFolderId'), None)

            self.__created = message.get(
                self._cc('createdDateTime'),
                message.get(self._cc('dateTimeCreated'),
                            None))  # fallback to office365 v1.0
            self.__modified = message.get(
                self._cc('lastModifiedDateTime'),
                message.get(self._cc('dateTimeModified'),
                            None))  # fallback to office365 v1.0

            self.__created = parse(self.__created).astimezone(
                self.protocol.timezone) if self.__created else None
            self.__modified = parse(self.__modified).astimezone(
                self.protocol.timezone) if self.__modified else None

        else:
            self.__modified = self.protocol.timezone.localize(
                dt.datetime.now())

        return True

    def get_body_text(self):
        """ Parse the body html and returns the body text using bs4 """
        if self.body_type != 'HTML':
            return self.body

        try:
            soup = bs(self.body, 'html.parser')
        except Exception as e:
            return self.body
        else:
            return soup.body.text

    def get_body_soup(self):
        """ Returns the beautifulsoup4 of the html body"""
        if self.body_type != 'HTML':
            return None
        else:
            return bs(self.body, 'html.parser')

    def __str__(self):
        return self.__repr__()

    def __repr__(self):
        return 'Subject: {}'.format(self.subject)