Skip to content

Commit 25565be

Browse files
authored
Add support for catch-all account sections (#84)
1 parent d2a9a0d commit 25565be

File tree

2 files changed

+58
-14
lines changed

2 files changed

+58
-14
lines changed

emailproxy.config

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,22 @@ documentation = The parameters below control advanced options for the proxy. In
175175
access), the `client_secret` value falls into this category - it is not actually secret, and there is no real need
176176
to prevent access to it. However, when using the Office 365 client credentials grant flow there is no user involved,
177177
and possession of the secret grants full access to an account. If you use this method and it is possible that others
178-
may gain access to the proxy's configuration file, set `encrypt_client_secret_on_first_use` and the proxy will
179-
replace the `client_secret` value with a new property `client_secret_encrypted` at the next token refresh.
178+
may gain access to the proxy's configuration file, set `encrypt_client_secret_on_first_use` to True and the proxy
179+
will replace the `client_secret` value with a new property `client_secret_encrypted` at the next token refresh. Note
180+
that this option is not compatible with `allow_catch_all_accounts` unless all accounts use the same login password.
181+
182+
- allow_catch_all_accounts (default = False): The default behaviour of the proxy is to require a full separate
183+
configuration file entry for each account. However, when proxying multiple accounts from the same domain it can be
184+
cumbersome to have to create multiple near-identical configuration profiles. To simplify this the proxy supports
185+
catch-all accounts when this option is set to True. Domain-level accounts are configured using section headings. For
186+
example, add a section [@domain.com] with all of the standard required account values, and the proxy will intercept
187+
authentication requests for all usernames at `domain.com`. Whenever a previously unseen account attempts to connect,
188+
account authorisation will take place as normal, and the proxy will automatically create a new account-level section
189+
that does not need to be configured manually. Any account-level configuration will override domain-level values.
190+
If needed, the global catch-all section [@] can also be used. Please note that this option is not compatible with
191+
`encrypt_client_secret_on_first_use` unless all IMAP/POP/SMTP accounts at the same domain use the same password.
180192

181193
[emailproxy]
182194
delete_account_token_on_password_error = True
183195
encrypt_client_secret_on_first_use = False
196+
allow_catch_all_accounts = False

emailproxy.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
__author__ = 'Simon Robinson'
55
__copyright__ = 'Copyright (c) 2022 Simon Robinson'
66
__license__ = 'Apache 2.0'
7-
__version__ = '2022-10-16' # ISO 8601 (YYYY-MM-DD)
7+
__version__ = '2022-10-24' # ISO 8601 (YYYY-MM-DD)
88

99
import argparse
1010
import base64
@@ -304,6 +304,11 @@ def accounts():
304304
AppConfig.get() # make sure config is loaded
305305
return AppConfig._ACCOUNTS
306306

307+
@staticmethod
308+
def add_account(username):
309+
AppConfig._PARSER.add_section(username)
310+
AppConfig._ACCOUNTS = [s for s in AppConfig._PARSER.sections() if '@' in s]
311+
307312
@staticmethod
308313
def save():
309314
if AppConfig._LOADED:
@@ -317,23 +322,35 @@ def get_oauth2_credentials(username, password, recurse_retries=True):
317322
"""Using the given username (i.e., email address) and password, reads account details from AppConfig and
318323
handles OAuth 2.0 token request and renewal, saving the updated details back to AppConfig (or removing them
319324
if invalid). Returns either (True, '[OAuth2 string for authentication]') or (False, '[Error message]')"""
320-
if username not in AppConfig.accounts():
325+
326+
# we support broader catch-all account names (e.g., `@domain.com` / `@`) if enabled
327+
valid_accounts = [username in AppConfig.accounts()]
328+
if AppConfig.globals().getboolean('allow_catch_all_accounts', fallback=False):
329+
user_domain = '@%s' % username.split('@')[-1]
330+
valid_accounts.extend([account in AppConfig.accounts() for account in [user_domain, '@']])
331+
332+
if not any(valid_accounts):
321333
Log.error('Proxy config file entry missing for account', username, '- aborting login')
322334
return (False, '%s: No config file entry found for account %s - please add a new section with values '
323335
'for permission_url, token_url, oauth2_scope, redirect_uri, client_id and '
324336
'client_secret' % (APP_NAME, username))
325337

326338
config = AppConfig.get()
327-
current_time = int(time.time())
328339

329-
permission_url = config.get(username, 'permission_url', fallback=None)
330-
token_url = config.get(username, 'token_url', fallback=None)
331-
oauth2_scope = config.get(username, 'oauth2_scope', fallback=None)
332-
redirect_uri = config.get(username, 'redirect_uri', fallback=None)
333-
redirect_listen_address = config.get(username, 'redirect_listen_address', fallback=None)
334-
client_id = config.get(username, 'client_id', fallback=None)
335-
client_secret = config.get(username, 'client_secret', fallback=None)
336-
client_secret_encrypted = config.get(username, 'client_secret_encrypted', fallback=None)
340+
def get_account_with_catch_all_fallback(option):
341+
fallback = None
342+
if AppConfig.globals().getboolean('allow_catch_all_accounts', fallback=False):
343+
fallback = config.get(user_domain, option, fallback=config.get('@', option, fallback=None))
344+
return config.get(username, option, fallback=fallback)
345+
346+
permission_url = get_account_with_catch_all_fallback('permission_url')
347+
token_url = get_account_with_catch_all_fallback('token_url')
348+
oauth2_scope = get_account_with_catch_all_fallback('oauth2_scope')
349+
redirect_uri = get_account_with_catch_all_fallback('redirect_uri')
350+
redirect_listen_address = get_account_with_catch_all_fallback('redirect_listen_address')
351+
client_id = get_account_with_catch_all_fallback('client_id')
352+
client_secret = get_account_with_catch_all_fallback('client_secret')
353+
client_secret_encrypted = get_account_with_catch_all_fallback('client_secret_encrypted')
337354

338355
# note that we don't require permission_url here because it is not needed for the client credentials grant flow,
339356
# and likewise for client_secret here because it can be optional for Office 365 configurations
@@ -355,6 +372,7 @@ def get_oauth2_credentials(username, password, recurse_retries=True):
355372
'are using an Office 365 setup that does not need a secret, please delete this line entirely;',
356373
'otherwise, if authentication fails, please double-check this value is correct')
357374

375+
current_time = int(time.time())
358376
token_salt = config.get(username, 'token_salt', fallback=None)
359377
access_token = config.get(username, 'access_token', fallback=None)
360378
access_token_expiry = config.getint(username, 'access_token_expiry', fallback=current_time)
@@ -413,6 +431,8 @@ def get_oauth2_credentials(username, password, recurse_retries=True):
413431
client_secret, auth_code, oauth2_scope)
414432

415433
access_token = response['access_token']
434+
if not config.has_section(username):
435+
AppConfig.add_account(username) # in wildcard mode the section may not yet exist
416436
config.set(username, 'token_salt', token_salt)
417437
config.set(username, 'access_token', OAuth2Helper.encrypt(fernet, access_token))
418438
config.set(username, 'access_token_expiry', str(current_time + response['expires_in']))
@@ -425,6 +445,8 @@ def get_oauth2_credentials(username, password, recurse_retries=True):
425445

426446
if AppConfig.globals().getboolean('encrypt_client_secret_on_first_use', fallback=False):
427447
if client_secret:
448+
# note: save to the `username` entry even if `user_domain` exists, avoiding conflicts when using
449+
# incompatible `encrypt_client_secret_on_first_use` and `allow_catch_all_accounts` options
428450
config.set(username, 'client_secret_encrypted', OAuth2Helper.encrypt(fernet, client_secret))
429451
config.remove_option(username, 'client_secret')
430452

@@ -1945,8 +1967,17 @@ def create_config_menu(self):
19451967
if len(config_accounts) <= 0:
19461968
items.append(pystray.MenuItem(' No accounts configured', None, enabled=False))
19471969
else:
1970+
catch_all_enabled = AppConfig.globals().getboolean('allow_catch_all_accounts', fallback=False)
1971+
catch_all_accounts = []
19481972
for account in config_accounts:
1949-
items.append(pystray.MenuItem(App.get_last_activity(account), None, enabled=False))
1973+
if account.startswith('@') and catch_all_enabled:
1974+
catch_all_accounts.append(account)
1975+
else:
1976+
items.append(pystray.MenuItem(App.get_last_activity(account), None, enabled=False))
1977+
if len(catch_all_accounts) > 0:
1978+
items.append(pystray.MenuItem('Catch-all accounts:', None, enabled=False))
1979+
for account in catch_all_accounts:
1980+
items.append(pystray.MenuItem(' %s' % account, None, enabled=False))
19501981
if not sys.platform == 'darwin':
19511982
items.append(pystray.MenuItem(' Refresh activity data', self.icon.update_menu))
19521983
items.append(pystray.Menu.SEPARATOR)

0 commit comments

Comments
 (0)