예제 #1
0
def test_merge(config):
    """Test the basic logic of the merge() function."""
    base = Contact(name='Original Name',
                   email_address='*****@*****.**')
    remote = Contact(name='New Name',
                     email_address='*****@*****.**')
    dest = Contact(name='Original Name',
                   email_address='*****@*****.**')
    dest.merge_from(base, remote)
    assert dest.name == 'New Name'
    assert dest.email_address == '*****@*****.**'
예제 #2
0
def test_merge(config, contact_sync):
    """Test the basic logic of the merge() function."""
    base = Contact(name='Original Name',
                   email_address='*****@*****.**')
    remote = Contact(name='New Name',
                     email_address='*****@*****.**')
    dest = Contact(name='Original Name',
                   email_address='*****@*****.**')
    contact_sync.merge(base, remote, dest)
    assert dest.name == 'New Name'
    assert dest.email_address == '*****@*****.**'
예제 #3
0
def test_merge_conflict(config, contact_sync):
    """Test that merge() raises an error on conflict."""
    base = Contact(name='Original Name',
                   email_address='*****@*****.**')
    remote = Contact(name='New Name',
                     email_address='*****@*****.**')
    dest = Contact(name='Some Other Name',
                   email_address='*****@*****.**')
    with pytest.raises(MergeError):
        contact_sync.merge(base, remote, dest)

    # Check no update in case of conflict
    assert dest.name == 'Some Other Name'
    assert dest.email_address == '*****@*****.**'
예제 #4
0
def test_merge_conflict(config):
    """Test that merge() raises an error on conflict."""
    base = Contact(name='Original Name',
                   email_address='*****@*****.**')
    remote = Contact(name='New Name',
                     email_address='*****@*****.**')
    dest = Contact(name='Some Other Name',
                   email_address='*****@*****.**')
    with pytest.raises(MergeError):
        dest.merge_from(base, remote)

    # Check no update in case of conflict
    assert dest.name == 'Some Other Name'
    assert dest.email_address == '*****@*****.**'
예제 #5
0
    def _vCard_raw_to_contact(self, cardstring):
        card = vcard_from_string(cardstring)

        def _x(key):  # Ugly parsing helper for ugly formats
            if key in card:
                try:
                    return card[key][0][0]
                except IndexError:
                    pass

        # Skip contact groups for now
        if _x('X-ADDRESSBOOKSERVER-KIND') == 'group':
            return None

        uid = _x('UID')
        name = _x('FN')
        email_address = _x('EMAIL')
        # TODO add these later
        # street_address = _x('ADR')
        # phone_number = _x('TEL')
        # organization = _x('ORG')

        return Contact(namespace_id=self.namespace_id,
                       provider_name=self.PROVIDER_NAME,
                       uid=uid,
                       name=name,
                       email_address=email_address,
                       raw_data=cardstring)
예제 #6
0
def _get_contact_map(db_session, namespace_id, all_addresses):
    """
    Retrieves or creates contacts for the given address pairs, returning a dict
    with the canonicalized emails mapped to Contact objects.
    """
    canonicalized_addresses = [canonicalize(addr) for _, addr in all_addresses]

    if not canonicalized_addresses:
        return {}

    existing_contacts = (
        db_session.query(Contact)
        .filter(
            Contact._canonicalized_address.in_(canonicalized_addresses),
            Contact.namespace_id == namespace_id,
        )
        .all()
    )

    contact_map = {c._canonicalized_address: c for c in existing_contacts}
    for name, email_address in all_addresses:
        canonicalized_address = canonicalize(email_address)
        if canonicalized_address not in contact_map:
            new_contact = Contact(
                name=name,
                email_address=email_address,
                namespace_id=namespace_id,
                provider_name=INBOX_PROVIDER_NAME,
                uid=uuid.uuid4().hex,
            )
            contact_map[canonicalized_address] = new_contact
    return contact_map
예제 #7
0
    def _parse_contact_result(self, google_contact):
        """Constructs a Contact object from a Google contact entry.

        Parameters
        ----------
        google_contact: gdata.contacts.entry.ContactEntry
            The Google contact entry to parse.

        Returns
        -------
        ..models.tables.base.Contact
            A corresponding Nylas Contact instance.

        Raises
        ------
        AttributeError
           If the contact data could not be parsed correctly.
        """
        email_addresses = [
            email for email in google_contact.email if email.primary
        ]
        if email_addresses and len(email_addresses) > 1:
            self.log.error(
                "Should not have more than one email per entry!",
                num_email=len(email_addresses),
            )

        try:
            # The id.text field of a ContactEntry object takes the form
            # 'http://www.google.com/m8/feeds/contacts/<useremail>/base/<uid>'.
            # We only want the <uid> part.
            raw_google_id = google_contact.id.text
            _, g_id = posixpath.split(raw_google_id)
            name = (google_contact.name.full_name.text if
                    (google_contact.name
                     and google_contact.name.full_name) else None)
            email_address = email_addresses[
                0].address if email_addresses else None

            # The entirety of the raw contact data in XML string
            # representation.
            raw_data = google_contact.to_string()
        except AttributeError as e:
            self.log.error("Something is wrong with contact",
                           contact=google_contact)
            raise e

        deleted = google_contact.deleted is not None

        return Contact(
            namespace_id=self.namespace_id,
            uid=g_id,
            name=name,
            provider_name=self.PROVIDER_NAME,
            email_address=email_address,
            deleted=deleted,
            raw_data=raw_data,
        )
예제 #8
0
def create(namespace, db_session, name, email):
    contact = Contact(namespace=namespace,
                      provider_name=INBOX_PROVIDER_NAME,
                      uid=uuid.uuid4().hex,
                      name=name,
                      email_address=email)
    db_session.add(contact)
    db_session.commit()
    return contact
예제 #9
0
def contact(db, default_account):
    from inbox.models import Contact
    contact = Contact(namespace_id=default_account.namespace.id,
                      name='Ben Bitdiddle',
                      email_address='*****@*****.**',
                      uid='22')
    db.session.add(contact)
    db.session.commit()
    return contact
예제 #10
0
 def supply_contact(self, name, email_address, deleted=False):
     from inbox.models import Contact
     self._contacts.append(Contact(namespace_id=1,
                                   uid=str(self._next_uid),
                                   provider_name=self.PROVIDER_NAME,
                                   name=name,
                                   email_address=email_address,
                                   deleted=deleted))
     self._next_uid += 1
예제 #11
0
def update_contacts_from_message(db_session, message, namespace):
    with db_session.no_autoflush:
        # First create Contact objects for any email addresses that we haven't
        # seen yet. We want to dedupe by canonicalized address, so this part is
        # a bit finicky.
        canonicalized_addresses = []
        all_addresses = []
        for field in (message.from_addr, message.to_addr, message.cc_addr,
                      message.bcc_addr, message.reply_to):
            # We generally require these attributes to be non-null, but only
            # set them to the default empty list at flush time. So it's better
            # to be safe here.
            if field is not None:
                all_addresses.extend(field)
        canonicalized_addresses = [
            canonicalize(addr) for _, addr in all_addresses
        ]

        existing_contacts = db_session.query(Contact).filter(
            Contact._canonicalized_address.in_(canonicalized_addresses),
            Contact.namespace_id == namespace.id).all()

        contact_map = {c._canonicalized_address: c for c in existing_contacts}
        for name, email_address in all_addresses:
            canonicalized_address = canonicalize(email_address)
            if canonicalized_address not in contact_map:
                new_contact = Contact(name=name,
                                      email_address=email_address,
                                      namespace=namespace,
                                      provider_name=INBOX_PROVIDER_NAME,
                                      uid=uuid.uuid4().hex)
                contact_map[canonicalized_address] = new_contact

        # Now associate each contact to the message.
        for field_name in ('from_addr', 'to_addr', 'cc_addr', 'bcc_addr',
                           'reply_to'):
            field = getattr(message, field_name)
            if field is None:
                continue
            for name, email_address in field:
                if not valid_email(email_address):
                    continue
                canonicalized_address = canonicalize(email_address)
                contact = contact_map.get(canonicalized_address)
                # Hackily address the condition that you get mail from e.g.
                # "Ben Gotow (via Google Drive) <*****@*****.**"
                # "Christine Spang (via Google Drive) <*****@*****.**"
                # and so on: rather than creating many contacts with
                # varying name, null out the name for the existing contact.
                if contact.name != name and 'noreply' in canonicalized_address:
                    contact.name = None

                message.contacts.append(
                    MessageContactAssociation(contact=contact,
                                              field=field_name))
예제 #12
0
def add_fake_contact(db_session,
                     namespace_id,
                     name='Ben Bitdiddle',
                     email_address='*****@*****.**',
                     uid='22'):
    from inbox.models import Contact
    contact = Contact(namespace_id=namespace_id,
                      name=name,
                      email_address=email_address,
                      uid=uid)

    db_session.add(contact)
    db_session.commit()
    return contact
예제 #13
0
def update_contacts_from_message(db_session, message, namespace):
    with db_session.no_autoflush:
        # First create Contact objects for any email addresses that we haven't
        # seen yet. We want to dedupe by canonicalized address, so this part is
        # a bit finicky.
        canonicalized_addresses = []
        all_addresses = []
        for field in (message.from_addr, message.to_addr, message.cc_addr,
                      message.bcc_addr, message.reply_to):
            # We generally require these attributes to be non-null, but only
            # set them to the default empty list at flush time. So it's better
            # to be safe here.
            if field is not None:
                all_addresses.extend(field)
        canonicalized_addresses = [
            canonicalize(addr) for _, addr in all_addresses
        ]

        existing_contacts = db_session.query(Contact).filter(
            Contact._canonicalized_address.in_(canonicalized_addresses),
            Contact.namespace_id == namespace.id).all()

        contact_map = {c._canonicalized_address: c for c in existing_contacts}
        for name, email_address in all_addresses:
            canonicalized_address = canonicalize(email_address)
            if canonicalized_address not in contact_map:
                new_contact = Contact(name=name,
                                      email_address=email_address,
                                      namespace=namespace,
                                      provider_name=INBOX_PROVIDER_NAME,
                                      uid=uuid.uuid4().hex)
                contact_map[canonicalized_address] = new_contact

        # Now associate each contact to the message.
        for field_name in ('from_addr', 'to_addr', 'cc_addr', 'bcc_addr',
                           'reply_to'):
            field = getattr(message, field_name)
            if field is None:
                continue
            for name, email_address in field:
                canonicalized_address = canonicalize(email_address)
                contact = contact_map.get(canonicalized_address)
                message.contacts.append(
                    MessageContactAssociation(contact=contact,
                                              field=field_name))
예제 #14
0
def update_contacts_from_message(db_session, message, account_id):
    with db_session.no_autoflush:
        for field in ('to_addr', 'from_addr', 'cc_addr', 'bcc_addr'):
            if getattr(message, field) is None:
                continue
            items = set(getattr(message, field))
            for name, email_address in items:
                contact = db_session.query(Contact).filter(
                    Contact.email_address == email_address).first()
                if contact is None:
                    contact = Contact(name=name,
                                      email_address=email_address,
                                      account_id=account_id,
                                      source='local',
                                      provider_name='inbox',
                                      uid=uuid.uuid4().hex)
                message.contacts.append(
                    MessageContactAssociation(contact=contact, field=field))
예제 #15
0
class GoogleContactsProvider(BaseSyncProvider):
    """A utility class to fetch and parse Google contact data for the specified
    account using the Google Contacts API.

    Parameters
    ----------
    db_session: sqlalchemy.orm.session.Session
        Database session.

    account: inbox.models.gmail.GmailAccount
        The user account for which to fetch contact data.

    Attributes
    ----------
    google_client: gdata.contacts.client.ContactsClient
        Google API client to do the actual data fetching.
    log: logging.Logger
        Logging handler.
    """
    PROVIDER_NAME = 'google'

    def __init__(self, account_id):
        self.account_id = account_id
        self.log = logger.new(account_id=account_id, component='contacts sync',
                              provider=self.PROVIDER_NAME)

    def _get_google_client(self):
        """Return the Google API client."""
        # TODO(emfree) figure out a better strategy for refreshing OAuth
        # credentials as needed
        with session_scope() as db_session:
            try:
                account = db_session.query(GmailAccount).get(self.account_id)
                client_id = account.client_id or OAUTH_CLIENT_ID
                client_secret = (account.client_secret or
                                 OAUTH_CLIENT_SECRET)
                two_legged_oauth_token = gdata.gauth.OAuth2Token(
                    client_id=client_id,
                    client_secret=client_secret,
                    scope=OAUTH_SCOPE,
                    user_agent=SOURCE_APP_NAME,
                    access_token=account.access_token,
                    refresh_token=account.refresh_token)
                google_client = gdata.contacts.client.ContactsClient(
                    source=SOURCE_APP_NAME)
                google_client.auth_token = two_legged_oauth_token
                return google_client
            except (gdata.client.BadAuthentication, OAuthError):
                self.log.error('Invalid user credentials given')
                account.sync_state = 'invalid'
                db_session.add(account)
                db_session.commit()
                return None
            except ConnectionError:
                self.log.error('Connection error')
                account.sync_state = 'connerror'
                db_session.add(account)
                db_session.commit()
                return None

    def _parse_contact_result(self, google_contact):
        """Constructs a Contact object from a Google contact entry.

        Parameters
        ----------
        google_contact: gdata.contacts.entry.ContactEntry
            The Google contact entry to parse.

        Returns
        -------
        ..models.tables.base.Contact
            A corresponding Inbox Contact instance.

        Raises
        ------
        AttributeError
           If the contact data could not be parsed correctly.
        """
        email_addresses = [email for email in google_contact.email if
                           email.primary]
        if email_addresses and len(email_addresses) > 1:
            self.log.error("Should not have more than one email per entry! {0}"
                           .format(email_addresses))
        try:
            # The id.text field of a ContactEntry object takes the form
            # 'http://www.google.com/m8/feeds/contacts/<useremail>/base/<uid>'.
            # We only want the <uid> part.
            raw_google_id = google_contact.id.text
            _, g_id = posixpath.split(raw_google_id)
            name = (google_contact.name.full_name.text if (google_contact.name
                    and google_contact.name.full_name) else None)
            email_address = (email_addresses[0].address if email_addresses else
                             None)

            # The entirety of the raw contact data in XML string
            # representation.
            raw_data = google_contact.to_string()
        except AttributeError, e:
            self.log.error('Something is wrong with contact',
                           contact=google_contact)
            raise e

        deleted = google_contact.deleted is not None

        return Contact(account_id=self.account_id, source='remote',
                       uid=g_id, name=name, provider_name=self.PROVIDER_NAME,
                       email_address=email_address, deleted=deleted,
                       raw_data=raw_data)
예제 #16
0
def poll(account_id, provider):
    """Query a remote contacts provider for updates and persist them to the
    database.

    Parameters
    ----------
    account_id: int
        ID for the account whose contacts should be queried.
    db_session: sqlalchemy.orm.session.Session
        Database session

    provider: Interface to the remote contact data provider.
        Must have a PROVIDER_NAME attribute and implement the get_contacts()
        method.
    """
    log = logger.new(account_id=account_id)
    provider_name = provider.PROVIDER_NAME
    with session_scope() as db_session:
        account = db_session.query(Account).get(account_id)
        change_counter = Counter()
        last_sync = or_none(account.last_synced_contacts,
                            datetime.datetime.isoformat)
        to_commit = []
        for remote_contact in provider.get_contacts(last_sync):
            remote_contact.account = account
            assert remote_contact.uid is not None, \
                'Got remote contact with null uid'
            assert isinstance(remote_contact.uid, str)
            matching_contacts = db_session.query(Contact).filter(
                Contact.account == account,
                Contact.provider_name == provider_name,
                Contact.uid == remote_contact.uid)
            # Snapshot of contact data from immediately after last sync:
            cached_contact = matching_contacts. \
                filter(Contact.source == 'remote').first()
            # Contact data reflecting any local modifications since the last
            # sync with the remote provider:
            local_contact = matching_contacts. \
                filter(Contact.source == 'local').first()
            # If the remote contact was deleted, purge the corresponding
            # database entries.
            if remote_contact.deleted:
                if cached_contact is not None:
                    db_session.delete(cached_contact)
                    change_counter['deleted'] += 1
                if local_contact is not None:
                    db_session.delete(local_contact)
                continue
            # Otherwise, update the database.
            if cached_contact is not None:
                # The provider gave an update to a contact we already have.
                if local_contact is not None:
                    try:
                        # Attempt to merge remote updates into local_contact
                        merge(cached_contact, remote_contact, local_contact)
                        # And update cached_contact to reflect both local and
                        # remote updates
                        cached_contact.copy_from(local_contact)
                    except MergeError:
                        log.error('Conflicting local and remote updates to '
                                  'contact.',
                                  local=local_contact, cached=cached_contact,
                                  remote=remote_contact)
                        # TODO(emfree): Come up with a strategy for handling
                        # merge conflicts. For now, just don't update if there
                        # is a conflict.
                        continue
                else:
                    log.warning('Contact already present as remote but not '
                                'local contact', cached_contact=cached_contact)
                    cached_contact.copy_from(remote_contact)
                change_counter['updated'] += 1
            else:
                # This is a new contact, create both local and remote DB
                # entries.
                local_contact = Contact()
                local_contact.copy_from(remote_contact)
                local_contact.source = 'local'
                to_commit.append(local_contact)
                to_commit.append(remote_contact)
                change_counter['added'] += 1

        account.last_synced_contacts = datetime.datetime.now()

        log.info('added contacts', count=change_counter['added'])
        log.info('updated contacts', count=change_counter['updated'])
        log.info('deleted contacts', count=change_counter['deleted'])

        db_session.add_all(to_commit)
        db_session.commit()
예제 #17
0
class GoogleContactsProvider(object):
    """
    A utility class to fetch and parse Google contact data for the specified
    account using the Google Contacts API.

    Parameters
    ----------
    db_session: sqlalchemy.orm.session.Session
        Database session.

    account: inbox.models.gmail.GmailAccount
        The user account for which to fetch contact data.

    Attributes
    ----------
    google_client: gdata.contacts.client.ContactsClient
        Google API client to do the actual data fetching.
    log: logging.Logger
        Logging handler.

    """
    PROVIDER_NAME = 'google'

    def __init__(self, account_id, namespace_id):
        self.account_id = account_id
        self.namespace_id = namespace_id
        self.log = logger.new(account_id=account_id,
                              component='contacts sync',
                              provider=self.PROVIDER_NAME)

    def _get_google_client(self, retry_conn_errors=True):
        """Return the Google API client."""
        # TODO(emfree) figure out a better strategy for refreshing OAuth
        # credentials as needed
        with session_scope() as db_session:
            try:
                account = db_session.query(GmailAccount).get(self.account_id)
                access_token, auth_creds_id = \
                    g_token_manager.get_token_and_auth_creds_id_for_contacts(
                        account)
                auth_creds = db_session.query(GmailAuthCredentials) \
                             .get(auth_creds_id)

                two_legged_oauth_token = gdata.gauth.OAuth2Token(
                    client_id=auth_creds.client_id,
                    client_secret=auth_creds.client_secret,
                    scope=auth_creds.scopes,  # FIXME: string not list?
                    user_agent=SOURCE_APP_NAME,
                    access_token=access_token,
                    refresh_token=auth_creds.refresh_token)
                google_client = gdata.contacts.client.ContactsClient(
                    source=SOURCE_APP_NAME)
                google_client.auth_token = two_legged_oauth_token
                return google_client
            except (gdata.client.BadAuthentication, gdata.client.Unauthorized,
                    OAuthError):

                if not retry_conn_errors:  # end of the line
                    raise ValidationError

                # If there are no valid refresh_tokens, will raise an
                # OAuthError, stopping the sync
                g_token_manager.get_token_for_contacts(account,
                                                       force_refresh=True)
                return self._google_client(retry_conn_errors=False)

            except ConnectionError:
                self.log.error('Connection error')
                raise

    def _parse_contact_result(self, google_contact):
        """Constructs a Contact object from a Google contact entry.

        Parameters
        ----------
        google_contact: gdata.contacts.entry.ContactEntry
            The Google contact entry to parse.

        Returns
        -------
        ..models.tables.base.Contact
            A corresponding Inbox Contact instance.

        Raises
        ------
        AttributeError
           If the contact data could not be parsed correctly.
        """
        email_addresses = [
            email for email in google_contact.email if email.primary
        ]
        if email_addresses and len(email_addresses) > 1:
            self.log.error(
                "Should not have more than one email per entry! {0}".format(
                    email_addresses))
        try:
            # The id.text field of a ContactEntry object takes the form
            # 'http://www.google.com/m8/feeds/contacts/<useremail>/base/<uid>'.
            # We only want the <uid> part.
            raw_google_id = google_contact.id.text
            _, g_id = posixpath.split(raw_google_id)
            name = (google_contact.name.full_name.text if
                    (google_contact.name
                     and google_contact.name.full_name) else None)
            email_address = (email_addresses[0].address
                             if email_addresses else None)

            # The entirety of the raw contact data in XML string
            # representation.
            raw_data = google_contact.to_string()
        except AttributeError, e:
            self.log.error('Something is wrong with contact',
                           contact=google_contact)
            raise e

        deleted = google_contact.deleted is not None

        return Contact(namespace_id=self.namespace_id,
                       uid=g_id,
                       name=name,
                       provider_name=self.PROVIDER_NAME,
                       email_address=email_address,
                       deleted=deleted,
                       raw_data=raw_data)