A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Added XOAUTH2 support for the benchmark. Updated documentation

+134 -6
+13 -1
README.md
··· 217 217 | FETCH (200 emails) | 76ms | 218 218 | MOVE (1 email) | 46ms | 219 219 220 - **Gmail** — folder switch: **~570ms total** (17x slower) 220 + **Outlook/Office365** — folder switch: **~269ms total** (8x slower than Hostpoint) 221 + | Operation | Time | 222 + |-----------|------| 223 + | SELECT | 45ms | 224 + | UID SEARCH | 22ms | 225 + | FETCH (10 emails) | 180ms | 226 + | MOVE (1 email) | 21ms | 227 + 228 + **Gmail** — folder switch: **~570ms total** (17x slower than Hostpoint) 221 229 | Operation | Time | 222 230 |-----------|------| 223 231 | SELECT | 200ms | ··· 231 239 232 240 **Test your own provider:** 233 241 ```bash 242 + # With password 234 243 IMAP_HOST=imap.example.com IMAP_USER=me@example.com IMAP_PASS=secret ./scripts/imap-benchmark.sh 244 + 245 + # With OAuth2 (reads token from neomd config) 246 + CONFIG=~/.config/neomd/config.toml IMAP_USER=me@gmail.com ./scripts/imap-benchmark.sh 235 247 ``` 236 248 237 249 ## Security
+121 -5
scripts/imap-benchmark.sh
··· 5 5 # Or with env vars from your shell: 6 6 # IMAP_HOST=imap.mail.hostpoint.ch IMAP_USER=simu@sspaeti.com IMAP_PASS=$IMAP_PASS_SIMU ./scripts/imap-benchmark.sh 7 7 # IMAP_HOST=imap.gmail.com IMAP_USER=demo@gmail.com IMAP_PASS=$GMAIL_APP_PASS ./scripts/imap-benchmark.sh 8 + # 9 + # OAuth2 support - reads token from neomd config: 10 + # CONFIG=~/.config/neomd/config.toml ./scripts/imap-benchmark.sh 11 + # CONFIG=~/.config/neomd-demo/config.toml ./scripts/imap-benchmark.sh 8 12 9 13 set -e 10 14 11 15 HOST="${IMAP_HOST:?Set IMAP_HOST (e.g. imap.gmail.com)}" 12 16 USER="${IMAP_USER:?Set IMAP_USER (e.g. me@example.com)}" 13 - PASS="${IMAP_PASS:?Set IMAP_PASS (app password)}" 17 + PASS="${IMAP_PASS:-}" 14 18 PORT="${IMAP_PORT:-993}" 19 + CONFIG="${CONFIG:-}" 15 20 16 21 python3 -c " 17 - import time, ssl, socket 22 + import time, ssl, socket, json, base64, os, sys 23 + from datetime import datetime, timezone 18 24 19 25 host, user, pw = '$HOST', '$USER', '$PASS' 20 26 port = $PORT 27 + config_path = '$CONFIG' 28 + 29 + def sanitize_account_name(name): 30 + '''Sanitize account name for token filename (same as Go)''' 31 + return ''.join('_' if c in '/\\\\:' else c for c in name) 32 + 33 + def get_token_from_config(config_path, user): 34 + '''Read token from neomd config and token file''' 35 + import tomllib # Python 3.11+ 36 + 37 + with open(config_path, 'rb') as f: 38 + cfg = tomllib.load(f) 39 + 40 + # Find account matching the user 41 + accounts = cfg.get('accounts', []) 42 + if not accounts and cfg.get('account'): 43 + accounts = [cfg['account']] 44 + 45 + account = None 46 + for acc in accounts: 47 + if acc.get('user') == user: 48 + account = acc 49 + break 50 + 51 + if not account: 52 + print(f'Error: No account found for user {user} in {config_path}') 53 + sys.exit(1) 54 + 55 + # Check if OAuth2 56 + auth_type = account.get('auth_type', '').lower() 57 + if auth_type != 'oauth2': 58 + return None # Not OAuth2, use password 59 + 60 + account_name = account.get('name', 'Personal') 61 + 62 + # Compute token file path (same logic as Go) 63 + # Go uses: ~/.config/{cacheDirName}/tokens/{safe_name}.json 64 + config_dir = os.path.dirname(config_path) # e.g., ~/.config/neomd 65 + cache_dir_name = os.path.basename(config_dir) # e.g., neomd 66 + safe_name = sanitize_account_name(account_name) 67 + 68 + # Get the parent of config_dir (the .config directory) 69 + config_parent = os.path.dirname(config_dir) # e.g., ~/.config 70 + token_path = os.path.join(config_parent, cache_dir_name, 'tokens', f'{safe_name}.json') 71 + 72 + # Try alternate location (if running from different config) 73 + if not os.path.exists(token_path): 74 + home = os.path.expanduser('~') 75 + token_path = os.path.join(home, '.config', cache_dir_name, 'tokens', f'{safe_name}.json') 76 + 77 + if not os.path.exists(token_path): 78 + print(f'Error: Token file not found at {token_path}') 79 + print('Run neomd first to authenticate via OAuth2') 80 + sys.exit(1) 81 + 82 + with open(token_path) as f: 83 + token_data = json.load(f) 84 + 85 + # Check expiry 86 + expiry_str = token_data.get('expiry', '') 87 + if expiry_str: 88 + try: 89 + # Parse ISO format with or without Z 90 + expiry_str = expiry_str.replace('Z', '+00:00') 91 + expiry = datetime.fromisoformat(expiry_str) 92 + now = datetime.now(timezone.utc) 93 + if expiry < now: 94 + print(f'Error: Token expired at {expiry}') 95 + print('Run neomd to refresh the OAuth2 token') 96 + sys.exit(1) 97 + except ValueError as e: 98 + print(f'Warning: Could not parse token expiry: {e}') 99 + 100 + return token_data.get('access_token') 101 + 102 + # Load token if config provided 103 + token = None 104 + if config_path: 105 + try: 106 + token = get_token_from_config(config_path, user) 107 + if token: 108 + print(f'Using OAuth2 token from config: {config_path}') 109 + except ImportError as e: 110 + print(f'Error: {e}') 111 + print('Note: OAuth2 support requires Python 3.11+ for tomllib') 112 + sys.exit(1) 113 + except Exception as e: 114 + print(f'Error reading config: {e}') 115 + sys.exit(1) 116 + 117 + if not token and not pw: 118 + print('Error: Either IMAP_PASS or CONFIG (for OAuth2) must be provided') 119 + sys.exit(1) 21 120 22 121 print(f'Benchmarking {host}:{port} as {user}...') 23 122 print() ··· 32 131 greeting = s.recv(4096) 33 132 tls_ms = (time.time() - start) * 1000 34 133 35 - # LOGIN 134 + # Authenticate (LOGIN or XOAUTH2) 36 135 start = time.time() 37 - s.send(f'a1 LOGIN {user} {pw}\r\n'.encode()) 136 + if token: 137 + # XOAUTH2 SASL: base64(user={user}\x01auth=Bearer {token}\x01\x01) 138 + auth_str = f'user={user}\x01auth=Bearer {token}\x01\x01' 139 + auth_b64 = base64.b64encode(auth_str.encode()).decode() 140 + s.send(f'a1 AUTHENTICATE XOAUTH2 {auth_b64}\r\n'.encode()) 141 + else: 142 + s.send(f'a1 LOGIN {user} {pw}\r\n'.encode()) 143 + 38 144 resp = b'' 39 145 while b'a1 ' not in resp: 40 146 resp += s.recv(4096) 147 + 148 + # Check for authentication failure 149 + resp_str = resp.decode(errors='replace') 150 + if 'a1 NO' in resp_str or 'a1 BAD' in resp_str: 151 + print(f'Authentication failed: {resp_str.strip()}') 152 + sys.exit(1) 153 + 41 154 login_ms = (time.time() - start) * 1000 42 155 43 156 # SELECT INBOX ··· 89 202 90 203 total = login_ms + select_ms + search_ms + fetch_ms 91 204 print(f' TLS connect: {tls_ms:6.0f}ms') 92 - print(f' LOGIN: {login_ms:6.0f}ms') 205 + if token: 206 + print(f' XOAUTH2: {login_ms:6.0f}ms') 207 + else: 208 + print(f' LOGIN: {login_ms:6.0f}ms') 93 209 print(f' SELECT INBOX: {select_ms:6.0f}ms') 94 210 print(f' UID SEARCH: {search_ms:6.0f}ms') 95 211 n = min(10, len(uids))