dotfiles

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README

commit c86de2fa85eb7feb8ce96feb7a5015cc87f7a249
parent 3f00812ab0119969f8bdd6d6e1a49d41538c9b99
Author: Pablo Cárdenas <pablo.cardenas@imca.edu.pe>
Date:   Sun,  1 May 2022 21:32:17 -0500

XDG Base directory support for mutt

Diffstat:
Rlinux/.mutt/accounts/pablo-cardenas@outlook.com.rc -> linux/.config/mutt/accounts/pablo-cardenas@outlook.com.rc | 0
Alinux/.config/mutt/accounts/pablo.cardenas@imca.edu.pe.rc | 14++++++++++++++
Alinux/.config/mutt/accounts/pablocb2303@gmail.com.rc | 14++++++++++++++
Alinux/.config/mutt/accounts/pcardenasb@uni.edu.pe.rc | 14++++++++++++++
Alinux/.config/mutt/accounts/pcardenasb@uni.pe.rc | 14++++++++++++++
Rlinux/.mutt/clients/.gitignore -> linux/.config/mutt/clients/.gitignore | 0
Alinux/.config/mutt/conf.d/google.rc | 15+++++++++++++++
Rlinux/.mutt/gpg.rc -> linux/.config/mutt/gpg.rc | 0
Rlinux/.mutt/mailcap -> linux/.config/mutt/mailcap | 0
Alinux/.config/mutt/markdown2html | 309+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alinux/.config/mutt/mutt_oauth2.py | 421+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alinux/.config/mutt/muttrc | 36++++++++++++++++++++++++++++++++++++
Alinux/.config/mutt/muttrc.imca | 30++++++++++++++++++++++++++++++
Rlinux/.mutt/sidebar.rc -> linux/.config/mutt/sidebar.rc | 0
Rlinux/.mutt/signature.txt -> linux/.config/mutt/signature.txt | 0
Rlinux/.mutt/tokens/.gitignore -> linux/.config/mutt/tokens/.gitignore | 0
Dlinux/.mutt/accounts/pablo.cardenas@imca.edu.pe.rc | 14--------------
Dlinux/.mutt/accounts/pablocb2303@gmail.com.rc | 14--------------
Dlinux/.mutt/accounts/pcardenasb@uni.edu.pe.rc | 14--------------
Dlinux/.mutt/accounts/pcardenasb@uni.pe.rc | 14--------------
Dlinux/.mutt/conf.d/google.rc | 15---------------
Dlinux/.mutt/mutt_oauth2.py | 421-------------------------------------------------------------------------------
Dlinux/.mutt/muttrc | 33---------------------------------
23 files changed, 867 insertions(+), 525 deletions(-)

diff --git a/linux/.mutt/accounts/pablo-cardenas@outlook.com.rc b/linux/.config/mutt/accounts/pablo-cardenas@outlook.com.rc diff --git a/linux/.config/mutt/accounts/pablo.cardenas@imca.edu.pe.rc b/linux/.config/mutt/accounts/pablo.cardenas@imca.edu.pe.rc @@ -0,0 +1,14 @@ +# IMAP +set imap_user = "pablo.cardenas@imca.edu.pe" + +set folder = "imaps://${imap_user}@imap.gmail.com/" +set trash = "+[Gmail]/Papelera" +set postponed = "+[Gmail]/Borradores" +mailboxes -label ${imap_user}-INBOX +INBOX + + +# SMPT +set hostname = "imca.edu.pe" + + +source "~/.config/mutt/conf.d/google.rc" diff --git a/linux/.config/mutt/accounts/pablocb2303@gmail.com.rc b/linux/.config/mutt/accounts/pablocb2303@gmail.com.rc @@ -0,0 +1,14 @@ +# IMAP +set imap_user = "pablocb2303@gmail.com" + +set folder = "imaps://${imap_user}@imap.gmail.com/" +set trash = "+[Gmail]/Corbeille" +set postponed = "+[Gmail]/Brouillons" +mailboxes -label ${imap_user}-INBOX +INBOX + + +# SMPT +set hostname = "gmail.com" + + +source "~/.config/mutt/conf.d/google.rc" diff --git a/linux/.config/mutt/accounts/pcardenasb@uni.edu.pe.rc b/linux/.config/mutt/accounts/pcardenasb@uni.edu.pe.rc @@ -0,0 +1,14 @@ +# IMAP +set imap_user = "pcardenasb@uni.edu.pe" + +set folder = "imaps://${imap_user}@imap.gmail.com/" +set trash = "+[Gmail]/Trash" +set postponed = "+[Gmail]/Drafts" +mailboxes -label ${imap_user}-INBOX +INBOX + + +# SMPT +set hostname = "uni.edu.pe" + + +source "~/.config/mutt/conf.d/google.rc" diff --git a/linux/.config/mutt/accounts/pcardenasb@uni.pe.rc b/linux/.config/mutt/accounts/pcardenasb@uni.pe.rc @@ -0,0 +1,14 @@ +# IMAP +set imap_user = "pcardenasb@uni.pe" + +set folder = "imaps://${imap_user}@imap.gmail.com/" +set trash = "+[Gmail]/Trash" +set postponed = "+[Gmail]/Drafts" +mailboxes -label ${imap_user}-INBOX +INBOX + + +# SMPT +set hostname = "uni.pe" + + +source "~/.config/mutt/conf.d/google.rc" diff --git a/linux/.mutt/clients/.gitignore b/linux/.config/mutt/clients/.gitignore diff --git a/linux/.config/mutt/conf.d/google.rc b/linux/.config/mutt/conf.d/google.rc @@ -0,0 +1,15 @@ +# IMAP +set imap_authenticators = "oauthbearer:xoauth2" +set imap_oauth_refresh_command = "~/.config/mutt/mutt_oauth2.py ~/.config/mutt/tokens/${imap_user}.gpg" + +set folder = "imaps://${imap_user}@imap.gmail.com/" +set spoolfile = +INBOX +unset record + + +# SMPT +set smtp_url = "smtps://${imap_user}@smtp.gmail.com" +set smtp_authenticators = ${imap_authenticators} +set smtp_oauth_refresh_command = ${imap_oauth_refresh_command} + +set from = ${imap_user} diff --git a/linux/.mutt/gpg.rc b/linux/.config/mutt/gpg.rc diff --git a/linux/.mutt/mailcap b/linux/.config/mutt/mailcap diff --git a/linux/.config/mutt/markdown2html b/linux/.config/mutt/markdown2html @@ -0,0 +1,309 @@ +#!/usr/bin/python3 +# +# markdown2html.py — simple Markdown-to-HTML converter for use with Mutt +# +# Mutt recently learnt [how to compose `multipart/alternative` +# emails][1]. This script assumes a message has been composed using Markdown +# (with a lot of pandoc extensions enabled), and translates it to `text/html` +# for Mutt to tie into such a `multipart/alternative` message. +# +# [1]: https://gitlab.com/muttmua/mutt/commit/0e566a03725b4ad789aa6ac1d17cdf7bf4e7e354) +# +# Configuration: +# muttrc: +# set send_multipart_alternative=yes +# set send_multipart_alternative_filter=/path/to/markdown2html.py +# +# Optionally, Custom CSS styles will be read from `~/.mutt/markdown2html.css`, +# if present. +# +# Requirements: +# - python3 +# - PyPandoc (and pandoc installed, or downloaded) +# - Pynliner +# +# Optional: +# - Pygments, if installed, then syntax highlighting is enabled +# +# Latest version: +# https://git.madduck.net/etc/mutt.git/blob_plain/HEAD:/.mutt/markdown2html +# +# Copyright © 2019 martin f. krafft <madduck@madduck.net> +# Released under the GPL-2+ licence, just like Mutt itself. +# + +import pypandoc +import pynliner +import re +import os +import sys + +try: + from pygments.formatters import get_formatter_by_name + formatter = get_formatter_by_name('html', style='default') + DEFAULT_CSS = formatter.get_style_defs('.sourceCode') + +except ImportError: + DEFAULT_CSS = "" + + +DEFAULT_CSS += ''' +.quote { + padding: 0 0.5em; + margin: 0; + font-style: italic; + border-left: 2px solid #ccc; + color: #999; + font-size: 80%; +} +.quotelead { + font-style: italic; + margin-bottom: -1em; + color: #999; + font-size: 80%; +} +.quotechar { display: none; } +.footnote-ref, .footnote-back { text-decoration: none;} +.signature { + color: #999; + font-family: monospace; + white-space: pre; + margin: 1em 0 0 0; + font-size: 80%; +} +table, th, td { + border-collapse: collapse; + border: 1px solid #999; +} +th, td { padding: 0.5em; } +.header { + background: #eee; +} +.even { background: #eee; } +''' + +STYLESHEET = os.path.join(os.path.expanduser('~/.mutt'), + 'markdown2html.css') +if os.path.exists(STYLESHEET): + DEFAULT_CSS += open(STYLESHEET).read() + +HTML_DOCUMENT = '''<!DOCTYPE html> +<html><head> +<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> +<meta charset="utf-8"/> +<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes"/> +<title>HTML E-Mail</title> +</head><body class="email"> +{htmlbody} +</body></html>''' + + +SIGNATURE_HTML = \ + '<div class="signature"><span class="leader">-- </span>{sig}</div>' + + +def _preprocess_markdown(mdwn): + ''' + Preprocess Markdown for handling by the converter. + ''' + # convert hard line breaks within paragraphs to 2 trailing spaces, which + # is the markdown way of representing hard line breaks. Note how the + # regexp will not match between paragraphs. + ret = re.sub(r'(\S)\n(\s*\S)', r'\g<1> \n\g<2>', mdwn, flags=re.MULTILINE) + + # Clients like Thunderbird need the leading '>' to be able to properly + # create nested quotes, so we duplicate the symbol, the first instance + # will tell pandoc to create a blockquote, while the second instance will + # be a <span> containing the character, along with a class that causes CSS + # to actually hide it from display. However, this does not work with the + # text-mode HTML2text converters, and so it's left commented for now. + #ret = re.sub(r'\n>', r' \n>[>]{.quotechar}', ret, flags=re.MULTILINE) + + return ret + + +def _identify_quotes_for_later(mdwn): + ''' + Email quoting such as: + + ``` + On 1970-01-01, you said: + > The Flat Earth Society has members all around the globe. + ``` + + isn't really properly handled by Markdown, so let's do our best to + identify the individual elements, and mark them, using a syntax similar to + what pandoc uses already in some cases. As pandoc won't actually use these + data (yet?), we call `self._reformat_quotes` later to use these markers + to slap the appropriate classes on the HTML tags. + ''' + + def generate_lines_with_context(mdwn): + ''' + Iterates the input string line-wise, returning a triplet of + previous, current, and next line, the first and last of which + will be None on the first and last line of the input data + respectively. + ''' + prev = cur = nxt = None + lines = iter(mdwn.splitlines()) + cur = next(lines) + for nxt in lines: + yield prev, cur, nxt + prev = cur + cur = nxt + yield prev, cur, None + + ret = [] + for prev, cur, nxt in generate_lines_with_context(mdwn): + + # The lead-in to a quote is a single line immediately preceding the + # quote, and ending with ':'. Note that there could be multiple of + # these: + if nxt is not None and re.match(r'^\s*[^>].+:\s*$', cur) and nxt.startswith('>'): + ret.append(f'{{.quotelead}}{cur.strip()}') + # pandoc needs an empty line before the blockquote, so + # we enter one for the purpose of HTML rendition: + ret.append('') + continue + + # The first blockquote after such a lead-in gets marked as the + # "initial" quote: + elif prev is not None and re.match(r'^\s*[^>].+:\s*$', prev) and cur.startswith('>'): + ret.append(re.sub(r'^(\s*>\s*)+(.+)', + r'\g<1>{.quoteinitial}\g<2>', + cur, flags=re.MULTILINE)) + + # All other occurrences of blockquotes get the "subsequent" marker: + elif cur.startswith('>') and prev is not None and not prev.startswith('>'): + ret.append(re.sub(r'^((?:\s*>\s*)+)(.+)', + r'\g<1>{.quotesubsequent}\g<2>', + cur, flags=re.MULTILINE)) + + else: # pass through everything else. + ret.append(cur) + + return '\n'.join(ret) + + +def _reformat_quotes(html): + ''' + Earlier in the pipeline, we marked email quoting, using markers, which we + now need to turn into HTML classes, so that we can use CSS to style them. + ''' + ret = html.replace('{.quotelead}', '<p class="quotelead">') + ret = re.sub(r'<blockquote>\n((?:<blockquote>\n)*)<p>(?:\{\.quote(\w+)\})', + r'<blockquote class="quote \g<2>">\n\g<1><p>', ret, flags=re.MULTILINE) + return ret + + + +def _convert_with_pandoc(mdwn, inputfmt='markdown', outputfmt='html5', + ext_enabled=None, ext_disabled=None, + standalone=True, title="HTML E-Mail"): + ''' + Invoke pandoc to do the actual conversion of Markdown to HTML5. + ''' + if not ext_enabled: + ext_enabled = [ 'backtick_code_blocks', + 'line_blocks', + 'fancy_lists', + 'startnum', + 'definition_lists', + 'example_lists', + 'table_captions', + 'simple_tables', + 'multiline_tables', + 'grid_tables', + 'pipe_tables', + 'all_symbols_escapable', + 'intraword_underscores', + 'strikeout', + 'superscript', + 'subscript', + 'fenced_divs', + 'bracketed_spans', + 'footnotes', + 'inline_notes', + 'emoji', + 'tex_math_double_backslash', + 'autolink_bare_uris' + ] + if not ext_disabled: + ext_disabled = [ 'tex_math_single_backslash', + 'tex_math_dollars', + 'smart', + 'raw_html' + ] + + enabled = '+'.join(ext_enabled) + disabled = '-'.join(ext_disabled) + inputfmt = f'{inputfmt}+{enabled}-{disabled}' + + args = [] + if standalone: + args.append('--standalone') + if title: + args.append(f'--metadata=pagetitle:"{title}"') + + return pypandoc.convert_text(mdwn, format=inputfmt, to=outputfmt, + extra_args=args) + + +def _apply_styling(html): + ''' + Inline all styles defined and used into the individual HTML tags. + ''' + return pynliner.Pynliner().from_string(html).with_cssString(DEFAULT_CSS).run() + + +def _postprocess_html(html): + ''' + Postprocess the generated and styled HTML. + ''' + return html + + +def convert_markdown_to_html(mdwn): + ''' + Converts the input Markdown to HTML, handling separately the body, as well + as an optional signature. + ''' + parts = re.split(r'^-- $', mdwn, 1, flags=re.MULTILINE) + body = parts[0] + if len(parts) == 2: + sig = parts[1] + else: + sig = None + + html='' + if body: + body = _preprocess_markdown(body) + body = _identify_quotes_for_later(body) + html = _convert_with_pandoc(body, standalone=False) + html = _reformat_quotes(html) + + if sig: + sig = _preprocess_markdown(sig) + html += SIGNATURE_HTML.format(sig='<br/>'.join(sig.splitlines())) + + html = HTML_DOCUMENT.format(htmlbody=html) + html = _apply_styling(html) + html = _postprocess_html(html) + + return html + + +def main(): + ''' + Convert text on stdin to HTML, and print it to stdout, like mutt would + expect. + ''' + html = convert_markdown_to_html(sys.stdin.read()) + if html: + # mutt expects the content type in the first line, so: + print(f'text/html\n\n{html}') + + +if __name__ == '__main__': + main() diff --git a/linux/.config/mutt/mutt_oauth2.py b/linux/.config/mutt/mutt_oauth2.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +# +# Mutt OAuth2 token management script, version 2020-08-07 +# Written against python 3.7.3, not tried with earlier python versions. +# +# Copyright (C) 2020 Alexander Perlis +# +# 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. +'''Mutt OAuth2 token management''' + +import sys +import json +import argparse +import urllib.parse +import urllib.request +import imaplib +import poplib +import smtplib +import base64 +import secrets +import hashlib +import time +from datetime import timedelta, datetime +from pathlib import Path +import socket +import http.server +import subprocess + +# The token file must be encrypted because it contains multi-use bearer tokens +# whose usage does not require additional verification. Specify whichever +# encryption and decryption pipes you prefer. They should read from standard +# input and write to standard output. The example values here invoke GPG, +# although won't work until an appropriate identity appears in the first line. +ENCRYPTION_PIPE = ['gpg', '--encrypt', '--recipient', '99E05F373B9E3FCB78CA93EEAB3EA9F44C8DD92B'] +DECRYPTION_PIPE = ['gpg', '--decrypt'] + +google_client = json.load(open(Path.home() / '.config/mutt/clients/google.json')) + +registrations = { + 'google': { + 'authorize_endpoint': 'https://accounts.google.com/o/oauth2/auth', + 'devicecode_endpoint': 'https://oauth2.googleapis.com/device/code', + 'token_endpoint': 'https://accounts.google.com/o/oauth2/token', + 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob', + 'imap_endpoint': 'imap.gmail.com', + 'pop_endpoint': 'pop.gmail.com', + 'smtp_endpoint': 'smtp.gmail.com', + 'sasl_method': 'OAUTHBEARER', + 'scope': 'https://mail.google.com/', + 'client_id': google_client['installed']['client_id'], + 'client_secret': google_client['installed']['client_secret'], + }, + 'microsoft': { + 'authorize_endpoint': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + 'devicecode_endpoint': 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode', + 'token_endpoint': 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + 'redirect_uri': 'https://login.microsoftonline.com/common/oauth2/nativeclient', + 'tenant': 'common', + 'imap_endpoint': 'outlook.office365.com', + 'pop_endpoint': 'outlook.office365.com', + 'smtp_endpoint': 'smtp.office365.com', + 'sasl_method': 'XOAUTH2', + 'scope': ('offline_access https://outlook.office.com/IMAP.AccessAsUser.All ' + 'https://outlook.office.com/POP.AccessAsUser.All ' + 'https://outlook.office.com/SMTP.Send'), + 'client_id': '', + 'client_secret': '', + }, +} + +ap = argparse.ArgumentParser(epilog=''' +This script obtains and prints a valid OAuth2 access token. State is maintained in an +encrypted TOKENFILE. Run with "--verbose --authorize" to get started or whenever all +tokens have expired, optionally with "--authflow" to override the default authorization +flow. To truly start over from scratch, first delete TOKENFILE. Use "--verbose --test" +to test the IMAP/POP/SMTP endpoints. +''') +ap.add_argument('-v', '--verbose', action='store_true', help='increase verbosity') +ap.add_argument('-d', '--debug', action='store_true', help='enable debug output') +ap.add_argument('tokenfile', help='persistent token storage') +ap.add_argument('-a', '--authorize', action='store_true', help='manually authorize new tokens') +ap.add_argument('--authflow', help='authcode | localhostauthcode | devicecode') +ap.add_argument('-t', '--test', action='store_true', help='test IMAP/POP/SMTP endpoints') +args = ap.parse_args() + +token = {} +path = Path(args.tokenfile) +if path.exists(): + if 0o777 & path.stat().st_mode != 0o600: + sys.exit('Token file has unsafe mode. Suggest deleting and starting over.') + try: + sub = subprocess.run(DECRYPTION_PIPE, check=True, input=path.read_bytes(), + capture_output=True) + token = json.loads(sub.stdout) + except subprocess.CalledProcessError: + sys.exit('Difficulty decrypting token file. Is your decryption agent primed for ' + 'non-interactive usage, or an appropriate environment variable such as ' + 'GPG_TTY set to allow interactive agent usage from inside a pipe?') + + +def writetokenfile(): + '''Writes global token dictionary into token file.''' + if not path.exists(): + path.touch(mode=0o600) + if 0o777 & path.stat().st_mode != 0o600: + sys.exit('Token file has unsafe mode. Suggest deleting and starting over.') + sub2 = subprocess.run(ENCRYPTION_PIPE, check=True, input=json.dumps(token).encode(), + capture_output=True) + path.write_bytes(sub2.stdout) + + +if args.debug: + print('Obtained from token file:', json.dumps(token)) +if not token: + if not args.authorize: + sys.exit('You must run script with "--authorize" at least once.') + print('Available app and endpoint registrations:', *registrations) + token['registration'] = input('OAuth2 registration: ') + token['authflow'] = input('Preferred OAuth2 flow ("authcode" or "localhostauthcode" ' + 'or "devicecode"): ') + token['email'] = input('Account e-mail address: ') + token['access_token'] = '' + token['access_token_expiration'] = '' + token['refresh_token'] = '' + writetokenfile() + +if token['registration'] not in registrations: + sys.exit(f'ERROR: Unknown registration "{token["registration"]}". Delete token file ' + f'and start over.') +registration = registrations[token['registration']] + +authflow = token['authflow'] +if args.authflow: + authflow = args.authflow + +baseparams = {'client_id': registration['client_id']} +# Microsoft uses 'tenant' but Google does not +if 'tenant' in registration: + baseparams['tenant'] = registration['tenant'] + + +def access_token_valid(): + '''Returns True when stored access token exists and is still valid at this time.''' + token_exp = token['access_token_expiration'] + return token_exp and datetime.now() < datetime.fromisoformat(token_exp) + + +def update_tokens(r): + '''Takes a response dictionary, extracts tokens out of it, and updates token file.''' + token['access_token'] = r['access_token'] + token['access_token_expiration'] = (datetime.now() + + timedelta(seconds=int(r['expires_in']))).isoformat() + if 'refresh_token' in r: + token['refresh_token'] = r['refresh_token'] + writetokenfile() + if args.verbose: + print(f'NOTICE: Obtained new access token, expires {token["access_token_expiration"]}.') + + +if args.authorize: + p = baseparams.copy() + p['scope'] = registration['scope'] + + if authflow in ('authcode', 'localhostauthcode'): + verifier = secrets.token_urlsafe(90) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())[:-1] + redirect_uri = registration['redirect_uri'] + listen_port = 0 + if authflow == 'localhostauthcode': + # Find an available port to listen on + s = socket.socket() + s.bind(('127.0.0.1', 0)) + listen_port = s.getsockname()[1] + s.close() + redirect_uri = 'http://localhost:'+str(listen_port)+'/' + # Probably should edit the port number into the actual redirect URL. + + p.update({'login_hint': token['email'], + 'response_type': 'code', + 'redirect_uri': redirect_uri, + 'code_challenge': challenge, + 'code_challenge_method': 'S256'}) + print(registration["authorize_endpoint"] + '?' + + urllib.parse.urlencode(p, quote_via=urllib.parse.quote)) + + authcode = '' + if authflow == 'authcode': + authcode = input('Visit displayed URL to retrieve authorization code. Enter ' + 'code from server (might be in browser address bar): ') + else: + print('Visit displayed URL to authorize this application. Waiting...', + end='', flush=True) + + class MyHandler(http.server.BaseHTTPRequestHandler): + '''Handles the browser query resulting from redirect to redirect_uri.''' + + # pylint: disable=C0103 + def do_HEAD(self): + '''Response to a HEAD requests.''' + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + def do_GET(self): + '''For GET request, extract code parameter from URL.''' + # pylint: disable=W0603 + global authcode + querystring = urllib.parse.urlparse(self.path).query + querydict = urllib.parse.parse_qs(querystring) + if 'code' in querydict: + authcode = querydict['code'][0] + self.do_HEAD() + self.wfile.write(b'<html><head><title>Authorizaton result</title></head>') + self.wfile.write(b'<body><p>Authorization redirect completed. You may ' + b'close this window.</p></body></html>') + with http.server.HTTPServer(('127.0.0.1', listen_port), MyHandler) as httpd: + try: + httpd.handle_request() + except KeyboardInterrupt: + pass + + if not authcode: + sys.exit('Did not obtain an authcode.') + + for k in 'response_type', 'login_hint', 'code_challenge', 'code_challenge_method': + del p[k] + p.update({'grant_type': 'authorization_code', + 'code': authcode, + 'client_secret': registration['client_secret'], + 'code_verifier': verifier}) + try: + response = urllib.request.urlopen(registration['token_endpoint'], + urllib.parse.urlencode(p).encode()) + except urllib.error.HTTPError as err: + print(err.code, err.reason) + response = err + response = response.read() + if args.debug: + print(response) + response = json.loads(response) + if 'error' in response: + print(response['error']) + if 'error_description' in response: + print(response['error_description']) + sys.exit(1) + + elif authflow == 'devicecode': + try: + response = urllib.request.urlopen(registration['devicecode_endpoint'], + urllib.parse.urlencode(p).encode()) + except urllib.error.HTTPError as err: + print(err.code, err.reason) + response = err + response = response.read() + if args.debug: + print(response) + response = json.loads(response) + if 'error' in response: + print(response['error']) + if 'error_description' in response: + print(response['error_description']) + sys.exit(1) + print(response['message']) + del p['scope'] + p.update({'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'client_secret': registration['client_secret'], + 'device_code': response['device_code']}) + interval = int(response['interval']) + print('Polling...', end='', flush=True) + while True: + time.sleep(interval) + print('.', end='', flush=True) + try: + response = urllib.request.urlopen(registration['token_endpoint'], + urllib.parse.urlencode(p).encode()) + except urllib.error.HTTPError as err: + # Not actually always an error, might just mean "keep trying..." + response = err + response = response.read() + if args.debug: + print(response) + response = json.loads(response) + if 'error' not in response: + break + if response['error'] == 'authorization_declined': + print(' user declined authorization.') + sys.exit(1) + if response['error'] == 'expired_token': + print(' too much time has elapsed.') + sys.exit(1) + if response['error'] != 'authorization_pending': + print(response['error']) + if 'error_description' in response: + print(response['error_description']) + sys.exit(1) + print() + + else: + sys.exit(f'ERROR: Unknown OAuth2 flow "{token["authflow"]}. Delete token file and ' + f'start over.') + + update_tokens(response) + + +if not access_token_valid(): + if args.verbose: + print('NOTICE: Invalid or expired access token; using refresh token ' + 'to obtain new access token.') + if not token['refresh_token']: + sys.exit('ERROR: No refresh token. Run script with "--authorize".') + p = baseparams.copy() + p.update({'client_secret': registration['client_secret'], + 'refresh_token': token['refresh_token'], + 'grant_type': 'refresh_token'}) + try: + response = urllib.request.urlopen(registration['token_endpoint'], + urllib.parse.urlencode(p).encode()) + except urllib.error.HTTPError as err: + print(err.code, err.reason) + response = err + response = response.read() + if args.debug: + print(response) + response = json.loads(response) + if 'error' in response: + print(response['error']) + if 'error_description' in response: + print(response['error_description']) + print('Perhaps refresh token invalid. Try running once with "--authorize"') + sys.exit(1) + update_tokens(response) + + +if not access_token_valid(): + sys.exit('ERROR: No valid access token. This should not be able to happen.') + + +if args.verbose: + print('Access Token: ', end='') +print(token['access_token']) + + +def build_sasl_string(user, host, port, bearer_token): + '''Build appropriate SASL string, which depends on cloud server's supported SASL method.''' + if registration['sasl_method'] == 'OAUTHBEARER': + return f'n,a={user},\1host={host}\1port={port}\1auth=Bearer {bearer_token}\1\1' + if registration['sasl_method'] == 'XOAUTH2': + return f'user={user}\1auth=Bearer {bearer_token}\1\1' + sys.exit(f'Unknown SASL method {registration["sasl_method"]}.') + + +if args.test: + errors = False + + imap_conn = imaplib.IMAP4_SSL(registration['imap_endpoint']) + sasl_string = build_sasl_string(token['email'], registration['imap_endpoint'], 993, + token['access_token']) + if args.debug: + imap_conn.debug = 4 + try: + imap_conn.authenticate(registration['sasl_method'], lambda _: sasl_string.encode()) + # Microsoft has a bug wherein a mismatch between username and token can still report a + # successful login... (Try a consumer login with the token from a work/school account.) + # Fortunately subsequent commands fail with an error. Thus we follow AUTH with another + # IMAP command before reporting success. + imap_conn.list() + if args.verbose: + print('IMAP authentication succeeded') + except imaplib.IMAP4.error as e: + print('IMAP authentication FAILED (does your account allow IMAP?):', e) + errors = True + + pop_conn = poplib.POP3_SSL(registration['pop_endpoint']) + sasl_string = build_sasl_string(token['email'], registration['pop_endpoint'], 995, + token['access_token']) + if args.debug: + pop_conn.set_debuglevel(2) + try: + # poplib doesn't have an auth command taking an authenticator object + # Microsoft requires a two-line SASL for POP + # pylint: disable=W0212 + pop_conn._shortcmd('AUTH ' + registration['sasl_method']) + pop_conn._shortcmd(base64.standard_b64encode(sasl_string.encode()).decode()) + if args.verbose: + print('POP authentication succeeded') + except poplib.error_proto as e: + print('POP authentication FAILED (does your account allow POP?):', e.args[0].decode()) + errors = True + + # SMTP_SSL would be simpler but Microsoft does not answer on port 465. + smtp_conn = smtplib.SMTP(registration['smtp_endpoint'], 587) + sasl_string = build_sasl_string(token['email'], registration['smtp_endpoint'], 587, + token['access_token']) + smtp_conn.ehlo('test') + smtp_conn.starttls() + smtp_conn.ehlo('test') + if args.debug: + smtp_conn.set_debuglevel(2) + try: + smtp_conn.auth(registration['sasl_method'], lambda _=None: sasl_string) + if args.verbose: + print('SMTP authentication succeeded') + except smtplib.SMTPAuthenticationError as e: + print('SMTP authentication FAILED:', e) + errors = True + + if errors: + sys.exit(1) diff --git a/linux/.config/mutt/muttrc b/linux/.config/mutt/muttrc @@ -0,0 +1,36 @@ +set header_cache = "~/.cache/mutt/" +set message_cachedir = "~/.cache/mutt/" + +unset folder +unset spoolfile +set imap_keepalive = 300 +unset imap_passive +set mail_check = 60 +set mbox_type = Maildir + +set signature = ~/.config/mutt/signature.txt +set send_charset="utf-8" + +set realname = "Pablo Cárdenas" +set use_from = yes +#set ssl_force_tls = yes + +set mailcap_path = ~/.config/mutt/mailcap + +source "~/.config/mutt/gpg.rc" + +source "~/.config/mutt/sidebar.rc" +set sidebar_visible = yes + +set query_command = "khard email --parsable %s" + +set send_multipart_alternative +set send_multipart_alternative_filter = ~/.config/mutt/markdown2html + +macro index,pager <f2> '<enter-command>source ~/.config/mutt/accounts/.rc^B^B^B' +macro index,pager <f3> "\ +<enter-command>source ~/.config/mutt/accounts/pablocb2303@gmail.com.rc<enter>\ +<enter-command>source ~/.config/mutt/accounts/pablo.cardenas@imca.edu.pe.rc<enter>\ +<enter-command>source ~/.config/mutt/accounts/pcardenasb@uni.edu.pe.rc<enter>\ +<enter-command>source ~/.config/mutt/accounts/pcardenasb@uni.pe.rc<enter>\ +" diff --git a/linux/.config/mutt/muttrc.imca b/linux/.config/mutt/muttrc.imca @@ -0,0 +1,30 @@ +set header_cache = "~/.cache/mutt/" +set message_cachedir = "~/.cache/mutt/" + +unset folder +unset spoolfile +set imap_keepalive = 300 +unset imap_passive +set mail_check = 60 +set mbox_type = Maildir + +set signature = ~/.mutt/signature.txt +set send_charset="utf-8" + +set realname = "Pablo Cárdenas" +set use_from = yes +#set ssl_force_tls = yes + +set mailcap_path = ~/.mutt/mailcap + +source "~/.mutt/gpg.rc" + +source "~/.mutt/sidebar.rc" +set sidebar_visible = yes + +set query_command = "khard email --parsable %s" + +set send_multipart_alternative +set send_multipart_alternative_filter = ~/.mutt/markdown2html + +source ~/.mutt/accounts/pablo.cardenas@imca.edu.pe.rc diff --git a/linux/.mutt/sidebar.rc b/linux/.config/mutt/sidebar.rc diff --git a/linux/.mutt/signature.txt b/linux/.config/mutt/signature.txt diff --git a/linux/.mutt/tokens/.gitignore b/linux/.config/mutt/tokens/.gitignore diff --git a/linux/.mutt/accounts/pablo.cardenas@imca.edu.pe.rc b/linux/.mutt/accounts/pablo.cardenas@imca.edu.pe.rc @@ -1,14 +0,0 @@ -# IMAP -set imap_user = "pablo.cardenas@imca.edu.pe" - -set folder = "imaps://${imap_user}@imap.gmail.com/" -set trash = "+[Gmail]/Papelera" -set postponed = "+[Gmail]/Borradores" -mailboxes -label ${imap_user}-INBOX +INBOX - - -# SMPT -set hostname = "imca.edu.pe" - - -source "~/.mutt/conf.d/google.rc" diff --git a/linux/.mutt/accounts/pablocb2303@gmail.com.rc b/linux/.mutt/accounts/pablocb2303@gmail.com.rc @@ -1,14 +0,0 @@ -# IMAP -set imap_user = "pablocb2303@gmail.com" - -set folder = "imaps://${imap_user}@imap.gmail.com/" -set trash = "+[Gmail]/Corbeille" -set postponed = "+[Gmail]/Brouillons" -mailboxes -label ${imap_user}-INBOX +INBOX - - -# SMPT -set hostname = "gmail.com" - - -source "~/.mutt/conf.d/google.rc" diff --git a/linux/.mutt/accounts/pcardenasb@uni.edu.pe.rc b/linux/.mutt/accounts/pcardenasb@uni.edu.pe.rc @@ -1,14 +0,0 @@ -# IMAP -set imap_user = "pcardenasb@uni.edu.pe" - -set folder = "imaps://${imap_user}@imap.gmail.com/" -set trash = "+[Gmail]/Trash" -set postponed = "+[Gmail]/Drafts" -mailboxes -label ${imap_user}-INBOX +INBOX - - -# SMPT -set hostname = "uni.edu.pe" - - -source "~/.mutt/conf.d/google.rc" diff --git a/linux/.mutt/accounts/pcardenasb@uni.pe.rc b/linux/.mutt/accounts/pcardenasb@uni.pe.rc @@ -1,14 +0,0 @@ -# IMAP -set imap_user = "pcardenasb@uni.pe" - -set folder = "imaps://${imap_user}@imap.gmail.com/" -set trash = "+[Gmail]/Trash" -set postponed = "+[Gmail]/Drafts" -mailboxes -label ${imap_user}-INBOX +INBOX - - -# SMPT -set hostname = "uni.pe" - - -source "~/.mutt/conf.d/google.rc" diff --git a/linux/.mutt/conf.d/google.rc b/linux/.mutt/conf.d/google.rc @@ -1,15 +0,0 @@ -# IMAP -set imap_authenticators = "oauthbearer:xoauth2" -set imap_oauth_refresh_command = "~/.mutt/mutt_oauth2.py ~/.mutt/tokens/${imap_user}.gpg" - -set folder = "imaps://${imap_user}@imap.gmail.com/" -set spoolfile = +INBOX -unset record - - -# SMPT -set smtp_url = "smtps://${imap_user}@smtp.gmail.com" -set smtp_authenticators = ${imap_authenticators} -set smtp_oauth_refresh_command = ${imap_oauth_refresh_command} - -set from = ${imap_user} diff --git a/linux/.mutt/mutt_oauth2.py b/linux/.mutt/mutt_oauth2.py @@ -1,421 +0,0 @@ -#!/usr/bin/env python3 -# -# Mutt OAuth2 token management script, version 2020-08-07 -# Written against python 3.7.3, not tried with earlier python versions. -# -# Copyright (C) 2020 Alexander Perlis -# -# 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. -'''Mutt OAuth2 token management''' - -import sys -import json -import argparse -import urllib.parse -import urllib.request -import imaplib -import poplib -import smtplib -import base64 -import secrets -import hashlib -import time -from datetime import timedelta, datetime -from pathlib import Path -import socket -import http.server -import subprocess - -# The token file must be encrypted because it contains multi-use bearer tokens -# whose usage does not require additional verification. Specify whichever -# encryption and decryption pipes you prefer. They should read from standard -# input and write to standard output. The example values here invoke GPG, -# although won't work until an appropriate identity appears in the first line. -ENCRYPTION_PIPE = ['gpg', '--encrypt', '--recipient', '99E05F373B9E3FCB78CA93EEAB3EA9F44C8DD92B'] -DECRYPTION_PIPE = ['gpg', '--decrypt'] - -google_client = json.load(open(Path.home() / '.mutt/clients/google.json')) - -registrations = { - 'google': { - 'authorize_endpoint': 'https://accounts.google.com/o/oauth2/auth', - 'devicecode_endpoint': 'https://oauth2.googleapis.com/device/code', - 'token_endpoint': 'https://accounts.google.com/o/oauth2/token', - 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob', - 'imap_endpoint': 'imap.gmail.com', - 'pop_endpoint': 'pop.gmail.com', - 'smtp_endpoint': 'smtp.gmail.com', - 'sasl_method': 'OAUTHBEARER', - 'scope': 'https://mail.google.com/', - 'client_id': google_client['installed']['client_id'], - 'client_secret': google_client['installed']['client_secret'], - }, - 'microsoft': { - 'authorize_endpoint': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - 'devicecode_endpoint': 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode', - 'token_endpoint': 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - 'redirect_uri': 'https://login.microsoftonline.com/common/oauth2/nativeclient', - 'tenant': 'common', - 'imap_endpoint': 'outlook.office365.com', - 'pop_endpoint': 'outlook.office365.com', - 'smtp_endpoint': 'smtp.office365.com', - 'sasl_method': 'XOAUTH2', - 'scope': ('offline_access https://outlook.office.com/IMAP.AccessAsUser.All ' - 'https://outlook.office.com/POP.AccessAsUser.All ' - 'https://outlook.office.com/SMTP.Send'), - 'client_id': '', - 'client_secret': '', - }, -} - -ap = argparse.ArgumentParser(epilog=''' -This script obtains and prints a valid OAuth2 access token. State is maintained in an -encrypted TOKENFILE. Run with "--verbose --authorize" to get started or whenever all -tokens have expired, optionally with "--authflow" to override the default authorization -flow. To truly start over from scratch, first delete TOKENFILE. Use "--verbose --test" -to test the IMAP/POP/SMTP endpoints. -''') -ap.add_argument('-v', '--verbose', action='store_true', help='increase verbosity') -ap.add_argument('-d', '--debug', action='store_true', help='enable debug output') -ap.add_argument('tokenfile', help='persistent token storage') -ap.add_argument('-a', '--authorize', action='store_true', help='manually authorize new tokens') -ap.add_argument('--authflow', help='authcode | localhostauthcode | devicecode') -ap.add_argument('-t', '--test', action='store_true', help='test IMAP/POP/SMTP endpoints') -args = ap.parse_args() - -token = {} -path = Path(args.tokenfile) -if path.exists(): - if 0o777 & path.stat().st_mode != 0o600: - sys.exit('Token file has unsafe mode. Suggest deleting and starting over.') - try: - sub = subprocess.run(DECRYPTION_PIPE, check=True, input=path.read_bytes(), - capture_output=True) - token = json.loads(sub.stdout) - except subprocess.CalledProcessError: - sys.exit('Difficulty decrypting token file. Is your decryption agent primed for ' - 'non-interactive usage, or an appropriate environment variable such as ' - 'GPG_TTY set to allow interactive agent usage from inside a pipe?') - - -def writetokenfile(): - '''Writes global token dictionary into token file.''' - if not path.exists(): - path.touch(mode=0o600) - if 0o777 & path.stat().st_mode != 0o600: - sys.exit('Token file has unsafe mode. Suggest deleting and starting over.') - sub2 = subprocess.run(ENCRYPTION_PIPE, check=True, input=json.dumps(token).encode(), - capture_output=True) - path.write_bytes(sub2.stdout) - - -if args.debug: - print('Obtained from token file:', json.dumps(token)) -if not token: - if not args.authorize: - sys.exit('You must run script with "--authorize" at least once.') - print('Available app and endpoint registrations:', *registrations) - token['registration'] = input('OAuth2 registration: ') - token['authflow'] = input('Preferred OAuth2 flow ("authcode" or "localhostauthcode" ' - 'or "devicecode"): ') - token['email'] = input('Account e-mail address: ') - token['access_token'] = '' - token['access_token_expiration'] = '' - token['refresh_token'] = '' - writetokenfile() - -if token['registration'] not in registrations: - sys.exit(f'ERROR: Unknown registration "{token["registration"]}". Delete token file ' - f'and start over.') -registration = registrations[token['registration']] - -authflow = token['authflow'] -if args.authflow: - authflow = args.authflow - -baseparams = {'client_id': registration['client_id']} -# Microsoft uses 'tenant' but Google does not -if 'tenant' in registration: - baseparams['tenant'] = registration['tenant'] - - -def access_token_valid(): - '''Returns True when stored access token exists and is still valid at this time.''' - token_exp = token['access_token_expiration'] - return token_exp and datetime.now() < datetime.fromisoformat(token_exp) - - -def update_tokens(r): - '''Takes a response dictionary, extracts tokens out of it, and updates token file.''' - token['access_token'] = r['access_token'] - token['access_token_expiration'] = (datetime.now() + - timedelta(seconds=int(r['expires_in']))).isoformat() - if 'refresh_token' in r: - token['refresh_token'] = r['refresh_token'] - writetokenfile() - if args.verbose: - print(f'NOTICE: Obtained new access token, expires {token["access_token_expiration"]}.') - - -if args.authorize: - p = baseparams.copy() - p['scope'] = registration['scope'] - - if authflow in ('authcode', 'localhostauthcode'): - verifier = secrets.token_urlsafe(90) - challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())[:-1] - redirect_uri = registration['redirect_uri'] - listen_port = 0 - if authflow == 'localhostauthcode': - # Find an available port to listen on - s = socket.socket() - s.bind(('127.0.0.1', 0)) - listen_port = s.getsockname()[1] - s.close() - redirect_uri = 'http://localhost:'+str(listen_port)+'/' - # Probably should edit the port number into the actual redirect URL. - - p.update({'login_hint': token['email'], - 'response_type': 'code', - 'redirect_uri': redirect_uri, - 'code_challenge': challenge, - 'code_challenge_method': 'S256'}) - print(registration["authorize_endpoint"] + '?' + - urllib.parse.urlencode(p, quote_via=urllib.parse.quote)) - - authcode = '' - if authflow == 'authcode': - authcode = input('Visit displayed URL to retrieve authorization code. Enter ' - 'code from server (might be in browser address bar): ') - else: - print('Visit displayed URL to authorize this application. Waiting...', - end='', flush=True) - - class MyHandler(http.server.BaseHTTPRequestHandler): - '''Handles the browser query resulting from redirect to redirect_uri.''' - - # pylint: disable=C0103 - def do_HEAD(self): - '''Response to a HEAD requests.''' - self.send_response(200) - self.send_header('Content-type', 'text/html') - self.end_headers() - - def do_GET(self): - '''For GET request, extract code parameter from URL.''' - # pylint: disable=W0603 - global authcode - querystring = urllib.parse.urlparse(self.path).query - querydict = urllib.parse.parse_qs(querystring) - if 'code' in querydict: - authcode = querydict['code'][0] - self.do_HEAD() - self.wfile.write(b'<html><head><title>Authorizaton result</title></head>') - self.wfile.write(b'<body><p>Authorization redirect completed. You may ' - b'close this window.</p></body></html>') - with http.server.HTTPServer(('127.0.0.1', listen_port), MyHandler) as httpd: - try: - httpd.handle_request() - except KeyboardInterrupt: - pass - - if not authcode: - sys.exit('Did not obtain an authcode.') - - for k in 'response_type', 'login_hint', 'code_challenge', 'code_challenge_method': - del p[k] - p.update({'grant_type': 'authorization_code', - 'code': authcode, - 'client_secret': registration['client_secret'], - 'code_verifier': verifier}) - try: - response = urllib.request.urlopen(registration['token_endpoint'], - urllib.parse.urlencode(p).encode()) - except urllib.error.HTTPError as err: - print(err.code, err.reason) - response = err - response = response.read() - if args.debug: - print(response) - response = json.loads(response) - if 'error' in response: - print(response['error']) - if 'error_description' in response: - print(response['error_description']) - sys.exit(1) - - elif authflow == 'devicecode': - try: - response = urllib.request.urlopen(registration['devicecode_endpoint'], - urllib.parse.urlencode(p).encode()) - except urllib.error.HTTPError as err: - print(err.code, err.reason) - response = err - response = response.read() - if args.debug: - print(response) - response = json.loads(response) - if 'error' in response: - print(response['error']) - if 'error_description' in response: - print(response['error_description']) - sys.exit(1) - print(response['message']) - del p['scope'] - p.update({'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', - 'client_secret': registration['client_secret'], - 'device_code': response['device_code']}) - interval = int(response['interval']) - print('Polling...', end='', flush=True) - while True: - time.sleep(interval) - print('.', end='', flush=True) - try: - response = urllib.request.urlopen(registration['token_endpoint'], - urllib.parse.urlencode(p).encode()) - except urllib.error.HTTPError as err: - # Not actually always an error, might just mean "keep trying..." - response = err - response = response.read() - if args.debug: - print(response) - response = json.loads(response) - if 'error' not in response: - break - if response['error'] == 'authorization_declined': - print(' user declined authorization.') - sys.exit(1) - if response['error'] == 'expired_token': - print(' too much time has elapsed.') - sys.exit(1) - if response['error'] != 'authorization_pending': - print(response['error']) - if 'error_description' in response: - print(response['error_description']) - sys.exit(1) - print() - - else: - sys.exit(f'ERROR: Unknown OAuth2 flow "{token["authflow"]}. Delete token file and ' - f'start over.') - - update_tokens(response) - - -if not access_token_valid(): - if args.verbose: - print('NOTICE: Invalid or expired access token; using refresh token ' - 'to obtain new access token.') - if not token['refresh_token']: - sys.exit('ERROR: No refresh token. Run script with "--authorize".') - p = baseparams.copy() - p.update({'client_secret': registration['client_secret'], - 'refresh_token': token['refresh_token'], - 'grant_type': 'refresh_token'}) - try: - response = urllib.request.urlopen(registration['token_endpoint'], - urllib.parse.urlencode(p).encode()) - except urllib.error.HTTPError as err: - print(err.code, err.reason) - response = err - response = response.read() - if args.debug: - print(response) - response = json.loads(response) - if 'error' in response: - print(response['error']) - if 'error_description' in response: - print(response['error_description']) - print('Perhaps refresh token invalid. Try running once with "--authorize"') - sys.exit(1) - update_tokens(response) - - -if not access_token_valid(): - sys.exit('ERROR: No valid access token. This should not be able to happen.') - - -if args.verbose: - print('Access Token: ', end='') -print(token['access_token']) - - -def build_sasl_string(user, host, port, bearer_token): - '''Build appropriate SASL string, which depends on cloud server's supported SASL method.''' - if registration['sasl_method'] == 'OAUTHBEARER': - return f'n,a={user},\1host={host}\1port={port}\1auth=Bearer {bearer_token}\1\1' - if registration['sasl_method'] == 'XOAUTH2': - return f'user={user}\1auth=Bearer {bearer_token}\1\1' - sys.exit(f'Unknown SASL method {registration["sasl_method"]}.') - - -if args.test: - errors = False - - imap_conn = imaplib.IMAP4_SSL(registration['imap_endpoint']) - sasl_string = build_sasl_string(token['email'], registration['imap_endpoint'], 993, - token['access_token']) - if args.debug: - imap_conn.debug = 4 - try: - imap_conn.authenticate(registration['sasl_method'], lambda _: sasl_string.encode()) - # Microsoft has a bug wherein a mismatch between username and token can still report a - # successful login... (Try a consumer login with the token from a work/school account.) - # Fortunately subsequent commands fail with an error. Thus we follow AUTH with another - # IMAP command before reporting success. - imap_conn.list() - if args.verbose: - print('IMAP authentication succeeded') - except imaplib.IMAP4.error as e: - print('IMAP authentication FAILED (does your account allow IMAP?):', e) - errors = True - - pop_conn = poplib.POP3_SSL(registration['pop_endpoint']) - sasl_string = build_sasl_string(token['email'], registration['pop_endpoint'], 995, - token['access_token']) - if args.debug: - pop_conn.set_debuglevel(2) - try: - # poplib doesn't have an auth command taking an authenticator object - # Microsoft requires a two-line SASL for POP - # pylint: disable=W0212 - pop_conn._shortcmd('AUTH ' + registration['sasl_method']) - pop_conn._shortcmd(base64.standard_b64encode(sasl_string.encode()).decode()) - if args.verbose: - print('POP authentication succeeded') - except poplib.error_proto as e: - print('POP authentication FAILED (does your account allow POP?):', e.args[0].decode()) - errors = True - - # SMTP_SSL would be simpler but Microsoft does not answer on port 465. - smtp_conn = smtplib.SMTP(registration['smtp_endpoint'], 587) - sasl_string = build_sasl_string(token['email'], registration['smtp_endpoint'], 587, - token['access_token']) - smtp_conn.ehlo('test') - smtp_conn.starttls() - smtp_conn.ehlo('test') - if args.debug: - smtp_conn.set_debuglevel(2) - try: - smtp_conn.auth(registration['sasl_method'], lambda _=None: sasl_string) - if args.verbose: - print('SMTP authentication succeeded') - except smtplib.SMTPAuthenticationError as e: - print('SMTP authentication FAILED:', e) - errors = True - - if errors: - sys.exit(1) diff --git a/linux/.mutt/muttrc b/linux/.mutt/muttrc @@ -1,33 +0,0 @@ -set header_cache = "~/.cache/mutt/" -set message_cachedir = "~/.cache/mutt/" - -unset folder -unset spoolfile -set imap_keepalive = 300 -unset imap_passive -set mail_check = 60 -set mbox_type = Maildir - -set signature = ~/.mutt/signature.txt -set send_charset="utf-8" - -set realname = "Pablo Cárdenas" -set use_from = yes -#set ssl_force_tls = yes - -set mailcap_path = ~/.mutt/mailcap - -source "~/.mutt/gpg.rc" - -source "~/.mutt/sidebar.rc" -set sidebar_visible = yes - -set query_command = "khard email --parsable %s" - -macro index,pager <f2> '<enter-command>source ~/.mutt/accounts/.rc^B^B^B' -macro index,pager <f3> "\ -<enter-command>source ~/.mutt/accounts/pablocb2303@gmail.com.rc<enter>\ -<enter-command>source ~/.mutt/accounts/pablo.cardenas@imca.edu.pe.rc<enter>\ -<enter-command>source ~/.mutt/accounts/pcardenasb@uni.edu.pe.rc<enter>\ -<enter-command>source ~/.mutt/accounts/pcardenasb@uni.pe.rc<enter>\ -"