#!/usr/bin/env python3
"""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."""
__author__ = 'Simon Robinson'
__copyright__ = 'Copyright (c) 2025 Simon Robinson'
__license__ = 'Apache 2.0'
__package_version__ = '2025.10.14' # for pyproject.toml usage only - needs to be ast.literal_eval() compatible
__version__ = '-'.join('%02d' % int(part) for part in __package_version__.split('.')) # ISO 8601 (YYYY-MM-DD)
import abc
import argparse
import base64
import binascii
import configparser
import contextlib
import datetime
import enum
import errno
import hashlib
import io
import ipaddress
import json
import logging
import logging.handlers
import os
import pathlib
import platform
import plistlib
import queue
import re
import secrets
import select
import signal
import socket
import ssl
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import warnings
import wsgiref.simple_server
import wsgiref.util
import zlib
# asyncore is essential, but has been deprecated and will be removed in python 3.12 (see PEP 594)
# pyasyncore is our workaround, so suppress this warning until the proxy is rewritten in, e.g., asyncio
with warnings.catch_warnings():
warnings.simplefilter('ignore', DeprecationWarning)
import asyncore
# for encrypting/decrypting the locally-stored credentials
from cryptography.fernet import Fernet, MultiFernet, InvalidToken
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# by default the proxy is a GUI application with a menu bar/taskbar icon, but it is also useful in 'headless' contexts
# where not having to install GUI-only requirements can be helpful - see the proxy's readme (the `--no-gui` option)
MISSING_GUI_REQUIREMENTS = []
no_gui_parser = argparse.ArgumentParser(add_help=False)
no_gui_parser.add_argument('--no-gui', action='store_false', dest='gui')
no_gui_args = no_gui_parser.parse_known_args()[0]
if no_gui_args.gui:
try:
# noinspection PyUnresolvedReferences
import pystray # the menu bar/taskbar GUI
except Exception as gui_requirement_import_error: # see #204 - incomplete pystray installation can throw exceptions
MISSING_GUI_REQUIREMENTS.append(gui_requirement_import_error)
no_gui_args.gui = False # we need the dummy implementation
if not no_gui_args.gui:
class DummyPystray: # dummy implementation allows initialisation to complete (with skeleton to avoid lint warnings)
class Icon:
def __init__(self, *args, **kwargs):
pass
def run(self, *args, **kwargs):
pass
class Menu:
SEPARATOR = None
def __init__(self, *args, **kwargs):
pass
class MenuItem:
def __init__(self, *args, **kwargs):
pass
pystray = DummyPystray # this is just to avoid unignorable IntelliJ warnings about naming and spacing
del no_gui_parser
del no_gui_args
try:
# noinspection PyUnresolvedReferences
from PIL import Image, ImageDraw, ImageFont # draw the menu bar icon from the TTF font stored in APP_ICON
except ImportError as gui_requirement_import_error:
MISSING_GUI_REQUIREMENTS.append(gui_requirement_import_error)
try:
# noinspection PyUnresolvedReferences
import timeago # the last authenticated activity hint
except ImportError as gui_requirement_import_error:
MISSING_GUI_REQUIREMENTS.append(gui_requirement_import_error)
try:
# noinspection PyUnresolvedReferences
import webview # the popup authentication window (in default and GUI `--external-auth` modes only)
except ImportError as gui_requirement_import_error:
MISSING_GUI_REQUIREMENTS.append(gui_requirement_import_error)
try:
# pylint: disable-next=ungrouped-imports
import importlib.metadata as importlib_metadata # get package version numbers - available in stdlib from python 3.8
except ImportError:
try:
# noinspection PyUnresolvedReferences
import importlib_metadata
except ImportError as gui_requirement_import_error:
MISSING_GUI_REQUIREMENTS.append(gui_requirement_import_error)
try:
# noinspection PyUnresolvedReferences
import packaging.version # parse package version numbers - used to work around various GUI-only package issues
except ImportError as gui_requirement_import_error:
MISSING_GUI_REQUIREMENTS.append(gui_requirement_import_error)
# for macOS-specific functionality
if sys.platform == 'darwin':
try:
# PyUnresolvedReferences; see: youtrack.jetbrains.com/issue/PY-11963 (same for others with this suppression)
# noinspection PyPackageRequirements,PyUnresolvedReferences
import PyObjCTools.MachSignals # SIGTERM handling (only needed in GUI mode; `signal` is sufficient otherwise)
except ImportError as gui_requirement_import_error:
MISSING_GUI_REQUIREMENTS.append(gui_requirement_import_error)
try:
# noinspection PyPackageRequirements,PyUnresolvedReferences
import SystemConfiguration # network availability monitoring
except ImportError as gui_requirement_import_error:
MISSING_GUI_REQUIREMENTS.append(gui_requirement_import_error)
try:
# noinspection PyPackageRequirements
import AppKit # retina icon, menu update on click, native notifications and receiving system events
except ImportError as gui_requirement_import_error:
MISSING_GUI_REQUIREMENTS.append(gui_requirement_import_error)
class AppKit: # dummy implementation allows initialisation to complete
class NSObject:
pass
APP_NAME = 'Email OAuth 2.0 Proxy'
APP_SHORT_NAME = 'emailproxy'
APP_PACKAGE = 'ac.robinson.email-oauth2-proxy'
# noinspection SpellCheckingInspection
APP_ICON = b'''eNp1Uc9rE0EUfjM7u1nyq0m72aQxpnbTbFq0TbJNNkGkNpVKb2mxtgjWsqRJU+jaQHOoeMlVeoiCHqQXrwX/gEK9efGgNy+C4MWbHjxER
DCJb3dTUdQH733zvW/ezHszQADAAy3gIFO+kdbW3lXWAUgRs2sV02igdoL8MfLctrHf6PeBAXBe5OL27r2acry6hPprdLleNbbiXfkUtRfoeh0T4gaju
O6gT9TN5gEWo5GHGNjuXsVAPET+yuKmcdAAETaRR5BfuGuYVRCs/fQjBqGxt98En80/WzpYvaN3tPsvN4eufAWPc/r707dvLPyg/PiCcMSAq1n9AgXHs
MbeedvZz+zMH0YGZ99x7v9LxwyzpuBBpA8oTg9tB8kn0IiIHQLPwT9tuba4BfNQhervPZzdMGBWp1a9hJHYyHBeS2Y2r+I/2LF/9Ku3Q7tXZ9ogJKEEN
+EWbODRqpoaFwRXUJbDvK4Xghlek+WQ5KfKDM3N0dlshiQEQVHzuYJeKMxRVMNhWRISClYmc6qaUPxUitNZTdfz2QyfcmXIOK8xoOZKt7ViUkRqYXekW
J6Sp0urC5fCken5STr0KDoUlyhjVd4nxSUvq3tCftEn8r2ro+mxUDIaCMQmQrGZGHmi53tAT3rPGH1e3qF0p9w7LtcohwuyvnRxWZ8sZUej6WvlhXSk1
7k+POJ1iR73N/+w2xN0f4+GJcHtfqoWzgfi6cuZscC54lSq3SbN1tmzC4MXtcwN/zOC78r9BIfNc3M=''' # TTF ('e') -> zlib -> base64
CENSOR_CREDENTIALS = True
CENSOR_MESSAGE = b'[[ Credentials removed from proxy log ]]' # replaces actual credentials; must be a byte-type string
FROZEN_PACKAGE = getattr(sys, 'frozen', False) or '__compiled__' in globals() # for pyinstaller/nuitka etc
script_path = sys.executable if FROZEN_PACKAGE else os.path.realpath(__file__)
if sys.platform == 'darwin' and '.app/Contents/MacOS/' in script_path: # pyinstaller .app binary is within the bundle
if float('.'.join(platform.mac_ver()[0].split('.')[:2])) >= 10.12: # need a known path (due to App Translocation)
script_path = pathlib.Path('~/.%s/%s' % (APP_SHORT_NAME, APP_SHORT_NAME)).expanduser()
else:
script_path = '.'.join(script_path.split('Contents/MacOS/')[0].split('/')[:-1])
script_path = os.getcwd() if __package__ is not None else os.path.dirname(script_path) # for packaged version (PyPI)
CONFIG_FILE_PATH = CACHE_STORE = os.path.join(script_path, '%s.config' % APP_SHORT_NAME)
CONFIG_SERVER_MATCHER = re.compile(r'^(?P Click the following link to open your browser and approve the request: After logging in and successfully
authorising your account, paste and submit the resulting URL from the browser's address bar using the box at the
bottom of this page to allow the %s script to transparently handle login requests on your behalf in future. Note that your browser may show a navigation error (e.g., “localhost refused to connect”) after
successfully logging in, but the final URL is the only important part, and as long as this begins with the
correct redirection URI and contains a valid authorisation code your email client's request will succeed.''' + (
' If you are using Windows, submitting can take a few seconds.' if sys.platform == 'win32' else '') + ''' According to your proxy configuration file, the expected URL will be of the form:Login authorisation request for %s
%s […] code=[code] […]
Enter the following code when prompted:
%s⧉
You can close this window once authorisation is complete.