From 5ed699eb3292f1ca72dd9a46e1dd56d67adb66da Mon Sep 17 00:00:00 2001 From: Daniel Colascione Date: Wed, 3 Feb 2021 13:53:58 -0500 Subject: [PATCH] Add GMail feed mode --- .dir-locals.el | 3 + .gitignore | 2 +- Mailnag/backends/__init__.py | 6 +- Mailnag/backends/gmail_rss.py | 211 ++++++++++++++ Mailnag/common/accounts.py | 8 + Mailnag/common/config.py | 5 +- Mailnag/configuration/accountdialog.py | 324 +++++++++++++++++----- Mailnag/daemon/idlers.py | 6 +- Mailnag/daemon/mails.py | 6 +- data/account_widget.ui | 365 +++++++++++++++++++------ 10 files changed, 774 insertions(+), 162 deletions(-) create mode 100644 .dir-locals.el create mode 100644 Mailnag/backends/gmail_rss.py diff --git a/.dir-locals.el b/.dir-locals.el new file mode 100644 index 0000000..2bfea96 --- /dev/null +++ b/.dir-locals.el @@ -0,0 +1,3 @@ +( + (python-mode . ((indent-tabs-mode . t))) +) diff --git a/.gitignore b/.gitignore index a3dbe1e..7ede85f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ locale Mailnag/plugins/goaplugin.py Mailnag/plugins/messagingmenuplugin.py /.cache - +*~ diff --git a/Mailnag/backends/__init__.py b/Mailnag/backends/__init__.py index 2f1da81..f9eb040 100644 --- a/Mailnag/backends/__init__.py +++ b/Mailnag/backends/__init__.py @@ -25,6 +25,7 @@ from Mailnag.backends.imap import IMAPMailboxBackend from Mailnag.backends.pop3 import POP3MailboxBackend from Mailnag.backends.local import MBoxBackend, MaildirBackend +from Mailnag.backends.gmail_rss import GMailRssBackend from Mailnag.common.utils import splitstr @@ -80,9 +81,12 @@ def _bool_to_str(b): Param('path', 'path', str, str, ''), Param('folders', 'folder', _str_to_folders, _folders_to_str, []), ]), + 'gmail_rss' : Backend(GMailRssBackend, [ + Param('gmail_labels', 'gmail_labels', _str_to_folders, _folders_to_str, []), + Param('password', 'password', str, str, ''), + ]) } - def create_backend(mailbox_type, **kw): """Create mailbox backend of specified type and parameters.""" return _backends[mailbox_type].backend_class(**kw) diff --git a/Mailnag/backends/gmail_rss.py b/Mailnag/backends/gmail_rss.py new file mode 100644 index 0000000..bcce588 --- /dev/null +++ b/Mailnag/backends/gmail_rss.py @@ -0,0 +1,211 @@ +# Copyright 2021 Daniel Colascione +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301, USA. +# + +"""Implemention for GMail RSS parsing + +Store the cookies for GMail fetching in a cookie jar file encrypted +with the "password" we store in the account information. This way, we +don't have to constantly rewrite configuration or the secret store as +cookies change. +""" + +import concurrent.futures +from http.cookiejar import Cookie, CookieJar +import http.cookies +import pickle +import logging +import os.path +import urllib.parse +import urllib.request +from datetime import datetime +import dateutil.parser +import tempfile +from email.message import EmailMessage +from email.utils import format_datetime + +from cryptography.fernet import Fernet, InvalidToken + +from Mailnag.backends.base import MailboxBackend +from Mailnag.common.config import cfg_folder + +GMAIL_URL = "https://mail.google.com/" +GMAIL_RSS_URL = "https://mail.google.com/mail/u/0/feed/atom" + +def generate_gmail_cookies_key(): + # The generated key is base64 encoded, so we can treat it as + # an ASCII string. + return Fernet.generate_key().decode("ASCII") + +def get_gmail_cookie_file_name(account_name): + return os.path.join(cfg_folder, f"{account_name}.cookies") + +def load_gmail_cookies(account_name, cookies_key): + """Load GMail cookies for account + + Result is a CookieJar object. + """ + file_name = get_gmail_cookie_file_name(account_name) + try: + with open(file_name, "rb") as f: + ciphertext = f.read() + except IOError: + return [] + try: + cookies = pickle.loads( + Fernet(cookies_key.encode("ASCII")).decrypt(ciphertext)) + except InvalidToken: + return [] + cookie_jar = CookieJar() + for cookie in cookies: + if not isinstance(cookie, http.cookiejar.Cookie): + return [] + cookie_jar.set_cookie(cookie) + return cookie_jar + +def _serialize_gmail_cookies(cookie_jar): + # The CookieJar object can't be pickled, but individual + # cookies inside it can be. + assert isinstance(cookie_jar, http.cookiejar.CookieJar) + return pickle.dumps(list(cookie_jar)) + +def save_gmail_cookies(account_name, cookies_key, cookie_jar): + """Save GMail cookies for account.""" + fn = get_gmail_cookie_file_name(account_name) + ciphertext = (Fernet(cookies_key.encode("ASCII")) + .encrypt(_serialize_gmail_cookies(cookie_jar))) + with tempfile.NamedTemporaryFile( + dir=os.path.dirname(fn), + prefix="gmail_rss", + suffix=".mailnag", + delete=False) as f: + try: + f.write(ciphertext) + f.flush() + os.rename(f.name, fn) + except: + os.unlink(f.name) + +def _feed_entry_to_message(entry): + message = EmailMessage() + ad = entry.get("author_detail", {}) + from_name = ad.get("name") + from_email = ad.get("email") + title = entry.get("title") + date = entry.get("published") + msg_id = entry.get("id") + if from_name and from_email: + message.add_header("From", f"{from_name} <{from_email}>") + elif from_name: + message.add_header("From", from_name) + else: + message.add_header("From", from_email) + if title is not None: + message.add_header("Subject", title) + if date is not None: + message.add_header("Date", format_datetime(dateutil.parser.parse(date))) + if msg_id is not None: + message.add_header("Message-Id", msg_id) + return message + +class GMailRssBackend(MailboxBackend): + """Implementation of GMail RSS parsing""" + def __init__(self, name, password, gmail_labels=(), **kw): + super().__init__(**kw) + self._name = name + self._gmail_labels = gmail_labels + self._gmail_cookies_key = password or '' + self._cookie_jar = None + self._opened = False + + def open(self): + self._cookie_jar = load_gmail_cookies(self._name, self._gmail_cookies_key) + if not self._cookie_jar: + raise ValueError("no cookies") + self._opened = True + + def close(self): + self._opened = False + self._cookie_jar = None + + def is_open(self): + return self._opened + + def _get_feed_for_label(self, data): + label = data["label"] + url = f"{GMAIL_RSS_URL}/{urllib.parse.quote(label)}" + import feedparser + return label, data["mode"], feedparser.parse(url, handlers=[ + urllib.request.HTTPCookieProcessor(self._cookie_jar) + ]) + + def _get_all_feeds(self): + labels = self._gmail_labels + have_included = False + for label in labels: + if label.get("mode", "").lower() != "exclude": + have_included = True + break + if not have_included: + lables = list(labels) + labels.append({"label": "inbox", "mode": "include"}) + try: + with concurrent.futures.ThreadPoolExecutor() as pool: + return pool.map(self._get_feed_for_label, + self._gmail_labels) + finally: + save_gmail_cookies(self._name, + self._gmail_cookies_key, + self._cookie_jar) + + def list_messages(self): + excluded_msg_ids = set() + all_feeds = list(self._get_all_feeds()) + for label, mode, feed in all_feeds: + if mode.lower() != "exclude": + continue + for entry in feed.get("entries"): + msg = _feed_entry_to_message(entry) + msg_id = msg.get("Message-Id") + if msg_id is not None: + excluded_msg_ids.add(msg_id) + for label, mode, feed in all_feeds: + if mode.lower() != "include": + continue + for entry in feed.get("entries", ()): + msg = _feed_entry_to_message(entry) + msg_id = msg.get("Message-Id") + if msg_id not in excluded_msg_ids: + yield label, msg, {} + + def request_folders(self): + raise NotImplementedError + + def supports_mark_as_seen(self): + return False + + def mark_as_seen(self, mails): + raise NotImplementedError + + def supports_notifications(self): + return False + + def notify_next_change(self, callback=None, timeout=None): + raise NotImplementedError + + def cancel_notifications(self): + raise NotImplementedError diff --git a/Mailnag/common/accounts.py b/Mailnag/common/accounts.py index 8a358f0..e79db1b 100644 --- a/Mailnag/common/accounts.py +++ b/Mailnag/common/accounts.py @@ -76,6 +76,9 @@ def set_config(self, mailbox_type, enabled, name, config): self._backend.close() self._backend = None + if mailbox_type == "gmail_rss": + self.user = name + self.server = "gmail_rss" def get_config(self): """Return account's configuration as a dict.""" @@ -255,6 +258,11 @@ def load_from_cfg(self, cfg, enabled_only = False): # Not every backend requires a password. user = options.get('user') server = options.get('server') + # TODO: get rid of the fake server and user names + if mailbox_type == "gmail_rss": + user = name + server = "gmail_rss" + imap = True if self._secretstore != None and user and server: password = self._secretstore.get(self._get_account_id(user, server, imap)) if not password: password = '' diff --git a/Mailnag/common/config.py b/Mailnag/common/config.py index acc6e1c..3009ffc 100644 --- a/Mailnag/common/config.py +++ b/Mailnag/common/config.py @@ -52,5 +52,8 @@ def read_cfg(): def write_cfg(cfg): if not os.path.exists(cfg_folder): os.makedirs(cfg_folder) + + - with open(cfg_file, 'w') as configfile: cfg.write(configfile) + with open(cfg_file, 'w') as configfile: + cfg.write(configfile) diff --git a/Mailnag/configuration/accountdialog.py b/Mailnag/configuration/accountdialog.py index cbf5924..2329343 100644 --- a/Mailnag/configuration/accountdialog.py +++ b/Mailnag/configuration/accountdialog.py @@ -18,16 +18,32 @@ # import os +import json +import logging +import base64 +import urllib +import urllib.request +import io +from http.cookiejar import CookieJar +from http.client import HTTPMessage +from _thread import start_new_thread + import gi gi.require_version('Gtk', '3.0') gi.require_version('GLib', '2.0') - from gi.repository import GObject, GLib, Gtk, Gdk -from _thread import start_new_thread + from Mailnag.common.dist_cfg import PACKAGE_NAME from Mailnag.common.i18n import _ from Mailnag.common.utils import get_data_file, splitstr from Mailnag.common.accounts import Account +from Mailnag.backends.gmail_rss import ( + GMAIL_URL, + GMAIL_RSS_URL, + load_gmail_cookies, + save_gmail_cookies, + generate_gmail_cookies_key, +) IDX_GMAIL = 0 IDX_GMX = 1 @@ -37,6 +53,7 @@ IDX_POP3 = 5 IDX_MBOX = 6 IDX_MAILDIR = 7 +IDX_GMAIL_RSS = 8 GMAIL_SUPPORT_PAGE = "https://support.google.com/mail/answer/185833?hl=en" @@ -54,34 +71,56 @@ def folder_cell_data(tree_column, cell, model, iter, *data): value = '[root folder]' cell.set_property('text', value) +class FakeHTTPResonse: + """Dummy response for working with CookieJar""" + def __init__(self, set_cookie_headers): + msg = HTTPMessage() + for set_cookie_header in set_cookie_headers: + msg.add_header("Set-Cookie", set_cookie_header) + self._info = msg + def info(self): + return self._info class AccountDialog: def __init__(self, parent, acc): self._acc = acc - + self._gmail_cookies_key = '' + self._gmail_cookies = CookieJar() + builder = Gtk.Builder() builder.set_translation_domain(PACKAGE_NAME) builder.add_from_file(get_data_file("account_widget.ui")) - builder.connect_signals({ \ - "account_type_changed" : self._on_cmb_account_type_changed, \ - "entry_changed" : self._on_entry_changed, \ - "expander_folders_activate" : self._on_expander_folders_activate, \ - "password_info_icon_released" : self._on_password_info_icon_released \ + builder.connect_signals({ + "account_type_changed" : self._on_cmb_account_type_changed, + "entry_changed" : self._on_entry_changed, + "expander_folders_activate" : self._on_expander_folders_activate, + "password_info_icon_released" : self._on_password_info_icon_released, + "log_in" : self._on_log_in, + "gmail_label_add" : self._on_gmail_label_add, + "gmail_label_remove" : self._on_gmail_label_remove, + "gmail_label_edited" : self._on_gmail_label_edited, + "gmail_label_mode_edited" : self._on_gmail_label_mode_edited, + "gmail_labels_selection_changed" : self._on_gmail_labels_selection_changed, }) self._window = Gtk.Dialog(title = _('Mail Account'), parent = parent, use_header_bar = True, \ buttons = (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) - + self._window.set_default_response(Gtk.ResponseType.OK) self._window.set_default_size(400, 0) - + self._box = self._window.get_content_area() self._box.set_border_width(12) self._box.set_spacing(12) - + self._box.pack_start(builder.get_object("account_widget"), True, True, 0) self._cmb_account_type = builder.get_object("cmb_account_type") + self._label_log_in = builder.get_object("label_log_in") + self._label_gmail_labels = builder.get_object("label_gmail_labels") + self._box_gmail_labels = builder.get_object("box_gmail_labels") + self._button_log_in = builder.get_object("button_log_in") + self._button_remove_gmail_label = builder.get_object("button_remove_gmail_label") self._label_account_name = builder.get_object("label_account_name") self._entry_account_name = builder.get_object("entry_account_name") self._label_account_user = builder.get_object("label_account_user") @@ -89,7 +128,7 @@ def __init__(self, parent, acc): self._label_account_password = builder.get_object("label_account_password") self._entry_account_password = builder.get_object("entry_account_password") self._label_account_server = builder.get_object("label_account_server") - self._entry_account_server = builder.get_object("entry_account_server") + self._entry_account_server = builder.get_object("entry_account_server") self._label_account_port = builder.get_object("label_account_port") self._entry_account_port = builder.get_object("entry_account_port") self._label_account_file_path = builder.get_object("label_account_file_path") @@ -100,17 +139,19 @@ def __init__(self, parent, acc): self._overlay = builder.get_object("overlay") self._treeview_folders = builder.get_object("treeview_folders") self._liststore_folders = builder.get_object("liststore_folders") + self._liststore_gmail_labels = builder.get_object("liststore_gmail_labels") + self._treeview_gmail_labels = builder.get_object("treeview_gmail_labels") self._chk_account_push = builder.get_object("chk_account_push") self._chk_account_ssl = builder.get_object("chk_account_ssl") - + self._button_ok = self._window.get_widget_for_response(Gtk.ResponseType.OK) self._button_ok.set_sensitive(False) - + self._error_label = None self._folders_received = False self._selected_folder_count = 0 self._pwd_info_icon = self._entry_account_password.get_icon_name(Gtk.EntryIconPosition.SECONDARY) - + self._entry_account_port.set_placeholder_text(_("optional")) renderer_folders_enabled = Gtk.CellRendererToggle() @@ -124,25 +165,26 @@ def __init__(self, parent, acc): column_folders_name = Gtk.TreeViewColumn(_('Name'), renderer_folders_name, text = 1) column_folders_name.set_cell_data_func(renderer_folders_name, folder_cell_data) self._treeview_folders.append_column(column_folders_name) - - + self._on_gmail_labels_selection_changed( + self._treeview_gmail_labels.get_selection()) + def run(self): self._fill_account_type_cmb() self._load_account(self._acc) - + res = self._window.run() - + if res == Gtk.ResponseType.OK: self._configure_account(self._acc) - + self._window.destroy() return res - + def get_account(self): return self._acc - - + + def _load_account(self, acc): config = acc.get_config() self._entry_account_name.set_text(acc.name) @@ -150,6 +192,12 @@ def _load_account(self, acc): self._entry_account_user.set_text(config['user']) if 'password' in config: self._entry_account_password.set_text(config['password']) + if 'gmail_labels' in config: + self._liststore_gmail_labels.clear() + for data in config['gmail_labels']: + self._liststore_gmail_labels.append( + [data["label"], + data.get("mode", "Include")]) if 'server' in config: self._entry_account_server.set_text(config['server']) if 'port' in config: @@ -168,12 +216,16 @@ def _load_account(self, acc): self._on_entry_changed(self._chooser_account_file_path) self._chooser_account_directory_path.set_filename(config.get('path')) self._on_entry_changed(self._chooser_account_directory_path) - - + if acc.mailbox_type == 'gmail_rss' and acc.name and 'password' in config: + self._set_gmail_credentials( + config['password'], + load_gmail_cookies(acc.name, config['password'])) + self._on_entry_changed(None) + def _configure_account(self, acc): config = {} acctype = self._cmb_account_type.get_active() - + if (acctype == IDX_POP3) or (acctype == IDX_IMAP): name = self._entry_account_name.get_text() config['user'] = self._entry_account_user.get_text() @@ -182,7 +234,7 @@ def _configure_account(self, acc): config['folders'] = [] config['port'] = self._entry_account_port.get_text() config['ssl'] = self._chk_account_ssl.get_active() - + if acctype == IDX_POP3: mailbox_type = 'pop3' config['imap'] = False @@ -205,6 +257,23 @@ def _configure_account(self, acc): config['folders'] = [] if self._folders_received: config['folders'] = self._get_selected_folders() + elif acctype == IDX_GMAIL_RSS: + name = self._entry_account_name.get_text() + mailbox_type = 'gmail_rss' + assert self._gmail_cookies_key + assert self._gmail_cookies + config['password'] = self._gmail_cookies_key + save_gmail_cookies(name, self._gmail_cookies_key, self._gmail_cookies) + label_list = [] + def _row_cb(model, _path, it): + label, mode = model.get(it, 0, 1) + label_list.append({ + "label": label, + "mode": mode, + }) + self._liststore_gmail_labels.foreach(_row_cb) + config['gmail_labels'] = label_list + else: # known provider (imap only) mailbox_type = 'imap' name = self._entry_account_user.get_text() @@ -216,7 +285,7 @@ def _configure_account(self, acc): if self._folders_received: config['folders'] = self._get_selected_folders() config['idle'] = (len(config['folders']) < 2) - + if acctype < len(PROVIDER_CONFIGS): p = PROVIDER_CONFIGS[acctype] name += (' (%s)' % p[0]) @@ -224,7 +293,7 @@ def _configure_account(self, acc): config['port'] = p[2] else: raise Exception('Unknown account type') - + acc.set_config( mailbox_type=mailbox_type, enabled=acc.enabled, @@ -238,8 +307,8 @@ def _get_selected_folders(self): if row[0]: folders.append(row[1]) return folders - - + + def _fill_account_type_cmb(self): # fill acount type cmb for p in PROVIDER_CONFIGS: @@ -248,6 +317,7 @@ def _fill_account_type_cmb(self): self._cmb_account_type.append_text(_("POP3 (Custom)")) self._cmb_account_type.append_text(_("MBox (Custom)")) self._cmb_account_type.append_text(_("Maildir (Custom)")) + self._cmb_account_type.append_text(_("GMail RSS (No IMAP)")) config = self._acc.get_config() @@ -276,6 +346,8 @@ def _fill_account_type_cmb(self): idx = IDX_MBOX elif self._acc.mailbox_type == 'maildir': idx = IDX_MAILDIR + elif self._acc.mailbox_type == 'gmail_rss': + idx = IDX_GMAIL_RSS else: # This is actually error case, but recovering to IMAP idx = IDX_IMAP @@ -287,7 +359,7 @@ def _fill_account_type_cmb(self): is_type_change_allowed = True self._cmb_account_type.set_sensitive(is_type_change_allowed) - + def _on_entry_changed(self, widget): # # Validate @@ -304,57 +376,60 @@ def _on_entry_changed(self, widget): elif acctype == IDX_MAILDIR: ok = len(self._entry_account_name.get_text()) > 0 and \ (self._chooser_account_directory_path.get_filename() is not None) + elif acctype == IDX_GMAIL_RSS: + ok = len(self._entry_account_name.get_text()) > 0 and \ + self._gmail_cookies and self._gmail_cookies_key else: # known provider ok = len(self._entry_account_user.get_text()) > 0 and \ len(self._entry_account_password.get_text()) > 0 - + self._expander_folders.set_sensitive(self._folders_received or ok) self._button_ok.set_sensitive(ok) - - + + def _on_expander_folders_activate(self, widget): # Folder list has already been loaded or # expander is going to be closed -> do nothing. if self._folders_received or \ self._expander_folders.get_expanded(): return - + if self._error_label != None: self._error_label.destroy() self._error_label = None - + spinner = Gtk.Spinner() spinner.set_halign(Gtk.Align.CENTER) - spinner.set_valign(Gtk.Align.CENTER) + spinner.set_valign(Gtk.Align.CENTER) spinner.start() - + self._overlay.add_overlay(spinner) self._overlay.show_all() - + # Executed on a new worker thread def worker_thread(name): folders = [] exception = None - + try: acc = Account() self._configure_account(acc) folders = acc.request_server_folders() except Exception as ex: exception = ex - + # Executed on the GTK main thread - def finished_func(): + def finished_func(): spinner.stop() spinner.destroy() - + if exception != None: self._error_label = Gtk.Label() self._error_label.set_justify(Gtk.Justification.CENTER) self._error_label.set_halign(Gtk.Align.CENTER) self._error_label.set_valign(Gtk.Align.CENTER) self._error_label.set_markup('%s' % _('Connection failed.')) - + self._overlay.add_overlay(self._error_label) self._overlay.show_all() else: @@ -365,18 +440,18 @@ def finished_func(): self._selected_folder_count += 1 row = [enabled, f] self._liststore_folders.append(row) - - # Enable the push checkbox in case a remote folder wasn't found + + # Enable the push checkbox in case a remote folder wasn't found # and the folder count is now <2. - # (e.g. folders have been renamed/removed on the server, the user has entered a + # (e.g. folders have been renamed/removed on the server, the user has entered a # diffent username/password in this dialog, ...) self._chk_account_push.set_sensitive(self._selected_folder_count < 2) self._folders_received = True - + GObject.idle_add(finished_func) - - start_new_thread(worker_thread, ("worker_thread",)) - + + start_new_thread(worker_thread, ("worker_thread",)) + def _on_password_info_icon_released(self, widget, icon_pos, event): Gtk.show_uri_on_window(None, GMAIL_SUPPORT_PAGE, Gdk.CURRENT_TIME) @@ -387,27 +462,32 @@ def _on_folder_toggled(self, cell, path): model = self._liststore_folders iter = model.get_iter(path) self._liststore_folders.set_value(iter, 0, isactive) - + if isactive: self._selected_folder_count += 1 else: self._selected_folder_count -= 1 - + if self._selected_folder_count < 2: self._chk_account_push.set_sensitive(True) else: self._chk_account_push.set_active(False) self._chk_account_push.set_sensitive(False) - - + + def _on_cmb_account_type_changed(self, widget): acctype = widget.get_active() self._entry_account_password.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, None) - + + self._label_gmail_labels.set_visible(acctype == IDX_GMAIL_RSS) + self._box_gmail_labels.set_visible(acctype == IDX_GMAIL_RSS) + self._label_log_in.set_visible(acctype == IDX_GMAIL_RSS) + self._button_log_in.set_visible(acctype == IDX_GMAIL_RSS) + # # Reset everything when the account type changes # - + if acctype == IDX_POP3: self._label_account_name.set_visible(True) self._entry_account_name.set_visible(True) @@ -480,6 +560,25 @@ def _on_cmb_account_type_changed(self, widget): self._chooser_account_file_path.set_visible(False) self._label_account_directory_path.set_visible(True) self._chooser_account_directory_path.set_visible(True) + elif acctype == IDX_GMAIL_RSS: + self._label_account_name.set_visible(True) + self._entry_account_name.set_visible(True) + self._label_account_user.set_visible(False) + self._entry_account_user.set_visible(False) + self._label_account_password.set_visible(False) + self._entry_account_password.set_visible(False) + self._label_account_server.set_visible(False) + self._entry_account_server.set_visible(False) + self._label_account_port.set_visible(False) + self._entry_account_port.set_visible(False) + self._expander_folders.set_visible(False) + self._chk_account_push.set_visible(False) + self._chk_account_ssl.set_visible(False) + self._label_account_file_path.set_visible(False) + self._chooser_account_file_path.set_visible(False) + self._label_account_directory_path.set_visible(False) + self._chooser_account_directory_path.set_visible(False) + else: # known provider (imap only) self._label_account_name.set_visible(False) self._entry_account_name.set_visible(False) @@ -498,17 +597,118 @@ def _on_cmb_account_type_changed(self, widget): self._chooser_account_file_path.set_visible(False) self._label_account_directory_path.set_visible(False) self._chooser_account_directory_path.set_visible(False) - + if acctype == IDX_GMAIL: self._entry_account_password.set_icon_from_icon_name( Gtk.EntryIconPosition.SECONDARY, self._pwd_info_icon) - + self._folders_received = False self._selected_folder_count = 0 - + self._expander_folders.set_expanded(False) self._liststore_folders.clear() - + empty_acc = Account() self._load_account(empty_acc) + def _on_gmail_label_add(self, _widget): + it = self._liststore_gmail_labels.append( + ["", "Include"]) + self._treeview_gmail_labels.set_cursor_on_cell( + self._liststore_gmail_labels.get_path(it), + focus_column=self._treeview_gmail_labels.get_column(0), + focus_cell=None, + start_editing=True) + + def _on_gmail_label_remove(self, _widget): + model = self._liststore_gmail_labels + selection = self._treeview_gmail_labels.get_selection() + _, row_paths = selection.get_selected_rows() + row_refs = [Gtk.TreeRowReference(model, path) + for path in row_paths] + for row_ref in row_refs: + model.remove(model.get_iter(row_ref.get_path())) + + def _on_gmail_labels_selection_changed(self, selection): + self._button_remove_gmail_label.set_sensitive( + selection.count_selected_rows() > 0) + + def _on_gmail_label_edited(self, cell, path, new_text): + if not new_text: + return + it = self._liststore_gmail_labels.get_iter_from_string(path) + self._liststore_gmail_labels.set_value(it, 0, new_text) + + def _on_gmail_label_mode_edited(self, cell, path, new_value_it): + [new_text] = cell.get_property("model").get(new_value_it, 0) + if new_text not in ["Include", "Exclude"]: + return + it = self._liststore_gmail_labels.get_iter_from_string(path) + self._liststore_gmail_labels.set_value(it, 1, new_text) + + def _set_gmail_credentials(self, password, cookies): + self._gmail_cookies_key = password + self._gmail_cookies = cookies + if password and cookies: + self._button_log_in.set_label(_("Logged in")) + else: + self._button_log_in.set_label(_("Log in to account")) + + def _on_log_in(self, _widget): + import gi + gi.require_version('WebKit2', '4.0') + from gi.repository import Gtk, WebKit2, Soup + loop = GLib.MainLoop() + window = Gtk.Window() + window.set_title("Log in to GMail") + window.set_default_size(800, 600) + window.set_destroy_with_parent(True) + window.set_transient_for(self._window) + window.set_modal(True) + scrolled_window = Gtk.ScrolledWindow() + window.add(scrolled_window) + webview = WebKit2.WebView() + webview.load_uri(GMAIL_URL) + scrolled_window.add(webview) + window.connect("destroy", lambda _: loop.quit()) + got_to_inbox = False + def _on_load_changed(_, load_event): + if load_event != WebKit2.LoadEvent.FINISHED: + return + uri = webview.get_uri() + nonlocal got_to_inbox + if uri == "https://mail.google.com/mail/u/0/#inbox": + nonlocal got_to_inbox + got_to_inbox = True + window.destroy() + webview.connect("load-changed", _on_load_changed) + window.show_all() + loop.run() + cookie_headers = None + def _got_cookies(cookies, result, data): + try: + headers = [] + for cookie in cookies.get_cookies_finish(result): + headers.append(cookie.to_set_cookie_header()) + nonlocal cookie_headers + cookie_headers = headers + except: + logging.warning("getting cookies failed", exc_info=True) + loop.quit() + webview.get_context().get_cookie_manager().get_cookies( + GMAIL_RSS_URL, None, _got_cookies, None) + loop.run() + window.destroy() + if not cookie_headers: + logging.warning("could not set cookies from login") + return + if not got_to_inbox: + logging.warning("login dialog closed before login done") + return + cookie_jar = CookieJar() + cookie_jar.extract_cookies( + FakeHTTPResonse(cookie_headers), + urllib.request.Request(GMAIL_RSS_URL)) + self._set_gmail_credentials(generate_gmail_cookies_key(), cookie_jar) + + self._on_entry_changed(None) diff --git a/Mailnag/daemon/idlers.py b/Mailnag/daemon/idlers.py index ee6d894..d6ba57b 100644 --- a/Mailnag/daemon/idlers.py +++ b/Mailnag/daemon/idlers.py @@ -66,7 +66,7 @@ def _idle(self): try: self._account.open() except Exception as ex: - logging.error("Failed to open mailbox for account '%s' (%s)." % (self._account.name, ex)) + logging.error("Failed to open mailbox for account '%s' (%s)." % (self._account.name, ex), exc_info=True) logging.info("Trying to reconnect Idler thread for account '%s' in %s minutes" % (self._account.name, str(self.RECONNECT_RETRY_INTERVAL))) self._wait(60 * self.RECONNECT_RETRY_INTERVAL) # don't hammer the server @@ -143,7 +143,7 @@ def _reconnect(self): self._account.open() logging.info("Successfully reconnected Idler thread for account '%s'." % self._account.name) except Exception as ex: - logging.error("Failed to reconnect Idler thread for account '%s' (%s)." % (self._account.name, ex)) + logging.error("Failed to reconnect Idler thread for account '%s' (%s)." % (self._account.name, ex), exc_info=True) logging.info("Trying to reconnect Idler thread for account '%s' in %s minutes" % (self._account.name, str(self.RECONNECT_RETRY_INTERVAL))) self._wait(60 * self.RECONNECT_RETRY_INTERVAL) # don't hammer the server @@ -174,7 +174,7 @@ def start(self): idler.start() self._idlerlist.append(idler) except Exception as ex: - logging.error("Error: Failed to create an idler thread for account '%s' (%s)" % (acc.name, ex)) + logging.error("Error: Failed to create an idler thread for account '%s' (%s)" % (acc.name, ex), exc_info=True) def dispose(self): diff --git a/Mailnag/daemon/mails.py b/Mailnag/daemon/mails.py index f53500a..5e44c12 100644 --- a/Mailnag/daemon/mails.py +++ b/Mailnag/daemon/mails.py @@ -64,14 +64,14 @@ def collect_mail(self, sort = True): if not acc.is_open(): acc.open() except Exception as ex: - logging.error("Failed to open mailbox for account '%s' (%s)." % (acc.name, ex)) + logging.error("Failed to open mailbox for account '%s' (%s)." % (acc.name, ex), + exc_info=True) continue try: for folder, msg, flags in acc.list_messages(): sender, subject, datetime, msgid = self._get_header(msg) id = self._get_id(msgid, acc, folder, sender, subject, datetime) - # Discard mails with identical IDs (caused # by mails with a non-unique fallback ID, # i.e. mails received in the same folder with @@ -93,7 +93,7 @@ def collect_mail(self, sort = True): if acc.supports_notifications(): raise else: - logging.error("An error occured while processing mails of account '%s' (%s)." % (acc.name, ex)) + logging.error("An error occured while processing mails of account '%s' (%s)." % (acc.name, ex), exc_info=True) finally: # leave account with notifications open, so that it can # send notifications about new mails diff --git a/data/account_widget.ui b/data/account_widget.ui index 82ee8cc..ccd4dc6 100644 --- a/data/account_widget.ui +++ b/data/account_widget.ui @@ -1,5 +1,5 @@ - + @@ -10,219 +10,240 @@ - + + + + + + + + Include + + + Exclude + + + + + + + + + + + True - False - 6 - 6 - 6 + False + 6 + 6 + 6 True - True + True True - - number + + number - 1 - 5 + 1 + 7 True - True + True True - + - 1 - 4 + 1 + 6 True - True + True True False - - emblem-important - You may need to create an application-specific password for Gmail. + + emblem-important + You may need to create an application-specific password for Gmail. Click this icon for more information. - 1 - 3 + 1 + 5 True - True + True True - + - 1 - 2 + 1 + 4 True - True + True True - + - 1 - 1 + 1 + 1 Enable Push-IMAP True - True - False + True + False 0 - True + True - 0 - 8 + 0 + 10 2 True - False + False - 1 - 0 + 1 + 0 Enable SSL encryption True - True - False + True + False 0 - True + True - 0 - 9 + 0 + 11 2 True - False + False Accountname: 0 - 0 - 1 + 0 + 1 True - False + False Account type: 0 - 0 - 0 + 0 + 0 True - False + False User: 0 - 0 - 2 + 0 + 4 True - False + False Password: 0 - 0 - 3 + 0 + 5 True - False + False Server: 0 - 0 - 4 + 0 + 6 True - False + False Port: 0 - 0 - 5 + 0 + 7 True False - True + True True - False + False True - True - in - 100 + True + in + 100 True - True + True liststore_folders - False - 0 + False + 0 @@ -238,72 +259,234 @@ Click this icon for more information. True - False + False Folders (optional) - 0 - 10 + 0 + 12 2 True - False + False File path: 0 - 0 - 6 + 0 + 8 True - False - False - False - True - False + False + False + False + True + False - 1 - 6 + 1 + 8 True - False + False Directory: 0 - 0 - 7 + 0 + 9 True - False + False select-folder - False - False - True - False + False + False + True + False - 1 - 7 + 1 + 9 + + + + + True + False + Log in: + 0 + + + 0 + 2 + + + + + True + False + Gmail labels: + 0 + 0 + + + 0 + 3 + + + + + True + False + vertical + + + True + False + + + gtk-add + True + True + True + True + True + + + + 0 + + + + + gtk-remove + True + True + True + True + True + + + + 1 + + + + + False + True + 0 + + + + + 100 + True + True + True + in + + + True + True + True + liststore_gmail_labels + True + 0 + False + True + True + + + multiple + + + + + + True + Label + True + True + True + 0 + + + True + + + + 0 + + + + + + + fixed + Mode + True + True + True + 1 + + + True + False + liststore_gmail_label_modes + 0 + + + + 1 + + + + + + + + + False + True + 1 + + + + + 1 + 3 + + + + + Log in to account + True + True + True + + + + 1 + 2