diff options
| author | Craig Jennings <c@cjennings.net> | 2026-02-22 23:20:56 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-02-22 23:20:56 -0600 |
| commit | 3a2445080c880544985f50fb0d916534698cc073 (patch) | |
| tree | 909f98edbbb940aafb95de02457d4d6f7db3cba4 /docs/scripts/maildir-flag-manager.py | |
| parent | 3595aa8a8122da543676717fb5825044eee99a9d (diff) | |
| download | archangel-3a2445080c880544985f50fb0d916534698cc073.tar.gz archangel-3a2445080c880544985f50fb0d916534698cc073.zip | |
chore: add docs/ to .gitignore and untrack personal files
docs/ contains session history, personal workflows, and private
protocols that shouldn't be in a public repository.
Diffstat (limited to 'docs/scripts/maildir-flag-manager.py')
| -rwxr-xr-x | docs/scripts/maildir-flag-manager.py | 345 |
1 files changed, 0 insertions, 345 deletions
diff --git a/docs/scripts/maildir-flag-manager.py b/docs/scripts/maildir-flag-manager.py deleted file mode 100755 index 9c4a59c..0000000 --- a/docs/scripts/maildir-flag-manager.py +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env python3 -"""Manage maildir flags (read, starred) across email accounts. - -Uses atomic os.rename() for flag operations directly on maildir files. -Safer and more reliable than shell-based approaches (zsh loses PATH in -while-read loops, piped mu move silently fails). - -Supports the same flag semantics as mu4e: maildir files in new/ are moved -to cur/ when the Seen flag is added, and flag changes are persisted to the -filesystem so mbsync picks them up on the next sync. - -Usage: - # Mark all unread INBOX emails as read - maildir-flag-manager.py mark-read - - # Mark specific emails as read (by path) - maildir-flag-manager.py mark-read /path/to/message1 /path/to/message2 - - # Mark all unread INBOX emails as read, then reindex mu - maildir-flag-manager.py mark-read --reindex - - # Star specific emails (by path) - maildir-flag-manager.py star /path/to/message1 /path/to/message2 - - # Star and mark read - maildir-flag-manager.py star --mark-read /path/to/message1 - - # Dry run — show what would change without modifying anything - maildir-flag-manager.py mark-read --dry-run -""" - -import argparse -import os -import shutil -import subprocess -import sys - - -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - -MAILDIR_ACCOUNTS = { - 'gmail': os.path.expanduser('~/.mail/gmail/INBOX'), - 'cmail': os.path.expanduser('~/.mail/cmail/Inbox'), -} - - -# --------------------------------------------------------------------------- -# Core flag operations -# --------------------------------------------------------------------------- - -def parse_maildir_flags(filename): - """Extract flags from a maildir filename. - - Maildir filenames follow the pattern: unique:2,FLAGS - where FLAGS is a sorted string of flag characters (e.g., "FS" for - Flagged+Seen). - - Returns (base, flags_string). If no flags section, returns (filename, ''). - """ - if ':2,' in filename: - base, flags = filename.rsplit(':2,', 1) - return base, flags - return filename, '' - - -def build_flagged_filename(filename, new_flags): - """Build a maildir filename with the given flags. - - Flags are always sorted alphabetically per maildir spec. - """ - base, _ = parse_maildir_flags(filename) - sorted_flags = ''.join(sorted(set(new_flags))) - return f"{base}:2,{sorted_flags}" - - -def rename_with_flag(file_path, flag, dry_run=False): - """Add a flag to a single maildir message file via atomic rename. - - Handles moving from new/ to cur/ when adding the Seen flag. - Returns True if the flag was added, False if already present. - """ - dirname = os.path.dirname(file_path) - filename = os.path.basename(file_path) - maildir_root = os.path.dirname(dirname) - subdir = os.path.basename(dirname) - - _, current_flags = parse_maildir_flags(filename) - - if flag in current_flags: - return False - - new_flags = current_flags + flag - new_filename = build_flagged_filename(filename, new_flags) - - # Messages with the Seen flag belong in cur/, not new/ - if 'S' in new_flags and subdir == 'new': - target_dir = os.path.join(maildir_root, 'cur') - else: - target_dir = dirname - - new_path = os.path.join(target_dir, new_filename) - - if dry_run: - return True - - os.rename(file_path, new_path) - return True - - -def process_maildir(maildir_path, flag, dry_run=False): - """Add a flag to all messages in a maildir that don't have it. - - Scans both new/ and cur/ subdirectories. - Returns (changed_count, skipped_count, error_count). - """ - if not os.path.isdir(maildir_path): - print(f" Skipping {maildir_path} (not found)", file=sys.stderr) - return 0, 0, 0 - - changed = 0 - skipped = 0 - errors = 0 - - for subdir in ('new', 'cur'): - subdir_path = os.path.join(maildir_path, subdir) - if not os.path.isdir(subdir_path): - continue - - for filename in os.listdir(subdir_path): - file_path = os.path.join(subdir_path, filename) - if not os.path.isfile(file_path): - continue - - try: - if rename_with_flag(file_path, flag, dry_run): - changed += 1 - else: - skipped += 1 - except Exception as e: - print(f" Error on {filename}: {e}", file=sys.stderr) - errors += 1 - - return changed, skipped, errors - - -def process_specific_files(paths, flag, dry_run=False): - """Add a flag to specific message files by path. - - Returns (changed_count, skipped_count, error_count). - """ - changed = 0 - skipped = 0 - errors = 0 - - for path in paths: - path = os.path.abspath(path) - if not os.path.isfile(path): - print(f" File not found: {path}", file=sys.stderr) - errors += 1 - continue - - # Verify file is inside a maildir (parent should be cur/ or new/) - parent_dir = os.path.basename(os.path.dirname(path)) - if parent_dir not in ('cur', 'new'): - print(f" Not in a maildir cur/ or new/ dir: {path}", - file=sys.stderr) - errors += 1 - continue - - try: - if rename_with_flag(path, flag, dry_run): - changed += 1 - else: - skipped += 1 - except Exception as e: - print(f" Error on {path}: {e}", file=sys.stderr) - errors += 1 - - return changed, skipped, errors - - -def reindex_mu(): - """Run mu index to update the database after flag changes.""" - mu_path = shutil.which('mu') - if not mu_path: - print("Warning: mu not found in PATH, skipping reindex", - file=sys.stderr) - return False - - try: - result = subprocess.run( - [mu_path, 'index'], - capture_output=True, text=True, timeout=120 - ) - if result.returncode == 0: - print("mu index: database updated") - return True - else: - print(f"mu index failed: {result.stderr}", file=sys.stderr) - return False - except subprocess.TimeoutExpired: - print("mu index timed out after 120s", file=sys.stderr) - return False - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - -def cmd_mark_read(args): - """Mark emails as read (add Seen flag).""" - flag = 'S' - action = "Marking as read" - if args.dry_run: - action = "Would mark as read" - - total_changed = 0 - total_skipped = 0 - total_errors = 0 - - if args.paths: - print(f"{action}: {len(args.paths)} specific message(s)") - c, s, e = process_specific_files(args.paths, flag, args.dry_run) - total_changed += c - total_skipped += s - total_errors += e - else: - for name, maildir_path in MAILDIR_ACCOUNTS.items(): - print(f"{action} in {name} ({maildir_path})") - c, s, e = process_maildir(maildir_path, flag, args.dry_run) - total_changed += c - total_skipped += s - total_errors += e - if c > 0: - print(f" {c} message(s) marked as read") - if s > 0: - print(f" {s} already read") - - print(f"\nTotal: {total_changed} changed, {total_skipped} already set, " - f"{total_errors} errors") - - if args.reindex and not args.dry_run and total_changed > 0: - reindex_mu() - - return 0 if total_errors == 0 else 1 - - -def cmd_star(args): - """Star/flag emails (add Flagged flag).""" - flag = 'F' - action = "Starring" - if args.dry_run: - action = "Would star" - - if not args.paths: - print("Error: star requires specific message paths", file=sys.stderr) - return 1 - - print(f"{action}: {len(args.paths)} message(s)") - total_changed = 0 - total_skipped = 0 - total_errors = 0 - - c, s, e = process_specific_files(args.paths, flag, args.dry_run) - total_changed += c - total_skipped += s - total_errors += e - - # Also mark as read if requested - if args.mark_read: - print("Also marking as read...") - c2, _, e2 = process_specific_files(args.paths, 'S', args.dry_run) - total_changed += c2 - total_errors += e2 - - print(f"\nTotal: {total_changed} flag(s) changed, {total_skipped} already set, " - f"{total_errors} errors") - - if args.reindex and not args.dry_run and total_changed > 0: - reindex_mu() - - return 0 if total_errors == 0 else 1 - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - -def main(): - parser = argparse.ArgumentParser( - description="Manage maildir flags (read, starred) across email accounts." - ) - subparsers = parser.add_subparsers(dest='command', required=True) - - # mark-read - p_read = subparsers.add_parser( - 'mark-read', - help="Mark emails as read (add Seen flag)" - ) - p_read.add_argument( - 'paths', nargs='*', - help="Specific message file paths. If omitted, marks all unread " - "messages in configured INBOX maildirs." - ) - p_read.add_argument( - '--reindex', action='store_true', - help="Run mu index after changing flags" - ) - p_read.add_argument( - '--dry-run', action='store_true', - help="Show what would change without modifying anything" - ) - p_read.set_defaults(func=cmd_mark_read) - - # star - p_star = subparsers.add_parser( - 'star', - help="Star/flag emails (add Flagged flag)" - ) - p_star.add_argument( - 'paths', nargs='+', - help="Message file paths to star" - ) - p_star.add_argument( - '--mark-read', action='store_true', - help="Also mark starred messages as read" - ) - p_star.add_argument( - '--reindex', action='store_true', - help="Run mu index after changing flags" - ) - p_star.add_argument( - '--dry-run', action='store_true', - help="Show what would change without modifying anything" - ) - p_star.set_defaults(func=cmd_star) - - args = parser.parse_args() - sys.exit(args.func(args)) - - -if __name__ == '__main__': - main() |
