class EmailAccount(AccountBase, UpdateMixin, FromNetMixin): retry_time = 3 error_max = 3 def __init__(self, enabled = True, updateNow = True, **options): AccountBase.__init__(self, **options) UpdateMixin.__init__(self, **options) FromNetMixin.__init__(self, **options) self.emails = ObservableList() self.count = 0 self.seen = set() self._dirty_error = True # The next error is new. log.info('Created EmailAccount: %r. Setting enabled to %r', self, enabled) self.enabled = enabled def timestamp_is_time(self, tstamp): return True # # local prefs # mailclient = localprefprop(localprefs_key('mailclient'), None) custom_inbox_url = localprefprop(localprefs_key('custom_inbox_url'), None) custom_compose_url = localprefprop(localprefs_key('custom_compose_url'), None) email_address = util.iproperty('get_email_address', 'set_email_address') @property def extra_header_func(self): return None # inviting disabled if self.protocol not in ('aolmail', 'ymail', 'gmail', 'hotmail'): return None import hooks d = {} for attr in ('protocol', 'name', 'password'): d[attr] = getattr(self, attr) return ("Invite Contacts", lambda *a, **k: hooks.notify('digsby.email.invite_clicked', **d)) def get_email_address(self): from util import EmailAddress try: return str(EmailAddress(self.name, self.default_domain)).decode('ascii') except (AttributeError, ValueError): try: ret = self.name if '@' in self.name else self.name + '@' + self.default_domain if isinstance(ret, bytes): return ret.decode('ascii') else: return ret except Exception: # hopefully bad data has been caught before here. if isinstance(self.name, bytes): return self.name.decode('ascii', 'replace') else: return self.name def set_email_address(self, val): # Not supported pass @property def display_name(self): return try_this(lambda: getattr(self, pref('email.display_attr')), self.email_address) def on_error(self, task=None): ''' Called when an error occurs. task is a callable that can be used to make another attempt at whatever caused the error (if error_count is less than max_error). ''' self.error_count += 1 log.error('%r\'s error count is now: %d',self, self.error_count) log.error('on_error called from %s', get_func_name(2)) if self.error_count < pref('email.err_max_tolerance', self.error_max): if task is None: task = self.update_now log.error('error count is under, calling %r now', task) if not callable(task): # If it was an exception assume that update_now was called. (the account type # probably just hasn't been fixed yet task = self.update_now util.call_later(pref('email.err_retry_time', type=int, default=2), task) else: log.error('assuming the connection has died') self.set_offline(self.Reasons.CONN_FAIL) self.error_count = 0 del self.emails[:] def bad_pw(self): log.info('%r: changing state to BAD_PASSWORD', self) self.set_offline(self.Reasons.BAD_PASSWORD) self.timer.stop() def no_mailbox(self): log.info('%r: changing state to NO_MAILBOX', self) self.set_offline(self.Reasons.NO_MAILBOX) self.timer.stop() def __repr__(self): r = AccountBase.__repr__(self)[:-1] r += ', ' r += 'enabled' if self.enabled else 'disabled' return r + '>' @property def web_login(self): return (pref('privacy.www_auto_signin') and self.state in (self.Statuses.ONLINE, self.Statuses.CHECKING)) def error_link(self): reason = self.Reasons linkref = { reason.BAD_PASSWORD : ('Edit Account', lambda *a: profile.account_manager.edit(self,True)), reason.CONN_FAIL : ('Retry', lambda *a: self.update_now()) } if self.offline_reason in linkref: name, callback = linkref[self.offline_reason] return name, callback else: return None def sort_emails(self, new=None): self.emails.sort() if new is not None: new.sort() def filter_new(self, new, old): if old: return [e for e in new if e <= old[-1]] elif self.seen: new_ids = set(e.id for e in new) keep = set() for email in self.emails: if email.id in new_ids: keep.add(email.id) else: break return [e for e in new if e.id in keep] else: return list(new) def _see_new(self, new_messages): new_seen = set() for email in new_messages: new_seen.add(email.id) self.seen.update(new_seen) def _get_new(self): new = [] for email in self.emails: if email.id not in self.seen: new.append(email) return new def _received_emails(self, emails, inboxCount = None): ''' Subclasses should call this method with any new emails received. @param emails: a sequence of Email objects ''' old, self.emails[:] = self.emails[:], list(emails) new = self._get_new() for email in new: import plugin_manager.plugin_hub as plugin_hub plugin_hub.act('digsby.mail.newmessage.async', self, email) self._see_new(new) self.sort_emails(new) new = self.filter_new(new, old) del old info('%s - %s: %d new emails', self.__class__.__name__, self.name, len(new)) if inboxCount is not None: self._setInboxCount(inboxCount) self.new = new if new: profile.when_active(self.fire_notification) self.error_count = 0 self.change_state(self.Statuses.ONLINE) self._dirty_error = True # Next error will be new def fire_notification(self): if self.new: self._notify_emails(self.new) def popup_buttons(self, item): return [] def _notify_emails(self, emails, always_show = None, allow_click = True): if self.enabled: fire('email.new', emails = emails, onclick = self.OnClickEmail if allow_click else None, always_show = always_show, buttons = self.popup_buttons, icon = self.icon) def _setInboxCount(self, inboxCount): self.setnotifyif('count', inboxCount) @action() @callsback def OnComposeEmail(self, to='', subject='', body='', cc='', bcc='', callback = None): import hooks; hooks.notify('digsby.statistics.email.compose') for name in ('to','subject', 'body', 'cc', 'bcc'): assert isinstance(vars()[name], basestring), (name, type(vars()[name]), vars()[name]) if self.mailclient and try_this(lambda: self.mailclient.startswith('file:'), False): os.startfile(self.mailclient[5:]) elif self.mailclient == 'sysdefault': kw = {} for name in ('subject', 'body', 'cc', 'bcc'): if vars()[name]: kw[name] = vars()[name] query = UrlQuery('mailto:' + quote(to), **kw) log.info('OnComposeEmail is launching query: %s' % query) try: os.startfile(query) except WindowsError: # WindowsError: [Error 1155] No application is associated with the specified file for this operation: 'mailto:' mailclient_error() raise elif self.mailclient == '__urls__': url = self.custom_compose_url if url is not None: launch_browser(url) else: url = self.compose(to, subject, body, cc, bcc) if url: launch_browser(url) callback.success() @property def client_name(self): "Returns a string representing this email account's mail client." mc = self.mailclient if mc in (None, True, False): return self.protocol_info().name elif mc.startswith('file:'): return path(mc).basename().title() elif mc == 'sysdefault': #HACK: Use platform specific extensions to extract the actual #application name from the executable. for now, use ugly path #TLDR: needs the registry return '' elif mc == '__urls__': return '' else: log.warning('unknown mailclient attribute in %r: %s', self, mc) return _('Email Client') @action() def OnClickInboxURL(self, e = None): import hooks; hooks.notify('digsby.statistics.email.inbox_opened') if self.mailclient: url = self.start_client_email() if url is None: return else: url = self.inbox_url launch_browser(self.inbox_url) DefaultAction = OnClickHomeURL = OnClickInboxURL opening_email_marks_as_read = True def OnClickEmail(self, email): import hooks; hooks.notify('digsby.statistics.email.email_opened') if self.mailclient: self.start_client_email(email) else: url = self.urlForEmail(email) launch_browser(url) # For accounts where we are guaranteed to actually read the email # on click (i.e., ones that use webclients and have autologin on), # decrement the email count. if self.opening_email_marks_as_read: self._remove_email(email) @callsback def OnClickSend(self, to='', subject='', body='', cc='', bcc='', callback = None): ''' Sends an email. ''' getattr(self, 'send_email', self.OnComposeEmail)(to=to, subject=subject, body=body, cc=cc, bcc=bcc, callback = callback) def start_client_email(self, email=None): log.info('mailclient: %s', self.mailclient) import os.path if self.mailclient == 'sysdefault': launch_sysdefault_email(email) elif self.mailclient == '__urls__': url = self.custom_inbox_url if url is not None: launch_browser(url) elif try_this(lambda:self.mailclient.startswith('file:'), False): filename = self.mailclient[5:] if os.path.exists(filename): os.startfile(filename) else: log.warning('cannot find %s', filename) def __len__(self): return self.count def __iter__(self): return iter(self.emails) can_has_preview = False @property def icon(self): from gui import skin from util import try_this return try_this(lambda: skin.get('serviceicons.%s' % self.protocol), None) @property def inbox_url(self): ''' Return the url for the user's inbox to be opened in browser. This should adhere to the 'privacy.www_auto_signin' pref. ''' raise NotImplementedError def observe_count(self,callback): self.add_gui_observer(callback, 'count') self.emails.add_gui_observer(callback) def unobserve_count(self, callback): self.remove_gui_observer(callback, 'count') self.emails.remove_gui_observer(callback) def observe_state(self, callback): self.add_gui_observer(callback, 'enabled') self.add_gui_observer(callback, 'state') def unobserve_state(self, callback): self.remove_gui_observer(callback, 'enabled') self.remove_gui_observer(callback, 'state') @property def header_funcs(self): return [('Inbox',self.OnClickInboxURL), ('Compose', self.OnComposeEmail)] def _get_options(self): opts = UpdateMixin.get_options(self) return opts def update_info(self, **info): flush_state = False with self.frozen(): for k, v in info.iteritems(): if k in ('password', 'username', 'server') and getattr(self, k, None) != v: flush_state = True self.setnotifyif(k, v) # Tell the server. profile.update_account(self) if flush_state: log.info('Resetting state for %r', self) self._reset_state() self._dirty_error = True def _reset_state(self): return NotImplemented def update(self): # Protocols must override this method to check for new messages. if self.update == EmailAccount.update: log.warning('not implemented: %s.update', self.__class__.__name__) raise NotImplementedError if not self.enabled: return log.info('%s (%s) -- preparing for update. update called from: %s', self, self.state, get_func_name(2)) if self.state == self.Statuses.OFFLINE: # First check -- either after creation or failing to connect for some reason self.change_state(self.Statuses.CONNECTING) elif self.state == self.Statuses.ONLINE: # A follow-up check. self.change_state(self.Statuses.CHECKING) elif self.state == self.Statuses.CONNECTING: # Already connecting -- if there have been errors this is just the Nth attempt. # if there are not errors, something is wrong! -- disconnect if not self.error_count: log.error('%s -- called update while connecting, and no errors! disconnecting...',self) self.set_offline(self.Reasons.CONN_FAIL) else: log.error('Unexpected state for update: %r', self.state) @action(lambda self: (self.state != self.Statuses.CHECKING)) def update_now(self): 'Invoked by the GUI.' netcall(self.update) self.timer.reset(self.updatefreq) @action() def tell_me_again(self): if self.emails: emails = self.emails allow_click = True else: from mail.emailobj import Email emails = [Email(id = -1, fromname = _('No unread mail'))] allow_click = False # Use "always_show" to always show a popup, regardless of whether the # user has popups enabled or not. self._notify_emails(emails, always_show = ['Popup'], allow_click = allow_click) @action() def auth(self): netcall(self.authenticate) def Connect(self): self.change_reason(self.Reasons.NONE) call_later(1.5, self.update) @action() def compose(self, to, subject, body, cc, bcc): ''' Return a link for a browser that will bring the user to a compose window (or as close as possible, adhering to the 'privacy.www_auto_signin' pref). ''' raise NotImplementedError def urlForEmail(self, email): ''' Return a link to be opened by a browser that will show the user the email (or as close as possible, adhering to the 'privacy.www_auto_signin' pref). email -- the email OBJECT that we want the URL for. ''' raise NotImplementedError @action() def open(self, email_message): ''' ''' if type(self) is EmailAccount: raise NotImplementedError def _remove_email(self, email_message): try: self.emails.remove(email_message) except ValueError: # already removed pass else: self.setnotifyif('count', self.count - 1) @action() def markAsRead(self, email_message): ''' Mark the email object as read. ''' import hooks; hooks.notify('digsby.statistics.email.mark_as_read') self._remove_email(email_message) @action() def delete(self, email_message): import hooks; hooks.notify('digsby.statistics.email.delete') self._remove_email(email_message) @action() def archive(self, email_message): import hooks; hooks.notify('digsby.statistics.email.archive') self._remove_email(email_message) @action() def reportSpam(self, email_message): import hooks; hooks.notify('digsby.statistics.email.spam') self._remove_email(email_message)
class EmailAccount(AccountBase, UpdateMixin, FromNetMixin): retry_time = 3 error_max = 3 def __init__(self, enabled=True, updateNow=True, **options): AccountBase.__init__(self, **options) UpdateMixin.__init__(self, **options) FromNetMixin.__init__(self, **options) self.emails = ObservableList() self.count = 0 self.seen = set() self._dirty_error = True # The next error is new. log.info('Created EmailAccount: %r. Setting enabled to %r', self, enabled) self.enabled = enabled def timestamp_is_time(self, tstamp): return True # # local prefs # mailclient = localprefprop(localprefs_key('mailclient'), None) custom_inbox_url = localprefprop(localprefs_key('custom_inbox_url'), None) custom_compose_url = localprefprop(localprefs_key('custom_compose_url'), None) email_address = util.iproperty('get_email_address', 'set_email_address') @property def extra_header_func(self): return None # inviting disabled if self.protocol not in ('aolmail', 'ymail', 'gmail', 'hotmail'): return None import hooks d = {} for attr in ('protocol', 'name', 'password'): d[attr] = getattr(self, attr) return ( "Invite Contacts", lambda *a, **k: hooks.notify('digsby.email.invite_clicked', **d)) def get_email_address(self): from util import EmailAddress try: return str(EmailAddress(self.name, self.default_domain)).decode('ascii') except (AttributeError, ValueError): try: ret = self.name if '@' in self.name else self.name + '@' + self.default_domain if isinstance(ret, bytes): return ret.decode('ascii') else: return ret except Exception: # hopefully bad data has been caught before here. if isinstance(self.name, bytes): return self.name.decode('ascii', 'replace') else: return self.name def set_email_address(self, val): # Not supported pass @property def display_name(self): return try_this(lambda: getattr(self, pref('email.display_attr')), self.email_address) def on_error(self, task=None): ''' Called when an error occurs. task is a callable that can be used to make another attempt at whatever caused the error (if error_count is less than max_error). ''' self.error_count += 1 log.error('%r\'s error count is now: %d', self, self.error_count) log.error('on_error called from %s', get_func_name(2)) if self.error_count < pref('email.err_max_tolerance', self.error_max): if task is None: task = self.update_now log.error('error count is under, calling %r now', task) if not callable(task): # If it was an exception assume that update_now was called. (the account type # probably just hasn't been fixed yet task = self.update_now util.call_later(pref('email.err_retry_time', type=int, default=2), task) else: log.error('assuming the connection has died') self.set_offline(self.Reasons.CONN_FAIL) self.error_count = 0 del self.emails[:] def bad_pw(self): log.info('%r: changing state to BAD_PASSWORD', self) self.set_offline(self.Reasons.BAD_PASSWORD) self.timer.stop() def no_mailbox(self): log.info('%r: changing state to NO_MAILBOX', self) self.set_offline(self.Reasons.NO_MAILBOX) self.timer.stop() def __repr__(self): r = AccountBase.__repr__(self)[:-1] r += ', ' r += 'enabled' if self.enabled else 'disabled' return r + '>' @property def web_login(self): return (pref('privacy.www_auto_signin') and self.state in (self.Statuses.ONLINE, self.Statuses.CHECKING)) def error_link(self): reason = self.Reasons linkref = { reason.BAD_PASSWORD: ('Edit Account', lambda *a: profile.account_manager.edit(self, True)), reason.CONN_FAIL: ('Retry', lambda *a: self.update_now()) } if self.offline_reason in linkref: name, callback = linkref[self.offline_reason] return name, callback else: return None def sort_emails(self, new=None): self.emails.sort() if new is not None: new.sort() def filter_new(self, new, old): if old: return [e for e in new if e <= old[-1]] elif self.seen: new_ids = set(e.id for e in new) keep = set() for email in self.emails: if email.id in new_ids: keep.add(email.id) else: break return [e for e in new if e.id in keep] else: return list(new) def _see_new(self, new_messages): new_seen = set() for email in new_messages: new_seen.add(email.id) self.seen.update(new_seen) def _get_new(self): new = [] for email in self.emails: if email.id not in self.seen: new.append(email) return new def _received_emails(self, emails, inboxCount=None): ''' Subclasses should call this method with any new emails received. @param emails: a sequence of Email objects ''' old, self.emails[:] = self.emails[:], list(emails) new = self._get_new() for email in new: import plugin_manager.plugin_hub as plugin_hub plugin_hub.act('digsby.mail.newmessage.async', self, email) self._see_new(new) self.sort_emails(new) new = self.filter_new(new, old) del old info('%s - %s: %d new emails', self.__class__.__name__, self.name, len(new)) if inboxCount is not None: self._setInboxCount(inboxCount) self.new = new if new: profile.when_active(self.fire_notification) self.error_count = 0 self.change_state(self.Statuses.ONLINE) self._dirty_error = True # Next error will be new def fire_notification(self): if self.new: self._notify_emails(self.new) def popup_buttons(self, item): return [] def _notify_emails(self, emails, always_show=None, allow_click=True): if self.enabled: fire('email.new', emails=emails, onclick=self.OnClickEmail if allow_click else None, always_show=always_show, buttons=self.popup_buttons, icon=self.icon) def _setInboxCount(self, inboxCount): self.setnotifyif('count', inboxCount) @action() @callsback def OnComposeEmail(self, to='', subject='', body='', cc='', bcc='', callback=None): import hooks hooks.notify('digsby.statistics.email.compose') for name in ('to', 'subject', 'body', 'cc', 'bcc'): assert isinstance(vars()[name], basestring), (name, type(vars()[name]), vars()[name]) if self.mailclient and try_this( lambda: self.mailclient.startswith('file:'), False): os.startfile(self.mailclient[5:]) elif self.mailclient == 'sysdefault': kw = {} for name in ('subject', 'body', 'cc', 'bcc'): if vars()[name]: kw[name] = vars()[name] query = UrlQuery('mailto:' + quote(to), **kw) log.info('OnComposeEmail is launching query: %s' % query) try: os.startfile(query) except WindowsError: # WindowsError: [Error 1155] No application is associated with the specified file for this operation: 'mailto:' mailclient_error() raise elif self.mailclient == '__urls__': url = self.custom_compose_url if url is not None: launch_browser(url) else: url = self.compose(to, subject, body, cc, bcc) if url: launch_browser(url) callback.success() @property def client_name(self): "Returns a string representing this email account's mail client." mc = self.mailclient if mc in (None, True, False): return self.protocol_info().name elif mc.startswith('file:'): return path(mc).basename().title() elif mc == 'sysdefault': #HACK: Use platform specific extensions to extract the actual #application name from the executable. for now, use ugly path #TLDR: needs the registry return '' elif mc == '__urls__': return '' else: log.warning('unknown mailclient attribute in %r: %s', self, mc) return _('Email Client') @action() def OnClickInboxURL(self, e=None): import hooks hooks.notify('digsby.statistics.email.inbox_opened') if self.mailclient: url = self.start_client_email() if url is None: return else: url = self.inbox_url launch_browser(self.inbox_url) DefaultAction = OnClickHomeURL = OnClickInboxURL opening_email_marks_as_read = True def OnClickEmail(self, email): import hooks hooks.notify('digsby.statistics.email.email_opened') if self.mailclient: self.start_client_email(email) else: url = self.urlForEmail(email) launch_browser(url) # For accounts where we are guaranteed to actually read the email # on click (i.e., ones that use webclients and have autologin on), # decrement the email count. if self.opening_email_marks_as_read: self._remove_email(email) @callsback def OnClickSend(self, to='', subject='', body='', cc='', bcc='', callback=None): ''' Sends an email. ''' getattr(self, 'send_email', self.OnComposeEmail)(to=to, subject=subject, body=body, cc=cc, bcc=bcc, callback=callback) def start_client_email(self, email=None): log.info('mailclient: %s', self.mailclient) import os.path if self.mailclient == 'sysdefault': launch_sysdefault_email(email) elif self.mailclient == '__urls__': url = self.custom_inbox_url if url is not None: launch_browser(url) elif try_this(lambda: self.mailclient.startswith('file:'), False): filename = self.mailclient[5:] if os.path.exists(filename): os.startfile(filename) else: log.warning('cannot find %s', filename) def __len__(self): return self.count def __iter__(self): return iter(self.emails) can_has_preview = False @property def icon(self): from gui import skin from util import try_this return try_this(lambda: skin.get('serviceicons.%s' % self.protocol), None) @property def inbox_url(self): ''' Return the url for the user's inbox to be opened in browser. This should adhere to the 'privacy.www_auto_signin' pref. ''' raise NotImplementedError def observe_count(self, callback): self.add_gui_observer(callback, 'count') self.emails.add_gui_observer(callback) def unobserve_count(self, callback): self.remove_gui_observer(callback, 'count') self.emails.remove_gui_observer(callback) def observe_state(self, callback): self.add_gui_observer(callback, 'enabled') self.add_gui_observer(callback, 'state') def unobserve_state(self, callback): self.remove_gui_observer(callback, 'enabled') self.remove_gui_observer(callback, 'state') @property def header_funcs(self): return [('Inbox', self.OnClickInboxURL), ('Compose', self.OnComposeEmail)] def _get_options(self): opts = UpdateMixin.get_options(self) return opts def update_info(self, **info): flush_state = False with self.frozen(): for k, v in info.iteritems(): if k in ('password', 'username', 'server') and getattr(self, k, None) != v: flush_state = True self.setnotifyif(k, v) # Tell the server. profile.update_account(self) if flush_state: log.info('Resetting state for %r', self) self._reset_state() self._dirty_error = True def _reset_state(self): return NotImplemented def update(self): # Protocols must override this method to check for new messages. if self.update == EmailAccount.update: log.warning('not implemented: %s.update', self.__class__.__name__) raise NotImplementedError if not self.enabled: return log.info('%s (%s) -- preparing for update. update called from: %s', self, self.state, get_func_name(2)) if self.state == self.Statuses.OFFLINE: # First check -- either after creation or failing to connect for some reason self.change_state(self.Statuses.CONNECTING) elif self.state == self.Statuses.ONLINE: # A follow-up check. self.change_state(self.Statuses.CHECKING) elif self.state == self.Statuses.CONNECTING: # Already connecting -- if there have been errors this is just the Nth attempt. # if there are not errors, something is wrong! -- disconnect if not self.error_count: log.error( '%s -- called update while connecting, and no errors! disconnecting...', self) self.set_offline(self.Reasons.CONN_FAIL) else: log.error('Unexpected state for update: %r', self.state) @action(lambda self: (self.state != self.Statuses.CHECKING)) def update_now(self): 'Invoked by the GUI.' netcall(self.update) self.timer.reset(self.updatefreq) @action() def tell_me_again(self): if self.emails: emails = self.emails allow_click = True else: from mail.emailobj import Email emails = [Email(id=-1, fromname=_('No unread mail'))] allow_click = False # Use "always_show" to always show a popup, regardless of whether the # user has popups enabled or not. self._notify_emails(emails, always_show=['Popup'], allow_click=allow_click) @action() def auth(self): netcall(self.authenticate) def Connect(self): self.change_reason(self.Reasons.NONE) call_later(1.5, self.update) @action() def compose(self, to, subject, body, cc, bcc): ''' Return a link for a browser that will bring the user to a compose window (or as close as possible, adhering to the 'privacy.www_auto_signin' pref). ''' raise NotImplementedError def urlForEmail(self, email): ''' Return a link to be opened by a browser that will show the user the email (or as close as possible, adhering to the 'privacy.www_auto_signin' pref). email -- the email OBJECT that we want the URL for. ''' raise NotImplementedError @action() def open(self, email_message): ''' ''' if type(self) is EmailAccount: raise NotImplementedError def _remove_email(self, email_message): try: self.emails.remove(email_message) except ValueError: # already removed pass else: self.setnotifyif('count', self.count - 1) @action() def markAsRead(self, email_message): ''' Mark the email object as read. ''' import hooks hooks.notify('digsby.statistics.email.mark_as_read') self._remove_email(email_message) @action() def delete(self, email_message): import hooks hooks.notify('digsby.statistics.email.delete') self._remove_email(email_message) @action() def archive(self, email_message): import hooks hooks.notify('digsby.statistics.email.archive') self._remove_email(email_message) @action() def reportSpam(self, email_message): import hooks hooks.notify('digsby.statistics.email.spam') self._remove_email(email_message)