From 44ec78af4c2a4c041df32c65e9d3212179eb0820 Mon Sep 17 00:00:00 2001 From: Simon Robinson Date: Sun, 10 Jul 2022 20:00:30 +0200 Subject: [PATCH 1/4] Very basic support for Python 2.7 --- emailproxy.py | 189 +++++++++++++++++++++++++++++++------------------- 1 file changed, 118 insertions(+), 71 deletions(-) diff --git a/emailproxy.py b/emailproxy.py index 29cc54d..b256cf8 100644 --- a/emailproxy.py +++ b/emailproxy.py @@ -1,6 +1,12 @@ +# coding: utf-8 """A simple IMAP/POP/SMTP proxy that intercepts authenticate and login commands, transparently replacing them with OAuth 2.0 authentication. Designed for apps/clients that don't support OAuth 2.0 but need to connect to modern servers.""" +from __future__ import print_function +from future import standard_library + +standard_library.install_aliases() + __author__ = 'Simon Robinson' __copyright__ = 'Copyright (c) 2022 Simon Robinson' __license__ = 'Apache 2.0' @@ -10,15 +16,12 @@ import asyncore import base64 import binascii -import configparser import datetime -import enum import errno import json import logging import logging.handlers import os -import pathlib import plistlib import queue import re @@ -40,6 +43,30 @@ import timeago import webview + +# TODO: pyoslog does not support Python 2.7; this is a hacky workaround +class pyoslog: + @staticmethod + def is_supported(): + return False + + +sys.modules['pyoslog'] = pyoslog + +# support Python 2 and Python 3; other modules are handled by `future` +try: + import configparser +except ImportError: + import configparser2 as configparser +try: + import enum +except ImportError: + import aenum as enum +try: + import pathlib +except ImportError: + import pathlib2 as pathlib + # for drawing the menu bar icon from io import BytesIO from PIL import Image, ImageDraw, ImageFont @@ -460,7 +487,7 @@ def get_oauth2_authorisation_code(permission_url, redirect_uri, username, connec # (note: not enabled by default because GUI mode is typically unattended, but useful in some cases) if 'local_server_auth' in data: threading.Thread(target=OAuth2Helper.start_redirection_receiver_server, args=(data,), - name='EmailOAuth2Proxy-auth-%s' % data['username'], daemon=True).start() + name='EmailOAuth2Proxy-auth-%s' % data['username']).start() else: if 'response_url' in data and 'code=' in data['response_url']: @@ -643,13 +670,13 @@ def process_data(self, byte_data, censor_server_log=False): def send(self, byte_data): Log.debug(self.info_string(), '<--', byte_data) try: - super().send(byte_data) + asyncore.dispatcher_with_send.send(self, byte_data) except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as e: # only relevant when using local certificates Log.info(self.info_string(), 'Warning: caught client-side SSL send error', '(see https://github.com/simonrob/email-oauth2-proxy/issues/9):', Log.error_string(e)) while True: try: - super().send(byte_data) + asyncore.dispatcher_with_send.send(self, byte_data) break except (ssl.SSLWantReadError, ssl.SSLWantWriteError): time.sleep(1) @@ -669,15 +696,15 @@ def close(self): self.server_connection.close() self.server_connection = None self.proxy_parent.remove_client(self) - super().close() + asyncore.dispatcher_with_send.close(self) class IMAPOAuth2ClientConnection(OAuth2ClientConnection): """The client side of the connection - intercept LOGIN/AUTHENTICATE commands and replace with OAuth 2.0 SASL""" def __init__(self, connection, socket_map, connection_info, server_connection, proxy_parent, custom_configuration): - super().__init__('IMAP', connection, socket_map, connection_info, server_connection, proxy_parent, - custom_configuration) + OAuth2ClientConnection.__init__(self, 'IMAP', connection, socket_map, connection_info, server_connection, + proxy_parent, custom_configuration) self.authentication_tag = None self.authentication_command = None self.awaiting_credentials = False @@ -694,7 +721,7 @@ def process_data(self, byte_data, censor_server_log=False): else: match = IMAP_AUTHENTICATION_REQUEST_MATCHER.match(str_data) if not match: # probably an invalid command, but just let the server handle it - super().process_data(byte_data) + OAuth2ClientConnection.process_data(self, byte_data) return # we replace the standard LOGIN/AUTHENTICATE commands with OAuth 2.0 authentication @@ -709,7 +736,7 @@ def process_data(self, byte_data, censor_server_log=False): self.authenticate_connection(username, password) else: # wrong number of arguments - let the server handle the error - super().process_data(byte_data) + OAuth2ClientConnection.process_data(self, byte_data) elif self.authentication_command == 'authenticate': split_flags = client_flags.split(' ') @@ -725,20 +752,21 @@ def process_data(self, byte_data, censor_server_log=False): self.send(b'+ \r\n') # request credentials (note: space after response code is mandatory) else: # we don't support any other methods - let the server handle this - super().process_data(byte_data) + OAuth2ClientConnection.process_data(self, byte_data) else: # we haven't yet authenticated, but this is some other matched command - pass through - super().process_data(byte_data) + OAuth2ClientConnection.process_data(self, byte_data) def authenticate_connection(self, username, password, command='login'): success, result = OAuth2Helper.get_oauth2_credentials(username, password, self.connection_info) if success: # send authentication command to server (response checked in ServerConnection) # note: we only support single-trip authentication (SASL) without checking server capabilities - improve? - super().process_data(b'%s AUTHENTICATE XOAUTH2 ' % self.authentication_tag.encode('utf-8')) - super().process_data(OAuth2Helper.encode_oauth2_string(result), censor_server_log=True) - super().process_data(b'\r\n') + OAuth2ClientConnection.process_data(self, + b'%s AUTHENTICATE XOAUTH2 ' % self.authentication_tag.encode('utf-8')) + OAuth2ClientConnection.process_data(self, OAuth2Helper.encode_oauth2_string(result), censor_server_log=True) + OAuth2ClientConnection.process_data(self, b'\r\n') self.server_connection.authenticated_username = username else: @@ -760,8 +788,8 @@ class STATE(enum.Enum): XOAUTH2_CREDENTIALS_SENT = 6 def __init__(self, connection, socket_map, connection_info, server_connection, proxy_parent, custom_configuration): - super().__init__('POP', connection, socket_map, connection_info, server_connection, proxy_parent, - custom_configuration) + OAuth2ClientConnection.__init__(self, 'POP', connection, socket_map, connection_info, server_connection, + proxy_parent, custom_configuration) self.connection_state = self.STATE.PENDING def process_data(self, byte_data, censor_server_log=False): @@ -772,7 +800,7 @@ def process_data(self, byte_data, censor_server_log=False): if str_data_lower == 'capa': self.server_connection.capa = [] self.connection_state = self.STATE.CAPA_AWAITING_RESPONSE - super().process_data(byte_data) + OAuth2ClientConnection.process_data(self, byte_data) elif str_data_lower == 'auth': # a bare 'auth' command is another way to request capabilities self.send(b'+OK\r\nPLAIN\r\n.\r\n') # no need to actually send to the server - we know what we support @@ -793,7 +821,7 @@ def process_data(self, byte_data, censor_server_log=False): self.send(b'+OK\r\n') # request password else: - super().process_data(byte_data) # some other command that we don't handle - pass directly to server + OAuth2ClientConnection.process_data(self, byte_data) # some other command that we don't handle elif self.connection_state is self.STATE.AUTH_PLAIN_AWAITING_CREDENTIALS: if str_data == '*': # request cancelled by the client - reset state (must be a negative response) @@ -815,11 +843,11 @@ def process_data(self, byte_data, censor_server_log=False): self.close() else: - super().process_data(byte_data) # some other command that we don't handle - pass directly to server + OAuth2ClientConnection.process_data(self, byte_data) # some other command that we don't handle def send_authentication_request(self): self.connection_state = self.STATE.XOAUTH2_AWAITING_CONFIRMATION - super().process_data(b'AUTH XOAUTH2\r\n') + OAuth2ClientConnection.process_data(self, b'AUTH XOAUTH2\r\n') class SMTPOAuth2ClientConnection(OAuth2ClientConnection): @@ -835,8 +863,8 @@ class STATE(enum.Enum): XOAUTH2_CREDENTIALS_SENT = 7 def __init__(self, connection, socket_map, connection_info, server_connection, proxy_parent, custom_configuration): - super().__init__('SMTP', connection, socket_map, connection_info, server_connection, proxy_parent, - custom_configuration) + OAuth2ClientConnection.__init__(self, 'SMTP', connection, socket_map, connection_info, server_connection, + proxy_parent, custom_configuration) self.connection_state = self.STATE.PENDING def process_data(self, byte_data, censor_server_log=False): @@ -848,7 +876,7 @@ def process_data(self, byte_data, censor_server_log=False): if str_data_lower.startswith('ehlo') or str_data_lower.startswith('helo'): self.connection_state = self.STATE.EHLO_AWAITING_RESPONSE self.server_connection.ehlo = byte_data # save the command so we can replay later if needed (STARTTLS) - super().process_data(byte_data) # don't just go to STARTTLS - most servers require EHLO first + OAuth2ClientConnection.process_data(self, byte_data) # don't just go to STARTTLS - EHLO first # intercept AUTH PLAIN and AUTH LOGIN to replace with AUTH XOAUTH2 elif str_data_lower.startswith('auth plain'): @@ -869,7 +897,7 @@ def process_data(self, byte_data, censor_server_log=False): self.send(b'334 %s\r\n' % base64.b64encode(b'Username:')) else: - super().process_data(byte_data) # some other command that we don't handle - pass directly to server + OAuth2ClientConnection.process_data(self, byte_data) # some other command that we don't handle elif self.connection_state is self.STATE.AUTH_PLAIN_AWAITING_CREDENTIALS: self.server_connection.username, self.server_connection.password = OAuth2Helper.decode_credentials( @@ -888,7 +916,7 @@ def process_data(self, byte_data, censor_server_log=False): # some other command that we don't handle - pass directly to server else: - super().process_data(byte_data) + OAuth2ClientConnection.process_data(self, byte_data) def decode_username_and_request_password(self, encoded_username): try: @@ -901,7 +929,7 @@ def decode_username_and_request_password(self, encoded_username): def send_authentication_request(self): self.connection_state = self.STATE.XOAUTH2_AWAITING_CONFIRMATION - super().process_data(b'AUTH XOAUTH2\r\n') + OAuth2ClientConnection.process_data(self, b'AUTH XOAUTH2\r\n') class OAuth2ServerConnection(asyncore.dispatcher_with_send): @@ -992,7 +1020,7 @@ def process_data(self, byte_data): def send(self, byte_data, censor_log=False): if not self.client_connection.authenticated: # after authentication these are identical to server-side logs Log.debug(self.info_string(), ' -->', CENSOR_MESSAGE if censor_log else byte_data) - super().send(byte_data) + asyncore.dispatcher_with_send.send(self, byte_data) def handle_error(self): error_type, value, _traceback = sys.exc_info() @@ -1005,7 +1033,7 @@ def handle_error(self): 'Error type', error_type, 'with message:', value) self.handle_close() else: - super().handle_error() + asyncore.dispatcher_with_send.handle_error(self) def log_info(self, message, message_type='info'): # override to redirect error messages to our own log @@ -1027,7 +1055,8 @@ class IMAPOAuth2ServerConnection(OAuth2ServerConnection): # IMAP: https://tools.ietf.org/html/rfc3501 # IMAP SASL-IR: https://tools.ietf.org/html/rfc4959 def __init__(self, socket_map, server_address, connection_info, proxy_parent, custom_configuration): - super().__init__('IMAP', socket_map, server_address, connection_info, proxy_parent, custom_configuration) + OAuth2ServerConnection.__init__(self, 'IMAP', socket_map, server_address, connection_info, proxy_parent, + custom_configuration) def process_data(self, byte_data): # note: there is no reason why IMAP STARTTLS (https://tools.ietf.org/html/rfc2595) couldn't be supported here @@ -1054,7 +1083,7 @@ def process_data(self, byte_data): updated_response = re.sub(r' LOGINDISABLED', '', updated_response, count=1, flags=re.IGNORECASE) byte_data = (b'%s\r\n' % updated_response.encode('utf-8')) - super().process_data(byte_data) + OAuth2ServerConnection.process_data(self, byte_data) class POPOAuth2ServerConnection(OAuth2ServerConnection): @@ -1065,7 +1094,8 @@ class POPOAuth2ServerConnection(OAuth2ServerConnection): # POP3 AUTH: https://tools.ietf.org/html/rfc1734 # POP3 SASL: https://tools.ietf.org/html/rfc5034 def __init__(self, socket_map, server_address, connection_info, proxy_parent, custom_configuration): - super().__init__('POP', socket_map, server_address, connection_info, proxy_parent, custom_configuration) + OAuth2ServerConnection.__init__(self, 'POP', socket_map, server_address, connection_info, proxy_parent, + custom_configuration) self.capa = [] self.username = None self.password = None @@ -1079,7 +1109,7 @@ def process_data(self, byte_data): if self.client_connection.connection_state is POPOAuth2ClientConnection.STATE.CAPA_AWAITING_RESPONSE: if str_data.startswith('-'): # error self.client_connection.connection_state = POPOAuth2ClientConnection.STATE.PENDING - super().process_data(byte_data) + OAuth2ServerConnection.process_data(self, byte_data) elif str_data == '.': # end - send our cached response, adding USER and SASL PLAIN if required has_sasl = False @@ -1087,20 +1117,20 @@ def process_data(self, byte_data): for capa in self.capa: capa_lower = capa.lower() if capa_lower.startswith('sasl'): - super().process_data(b'SASL PLAIN\r\n') + OAuth2ServerConnection.process_data(self, b'SASL PLAIN\r\n') has_sasl = True else: if capa_lower == 'user': has_user = True - super().process_data(b'%s\r\n' % capa.encode('utf-8')) + OAuth2ServerConnection.process_data(self, b'%s\r\n' % capa.encode('utf-8')) if not has_sasl: - super().process_data(b'SASL PLAIN\r\n') + OAuth2ServerConnection.process_data(self, b'SASL PLAIN\r\n') if not has_user: - super().process_data(b'USER\r\n') + OAuth2ServerConnection.process_data(self, b'USER\r\n') self.client_connection.connection_state = POPOAuth2ClientConnection.STATE.PENDING - super().process_data(byte_data) + OAuth2ServerConnection.process_data(self, byte_data) else: self.capa.append(str_data) @@ -1118,24 +1148,25 @@ def process_data(self, byte_data): self.password = None if not success: # a local authentication error occurred - send details to the client and exit - super().process_data(b'-ERR Authentication failed. %s\r\n' % result.encode('utf-8')) + OAuth2ServerConnection.process_data(self, b'-ERR Authentication failed. ' + + b'%s\r\n' % result.encode('utf-8')) self.client_connection.close() else: - super().process_data(byte_data) # an error occurred - just send to the client and exit + OAuth2ServerConnection.process_data(self, byte_data) # an error occurred - just send to client and exit self.client_connection.close() elif self.client_connection.connection_state is POPOAuth2ClientConnection.STATE.XOAUTH2_CREDENTIALS_SENT: if str_data.startswith('+OK'): Log.info(self.info_string(), '[ Successfully authenticated POP connection - removing proxy ]') self.client_connection.authenticated = True - super().process_data(byte_data) + OAuth2ServerConnection.process_data(self, byte_data) else: - super().process_data(byte_data) # an error occurred - just send to the client and exit + OAuth2ServerConnection.process_data(self, byte_data) # an error occurred - just send to client and exit self.client_connection.close() else: - super().process_data(byte_data) # a server->client interaction we don't handle; ignore + OAuth2ServerConnection.process_data(self, byte_data) # a server->client interaction we don't handle; ignore class SMTPOAuth2ServerConnection(OAuth2ServerConnection): @@ -1151,7 +1182,8 @@ class STARTTLS(enum.Enum): COMPLETE = 3 def __init__(self, socket_map, server_address, connection_info, proxy_parent, custom_configuration): - super().__init__('SMTP', socket_map, server_address, connection_info, proxy_parent, custom_configuration) + OAuth2ServerConnection.__init__(self, 'SMTP', socket_map, server_address, connection_info, proxy_parent, + custom_configuration) self.ehlo = None if self.custom_configuration['starttls']: self.starttls_state = self.STARTTLS.PENDING @@ -1174,7 +1206,7 @@ def process_data(self, byte_data): flags=re.IGNORECASE) updated_response = b'%s\r\n' % updated_response.encode('utf-8') if self.starttls_state is self.STARTTLS.COMPLETE: - super().process_data(updated_response) # (we replay the EHLO command after STARTTLS for that situation) + OAuth2ServerConnection.process_data(self, updated_response) # (we replay EHLO after STARTTLS) if str_data.startswith('250 '): # space signifies final response to HELO (single line) or EHLO (multiline) self.client_connection.connection_state = SMTPOAuth2ClientConnection.STATE.PENDING @@ -1185,14 +1217,15 @@ def process_data(self, byte_data): elif self.starttls_state is self.STARTTLS.NEGOTIATING: if str_data.startswith('220'): ssl_context = ssl.create_default_context() - super().set_socket(ssl_context.wrap_socket(self.socket, server_hostname=self.server_address[0])) + OAuth2ServerConnection.set_socket(self, ssl_context.wrap_socket(self.socket, + server_hostname=self.server_address[0])) self.starttls_state = self.STARTTLS.COMPLETE Log.debug(self.info_string(), '[ Successfully negotiated SMTP STARTTLS connection -', 're-sending greeting ]') self.client_connection.connection_state = SMTPOAuth2ClientConnection.STATE.EHLO_AWAITING_RESPONSE self.send(self.ehlo) # re-send original EHLO/HELO to server (includes domain, so can't just be generic) else: - super().process_data(byte_data) # an error occurred - just send to the client and exit + OAuth2ServerConnection.process_data(self, byte_data) # an error occurred - just send to the client self.client_connection.close() # ...then, once we have the username and password we can respond to the '334 ' response with credentials @@ -1210,25 +1243,25 @@ def process_data(self, byte_data): self.password = None if not success: # a local authentication error occurred - send details to the client and exit - super().process_data( - b'535 5.7.8 Authentication credentials invalid. %s\r\n' % result.encode('utf-8')) + OAuth2ServerConnection.process_data(self, b'535 5.7.8 Authentication credentials invalid. ' + + b'%s\r\n' % result.encode('utf-8')) self.client_connection.close() else: - super().process_data(byte_data) # an error occurred - just send to the client and exit + OAuth2ServerConnection.process_data(self, byte_data) # an error occurred - just send to the client self.client_connection.close() elif self.client_connection.connection_state is SMTPOAuth2ClientConnection.STATE.XOAUTH2_CREDENTIALS_SENT: if str_data.startswith('235'): Log.info(self.info_string(), '[ Successfully authenticated SMTP connection - removing proxy ]') self.client_connection.authenticated = True - super().process_data(byte_data) + OAuth2ServerConnection.process_data(self, byte_data) else: - super().process_data(byte_data) # an error occurred - just send to the client and exit + OAuth2ServerConnection.process_data(self, byte_data) # an error occurred - just send to the client self.client_connection.close() else: - super().process_data(byte_data) # a server->client interaction we don't handle; ignore + OAuth2ServerConnection.process_data(self, byte_data) # a server->client interaction we don't handle; ignore class OAuth2Proxy(asyncore.dispatcher): @@ -1249,6 +1282,11 @@ def info_string(self): self.server_address[0], self.server_address[1], 'STARTTLS' if self.custom_configuration['starttls'] else 'SSL/TLS') + def handle_accept(self): + connected_address = self.accept() + if connected_address is not None: + self.handle_accepted(*connected_address) + def handle_accepted(self, connection, address): if MAX_CONNECTIONS <= 0 or len(self.client_connections) < MAX_CONNECTIONS: new_server_connection = None @@ -1265,7 +1303,7 @@ def handle_accepted(self, connection, address): self.client_connections.append(new_client_connection) threading.Thread(target=self.run_server, args=(new_client_connection, socket_map, address), - name='EmailOAuth2Proxy-connection-%d' % address[1], daemon=True).start() + name='EmailOAuth2Proxy-connection-%d' % address[1]).start() except ssl.SSLError: error_text = '%s encountered an SSL error - is the server\'s starttls setting correct? Current ' \ @@ -1321,7 +1359,7 @@ def create_socket(self, socket_family=socket.AF_INET, socket_type=socket.SOCK_ST keyfile=self.custom_configuration['local_key_path']) self.set_socket(ssl_context.wrap_socket(new_socket, server_side=True)) else: - super().create_socket(socket_family, socket_type) + asyncore.dispatcher.create_socket(self, socket_family, socket_type) def remove_client(self, client): if client in self.client_connections: # remove closed clients @@ -1373,7 +1411,7 @@ def handle_error(self): 'local certificate you may need to disable SSL verification (and/or add an exception) in your', 'client for the local host and port') else: - super().handle_error() + asyncore.dispatcher.handle_error(self) def log_info(self, message, message_type='info'): # override to redirect error messages to our own log @@ -1416,7 +1454,7 @@ class RetinaIcon(pystray.Icon): def _create_menu(self, descriptors, callbacks): # we add a new delegate to each created menu/submenu so that we can respond to menuNeedsUpdate - menu = super()._create_menu(descriptors, callbacks) + menu = pystray.Icon._create_menu(self, descriptors, callbacks) menu.setDelegate_(self._refresh_delegate) return menu @@ -1424,7 +1462,7 @@ def _mark_ready(self): # in order to create the delegate *after* the NSApplication has been initialised, but only once, we override # _mark_ready() to do so before the super() call that itself calls _create_menu() self._refresh_delegate = self.MenuDelegate.alloc().init() - super()._mark_ready() + pystray.Icon._mark_ready(self) # noinspection PyUnresolvedReferences class MenuDelegate(AppKit.NSObject): @@ -1593,7 +1631,7 @@ def get_image(): maximum_font_size = 255 font, font_width, font_height = App.get_icon_size(icon_font_file, icon_character, minimum_font_size) while maximum_font_size - minimum_font_size > 1: - current_font_size = round((minimum_font_size + maximum_font_size) / 2) # ImageFont only supports integers + current_font_size = int(round((minimum_font_size + maximum_font_size) / 2)) # ImageFont needs integers font, font_width, font_height = App.get_icon_size(icon_font_file, icon_character, current_font_size) if font_width > icon_width: maximum_font_size = current_font_size @@ -1624,8 +1662,8 @@ def create_config_menu(self): items.append(pystray.MenuItem(' No servers configured', None, enabled=False)) else: for proxy in self.proxies: - items.append(pystray.MenuItem(' %s:%d ➝ %s:%d' % (proxy.local_address[0], proxy.local_address[1], - proxy.server_address[0], proxy.server_address[1]), + items.append(pystray.MenuItem(' %s:%d %s:%d' % (proxy.local_address[0], proxy.local_address[1], + proxy.server_address[0], proxy.server_address[1]), None, enabled=False)) items.append(pystray.Menu.SEPARATOR) @@ -1808,16 +1846,22 @@ def toggle_start_at_login(self, icon, force_rewrite=False): } else: # just toggle the disabled value rather than loading/unloading, so we don't need to restart the proxy - with open(PLIST_FILE_PATH, 'rb') as plist_file: - plist = plistlib.load(plist_file) + with open(str(PLIST_FILE_PATH), 'rb') as plist_file: + try: + plist = plistlib.load(plist_file) + except AttributeError: + plist = plistlib.readPlist(plist_file) plist['Disabled'] = True if 'Disabled' not in plist else not plist['Disabled'] plist['Program'] = start_command[0] plist['ProgramArguments'] = start_command os.makedirs(PLIST_FILE_PATH.parent, exist_ok=True) - with open(PLIST_FILE_PATH, 'wb') as plist_file: - plistlib.dump(plist, plist_file) + with open(str(PLIST_FILE_PATH), 'wb') as plist_file: + try: + plistlib.dump(plist, plist_file) + except AttributeError: + plistlib.writePlist(plist, plist_file) # if loading, need to exit so we're not running twice (also exits the terminal instance for convenience) if not self.macos_launchctl('list'): @@ -1837,7 +1881,7 @@ def toggle_start_at_login(self, icon, force_rewrite=False): windows_start_command = 'start %s' % ' '.join(start_command) os.makedirs(CMD_FILE_PATH.parent, exist_ok=True) - with open(CMD_FILE_PATH, 'w') as cmd_file: + with open(str(CMD_FILE_PATH), 'w') as cmd_file: cmd_file.write(windows_start_command) # on Windows we don't have a service to run, but it is still useful to exit the terminal instance @@ -1857,7 +1901,7 @@ def toggle_start_at_login(self, icon, force_rewrite=False): } os.makedirs(AUTOSTART_FILE_PATH.parent, exist_ok=True) - with open(AUTOSTART_FILE_PATH, 'w') as desktop_file: + with open(str(AUTOSTART_FILE_PATH), 'w') as desktop_file: desktop_file.write('[Desktop Entry]\n') for key, value in xdg_autostart.items(): desktop_file.write('%s=%s\n' % (key, value)) @@ -1918,8 +1962,11 @@ def started_at_login(_): if sys.platform == 'darwin': if PLIST_FILE_PATH.exists(): if App.macos_launchctl('list'): - with open(PLIST_FILE_PATH, 'rb') as plist_file: - plist = plistlib.load(plist_file) + with open(str(PLIST_FILE_PATH), 'rb') as plist_file: + try: + plist = plistlib.load(plist_file) + except AttributeError: + plist = plistlib.readPlist(plist_file) if 'Disabled' in plist: return not plist['Disabled'] return True # job is loaded and is not disabled @@ -2037,7 +2084,7 @@ def load_and_start_servers(self, icon=None): if icon: icon.update_menu() # force refresh the menu to show running proxy servers - threading.Thread(target=self.run_proxy, name='EmailOAuth2Proxy-main', daemon=True).start() + threading.Thread(target=self.run_proxy, name='EmailOAuth2Proxy-main').start() return True def post_create(self, icon): From 216ad28038bd5eaca176bf78afeb59af010eef7f Mon Sep 17 00:00:00 2001 From: Simon Robinson Date: Wed, 20 Jul 2022 21:57:54 +0100 Subject: [PATCH 2/4] Fix webview launching; add Python 2 requirements --- emailproxy.py | 5 ++++- requirements.txt | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/emailproxy.py b/emailproxy.py index b256cf8..a70300f 100644 --- a/emailproxy.py +++ b/emailproxy.py @@ -1759,7 +1759,10 @@ def create_authorisation_window(self, request): request['permission_url'], APP_NAME, request['redirect_uri']) authorisation_window = webview.create_window(window_title, html=auth_page, on_top=True, text_select=True) else: - authorisation_window = webview.create_window(window_title, request['permission_url'], on_top=True) + if sys.version_info >= (3, 0): + authorisation_window = webview.create_window(window_title, request['permission_url'], on_top=False) + else: + authorisation_window = webview.create_window(window_title, request['permission_url']) setattr(authorisation_window, 'get_title', lambda window: window.title) # add missing get_title method # pywebview 3.6+ moved window events to a separate namespace in a non-backwards-compatible way diff --git a/requirements.txt b/requirements.txt index b645aef..5068f03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,25 @@ configobj cryptography pillow -pystray -pywebview; sys_platform != 'win32' # specify platform to avoid double requirement error with older pip versions +pystray; python_version >= '3.0' # specify to avoid double requirement error with older pip versions (same below) +pywebview; sys_platform != 'win32' and python_version >= '3.0' timeago +# modules needed for Python 2 support +aenum; python_version < '3.0' +configparser2; python_version < '3.0' +future; python_version < '3.0' +pathlib2; python_version < '3.0' +pystray==0.17.2; python_version < '3.0' +pywebview==2.4; python_version < '3.0' + # provide the previously standard library module `asyncore`, removed in Python 3.12 (https://peps.python.org/pep-0594/) pyasyncore; python_version >= '3.12' # used to improve menu bar interaction, provide native notifications, handle system events and output to unified logging pyobjc-framework-Cocoa; sys_platform == 'darwin' pyobjc-framework-SystemConfiguration; sys_platform == 'darwin' -pyoslog>=0.3.0; sys_platform == 'darwin' +pyoslog>=0.4.0; sys_platform == 'darwin' # force pywebview 3.5+ on Windows to fix authentication window crash bug (https://github.com/r0x0r/pywebview/issues/720) -pywebview>=3.5; sys_platform == 'win32' +pywebview>=3.5; sys_platform == 'win32' and python_version >= '3.0' From 8b3323f7fefef296e14b3dbe2bee5810fea418c4 Mon Sep 17 00:00:00 2001 From: Simon Robinson Date: Tue, 2 Aug 2022 21:02:46 +0100 Subject: [PATCH 3/4] Add support for Python 2 on very old Windows versions --- emailproxy.py | 18 +++++++----------- requirements.txt | 9 ++++++--- 2 files changed, 13 insertions(+), 14 deletions(-) mode change 100644 => 100755 emailproxy.py mode change 100644 => 100755 requirements.txt diff --git a/emailproxy.py b/emailproxy.py old mode 100644 new mode 100755 index a70300f..49ef241 --- a/emailproxy.py +++ b/emailproxy.py @@ -43,17 +43,13 @@ import timeago import webview - -# TODO: pyoslog does not support Python 2.7; this is a hacky workaround -class pyoslog: - @staticmethod - def is_supported(): - return False - - -sys.modules['pyoslog'] = pyoslog - # support Python 2 and Python 3; other modules are handled by `future` +if sys.platform == 'win32' and sys.version_info < (3, 0): + # note: this change fixes one issue with pystray, but a manual code edit is also required: + # line 349 of pystray/_util/win32.py needs editing from `except KeyError:` to + # `except (KeyError, AttributeError):` (see: https://github.com/moses-palmer/pystray/pull/128) + # noinspection PyUnresolvedReferences + del pystray._util.win32.LoadImage.errcheck # means we don't get an actual icon, but also no crash try: import configparser except ImportError: @@ -413,7 +409,7 @@ def start_redirection_receiver_server(token_request): class LoggingWSGIRequestHandler(wsgiref.simple_server.WSGIRequestHandler): def log_message(self, format_string, *args): Log.debug('Local server auth mode (%s:%d): received authentication response' % ( - parsed_uri.hostname, parsed_uri.port), *args) + parsed_uri.hostname, parsed_port), *args) class RedirectionReceiverWSGIApplication: def __call__(self, environ, start_response): diff --git a/requirements.txt b/requirements.txt old mode 100644 new mode 100755 index 5068f03..3f62c9e --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ configobj -cryptography -pillow +cryptography; sys_platform != 'win32' +pillow; python_version >= '3.0' pystray; python_version >= '3.0' # specify to avoid double requirement error with older pip versions (same below) pywebview; sys_platform != 'win32' and python_version >= '3.0' timeago @@ -10,8 +10,11 @@ aenum; python_version < '3.0' configparser2; python_version < '3.0' future; python_version < '3.0' pathlib2; python_version < '3.0' -pystray==0.17.2; python_version < '3.0' +pillow==5.3.0; python_version < '3.0' +pystray; sys_platform != 'darwin' and python_version < '3.0' # specific version needed for macOS +pystray==0.17.2; sys_platform == 'darwin' and python_version < '3.0' # specific version needed for macOS pywebview==2.4; python_version < '3.0' +cryptography==2.4.2; sys_platform == 'win32' and python_version < '3.0' # specific version required for very old Windows versions # provide the previously standard library module `asyncore`, removed in Python 3.12 (https://peps.python.org/pep-0594/) pyasyncore; python_version >= '3.12' From 712261203475955577a628995ae16b612bb6858f Mon Sep 17 00:00:00 2001 From: Simon Robinson Date: Wed, 3 Aug 2022 12:27:31 +0100 Subject: [PATCH 4/4] Update requirements to cover all platform/Python versions --- requirements.txt | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3f62c9e..34bdb52 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,11 @@ +# newer pip versions handle choosing more specific requirements over broader ones; older versions don't – always specify configobj -cryptography; sys_platform != 'win32' -pillow; python_version >= '3.0' -pystray; python_version >= '3.0' # specify to avoid double requirement error with older pip versions (same below) +cryptography; sys_platform != 'win32' or python_version >= '3.0' +pillow; sys_platform != 'win32' or python_version >= '3.0' +pystray>=0.19.4; sys_platform != 'darwin' or python_version >= '3.0' # force version with dummy GUI fix (pystray #118) pywebview; sys_platform != 'win32' and python_version >= '3.0' timeago -# modules needed for Python 2 support -aenum; python_version < '3.0' -configparser2; python_version < '3.0' -future; python_version < '3.0' -pathlib2; python_version < '3.0' -pillow==5.3.0; python_version < '3.0' -pystray; sys_platform != 'darwin' and python_version < '3.0' # specific version needed for macOS -pystray==0.17.2; sys_platform == 'darwin' and python_version < '3.0' # specific version needed for macOS -pywebview==2.4; python_version < '3.0' -cryptography==2.4.2; sys_platform == 'win32' and python_version < '3.0' # specific version required for very old Windows versions - # provide the previously standard library module `asyncore`, removed in Python 3.12 (https://peps.python.org/pep-0594/) pyasyncore; python_version >= '3.12' @@ -26,3 +16,13 @@ pyoslog>=0.4.0; sys_platform == 'darwin' # force pywebview 3.5+ on Windows to fix authentication window crash bug (https://github.com/r0x0r/pywebview/issues/720) pywebview>=3.5; sys_platform == 'win32' and python_version >= '3.0' + +# modules needed for (partial) Python 2 support +aenum; python_version < '3.0' +configparser2; python_version < '3.0' +cryptography==2.4.2; sys_platform == 'win32' and python_version < '3.0' # support very old Windows versions +future; python_version < '3.0' +pathlib2; python_version < '3.0' +pillow==5.3.0; sys_platform == 'win32' and python_version < '3.0' +pystray==0.17.2; sys_platform == 'darwin' and python_version < '3.0' # work around incompatible macOS Quartz dependency +pywebview==2.4; python_version < '3.0'