226 lines
8.4 KiB
Python
Executable File
226 lines
8.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Pause a Core's email in Online Accounts when its password stops working.
|
|
|
|
When your Core password changes (e.g. in Superpanel), email in Online Accounts
|
|
still has the old one. Online Accounts signs in with it each time the network
|
|
changes, and Evolution each time it fetches mail, and after a few wrong
|
|
passwords the mail server's fail2ban blocks this computer.
|
|
|
|
Online Accounts doesn't say when a mail server refuses a password (3.58 reports
|
|
it as a general failure). It does say when Nextcloud refuses one, and Nextcloud
|
|
turns off its app passwords when your Core password changes. So when the
|
|
Core's Nextcloud in Online Accounts needs attention, this signs in to Nextcloud,
|
|
once, with the password email has (your Core password). If Nextcloud says it's
|
|
wrong, the mail server will too, so this pauses email
|
|
(configure.d/lib/goa-account pause): nothing signs in with the old password
|
|
again. It shows a notification; clicking it runs configure core, where "Your
|
|
Core password changed" saves the new password and ends the pause. It never
|
|
signs in to the mail server itself.
|
|
|
|
Run by coredesktop-email-watch.service (see system/email-watch.sh).
|
|
|
|
email-watch --check-password <email> checks the password email has with
|
|
Nextcloud, as above, for configure core's tests: it exits 0 if it works, 2 if
|
|
Nextcloud says it's wrong, 1 if it couldn't tell.
|
|
"""
|
|
|
|
import base64
|
|
import glob
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gio", "2.0")
|
|
from gi.repository import Gio, GLib # noqa: E402
|
|
|
|
COREDESKTOP = os.path.expanduser("~/.local/share/coredesktop")
|
|
RUN_IN_TERMINAL = os.path.join(COREDESKTOP, "system/run-in-terminal")
|
|
CORES = os.path.expanduser("~/.config/coredesktop/cores/*.conf")
|
|
|
|
|
|
def load_goa_account():
|
|
# No __pycache__ in the CoreDesktop checkout, which update pulls into
|
|
sys.dont_write_bytecode = True
|
|
path = os.path.join(COREDESKTOP, "configure.d/lib/goa-account")
|
|
loader = importlib.machinery.SourceFileLoader("goa_account", path)
|
|
module = importlib.util.module_from_spec(importlib.util.spec_from_loader("goa_account", loader))
|
|
loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
goa = load_goa_account()
|
|
|
|
|
|
# Each Core's email and domain, from configure core's cores/<domain>.conf
|
|
def cores():
|
|
found = {}
|
|
for conf in glob.glob(CORES):
|
|
result = subprocess.run(
|
|
["bash", "-c", 'source "$1" && printf "%s\\n%s\\n" "$CORE_EMAIL" "$CORE_DOMAIN"', "-", conf],
|
|
capture_output=True, text=True)
|
|
lines = result.stdout.split("\n")
|
|
if result.returncode == 0 and len(lines) >= 2 and lines[0] and lines[1]:
|
|
found[lines[0]] = lines[1]
|
|
return found
|
|
|
|
|
|
def nextcloud_url(domain):
|
|
return f"https://nextcloud.{domain}"
|
|
|
|
|
|
# Sign in to the Core's Nextcloud with the password email has, the way
|
|
# configure core checks a password (configure.d/lib/nextcloud.sh). True if it
|
|
# works, False if Nextcloud says it's wrong, None if it couldn't tell.
|
|
def nextcloud_takes(email, domain, password):
|
|
user = email.rsplit("@", 1)[0]
|
|
auth = base64.b64encode(f"{user}:{password}".encode()).decode()
|
|
request = urllib.request.Request(
|
|
f"{nextcloud_url(domain)}/ocs/v2.php/cloud/user?format=json",
|
|
headers={"Authorization": f"Basic {auth}", "OCS-APIRequest": "true"})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15):
|
|
return True
|
|
except urllib.error.HTTPError as error:
|
|
return False if error.code == 401 else None
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def email_password(bus, email):
|
|
for _, account in goa.find_accounts(bus, "imap_smtp", email):
|
|
return goa.lookup_credentials("imap_smtp", account["Id"]).get("imap-password")
|
|
return None
|
|
|
|
|
|
def check_password(bus, email):
|
|
domain = cores().get(email)
|
|
if domain is None:
|
|
return None
|
|
password = email_password(bus, email)
|
|
if not password:
|
|
return None
|
|
return nextcloud_takes(email, domain, password)
|
|
|
|
|
|
# Waits for a click in its own thread, so the watching goes on meanwhile
|
|
def notify(email, domain, paused_now):
|
|
def run():
|
|
if paused_now:
|
|
body = (f"Your Core password seems to have changed: {email} has the old one. "
|
|
"So the mail server doesn't block this computer for trying it, it's "
|
|
"paused. Click to enter your new password.")
|
|
else:
|
|
body = (f"{email} is paused because it has an old password. "
|
|
"Click to enter your new Core password.")
|
|
result = subprocess.run(
|
|
["notify-send", "--wait", "--app-name=CoreDesktop", "--icon=mail-unread",
|
|
"--action=default=Fix", "--action=fix=Enter new password",
|
|
f"Email for {domain} is paused", body],
|
|
capture_output=True, text=True)
|
|
if result.stdout.strip() in ("default", "fix"):
|
|
subprocess.run([RUN_IN_TERMINAL, "configure core"])
|
|
|
|
threading.Thread(target=run, daemon=True).start()
|
|
|
|
|
|
# Emails already dealt with since they started failing, so each is checked and
|
|
# notified about once until it works again
|
|
handled = set()
|
|
|
|
|
|
def check(bus):
|
|
try:
|
|
accounts = {(account.get("ProviderType"), account.get("PresentationIdentity")): account
|
|
for _, account in goa.accounts(bus)}
|
|
except GLib.Error:
|
|
return
|
|
for email, domain in cores().items():
|
|
mail = accounts.get(("imap_smtp", email))
|
|
if mail is None:
|
|
continue
|
|
nextcloud = accounts.get(("owncloud", f"{email.rsplit('@', 1)[0]}@nextcloud.{domain}"))
|
|
mail_failing = bool(mail.get("AttentionNeeded"))
|
|
nextcloud_failing = bool(nextcloud and nextcloud.get("AttentionNeeded"))
|
|
if not mail_failing and not nextcloud_failing:
|
|
handled.discard(email)
|
|
continue
|
|
if email in handled:
|
|
continue
|
|
|
|
try:
|
|
if goa.paused(bus, "imap_smtp", email):
|
|
handled.add(email)
|
|
notify(email, domain, False)
|
|
continue
|
|
# Nextcloud may only have had an app password revoked, and email may
|
|
# still be marked from a pause whose new password hasn't been
|
|
# checked yet, so check the password email has before pausing it
|
|
password = email_password(bus, email)
|
|
if not password:
|
|
continue # e.g. the keyring is locked; try again later
|
|
works = nextcloud_takes(email, domain, password)
|
|
if works is None:
|
|
continue # try again when the network changes
|
|
if works:
|
|
handled.add(email)
|
|
continue
|
|
goa.pause(bus, "imap_smtp", email)
|
|
except (GLib.Error, LookupError):
|
|
continue
|
|
handled.add(email)
|
|
notify(email, domain, True)
|
|
|
|
|
|
def watch():
|
|
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
pending = []
|
|
|
|
# Signals come in bursts; check once they've settled
|
|
def queue_check(*_):
|
|
if not pending:
|
|
pending.append(GLib.timeout_add_seconds(2, run_check))
|
|
|
|
def run_check():
|
|
pending.clear()
|
|
check(bus)
|
|
return GLib.SOURCE_REMOVE
|
|
|
|
account = "org.gnome.OnlineAccounts.Account"
|
|
bus.signal_subscribe(goa.BUS_NAME, "org.freedesktop.DBus.Properties", "PropertiesChanged",
|
|
None, account, Gio.DBusSignalFlags.NONE, queue_check)
|
|
bus.signal_subscribe(goa.BUS_NAME, "org.freedesktop.DBus.ObjectManager", "InterfacesAdded",
|
|
None, None, Gio.DBusSignalFlags.NONE, queue_check)
|
|
# Online Accounts checks every account when it starts, e.g. at login, and
|
|
# when the network changes
|
|
bus.signal_subscribe("org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged",
|
|
"/org/freedesktop/DBus", goa.BUS_NAME, Gio.DBusSignalFlags.NONE, queue_check)
|
|
Gio.NetworkMonitor.get_default().connect("network-changed", queue_check)
|
|
queue_check()
|
|
GLib.MainLoop().run()
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) == 3 and sys.argv[1] == "--check-password":
|
|
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
try:
|
|
works = check_password(bus, sys.argv[2])
|
|
except (GLib.Error, LookupError):
|
|
works = None
|
|
return {True: 0, False: 2, None: 1}[works]
|
|
if len(sys.argv) == 1:
|
|
watch()
|
|
return 0
|
|
print(__doc__.strip().split("\n\n")[-1], file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|