diff --git a/main.py b/main.py
index 1288b0e..d0498a7 100644
--- a/main.py
+++ b/main.py
@@ -384,12 +384,13 @@ def _load_config() -> dict:
def _clean_export_dir(export_dir: Path) -> None:
- """Delete all files in the export directory after a sync cycle."""
- for f in export_dir.iterdir():
- try:
- f.unlink()
- except OSError as exc:
- log.warning("Could not delete export file %s: %s", f.name, exc)
+ """Remove the mail_* export directory tree after a sync cycle."""
+ import shutil
+ try:
+ shutil.rmtree(export_dir)
+ log.debug("Removed export directory: %s", export_dir.name)
+ except OSError as exc:
+ log.warning("Could not remove export directory %s: %s", export_dir.name, exc)
# ---------------------------------------------------------------------------
diff --git a/modules/exporter.py b/modules/exporter.py
index 1114a11..8e2780b 100644
--- a/modules/exporter.py
+++ b/modules/exporter.py
@@ -1,19 +1,21 @@
"""Automate proton-mail-export-cli via pexpect.
-The Proton Export CLI interactive prompt sequence (observed order):
- 1. Email address
+The Proton Export CLI interactive prompt sequence (v1.0.6):
+ 1. Username
2. Password
3. Two-factor authentication code (only if 2FA is enabled)
4. Mailbox password (only if separate mailbox password is set)
+ 5. Operation (B)ackup/(R)estore — we always send 'B'
+ 6. "Do you wish to proceed?" — we always send 'Yes'
-After authentication the CLI exports all mail to the export directory.
-Each message produces two files:
- {messageID}.eml
- {messageID}.metadata.json
+The CLI creates the following directory structure under the base export dir:
+ //mail_/
+ .eml
+ .metadata.json
+ ...
-Because the exact prompt strings can vary between CLI versions and account
-configurations, each pattern is defined as a regex so minor wording differences
-are tolerated. If the CLI changes its prompt wording, update PROMPTS below.
+run_export() returns the innermost mail_* directory so callers can
+scan .eml files directly.
"""
from pathlib import Path
@@ -37,12 +39,14 @@ EXPORT_DIR = Path(__file__).parent.parent / "data" / "exports"
# ---------------------------------------------------------------------------
PROMPTS = {
- "email": r"[Ee]mail\s*(address)?[\s:>]+",
- "password": r"[Pp]assword[\s:>]+",
- "totp": r"[Tt]wo.factor|[Oo]ne.time|[Tt][Oo][Tt][Pp]|[Aa]uth.*code",
- "mailbox_password": r"[Mm]ailbox\s*[Pp]assword[\s:>]+",
- "done": r"[Ee]xport\s*(complete|finished|done)|[Ss]uccessfully\s*export",
- "error": r"[Ee]rror|[Ff]ailed|[Ii]nvalid",
+ "username": r"[Uu]sername\s*:",
+ "password": r"[Pp]assword\s*:",
+ "totp": r"[Ee]nter the code from your authenticator",
+ "mailbox_password": r"[Mm]ailbox [Pp]assword\s*:",
+ "operation": r"[Oo]peration\s*\(\(B\)ackup",
+ "proceed": r"[Dd]o you wish to proceed\?",
+ "starting": r"Starting Export",
+ "error": r"[Ee]rror|[Ff]ailed|[Ii]nvalid|[Uu]nexpected",
}
# Maximum time to wait for each prompt (seconds)
@@ -66,12 +70,15 @@ def run_export(
mailbox_password: str = "",
export_dir: Optional[Path] = None,
) -> Path:
- """Drive proton-mail-export-cli and return the export directory path.
+ """Drive proton-mail-export-cli and return the mail_* export directory.
+
+ The CLI creates //mail_/ — this function finds
+ and returns that innermost directory.
Raises ExportError on authentication failure or unexpected CLI output.
"""
- out_dir = export_dir or EXPORT_DIR
- out_dir.mkdir(parents=True, exist_ok=True)
+ base_dir = export_dir or EXPORT_DIR
+ base_dir.mkdir(parents=True, exist_ok=True)
log.info("Starting Proton Mail export...")
@@ -80,8 +87,8 @@ def run_export(
except Exception as exc:
raise ExportError(str(exc)) from exc
- cmd = [str(binary), "--export-dir", str(out_dir)]
- log.info("Starting Proton export CLI: %s", " ".join(cmd))
+ cmd = [str(binary), "--dir", str(base_dir)]
+ log.debug("Running: %s", " ".join(cmd))
child = pexpect.spawn(
cmd[0], cmd[1:], timeout=PROMPT_TIMEOUT, encoding="utf-8"
@@ -100,13 +107,17 @@ def run_export(
raise ExportError(
f"CLI exited with code {child.exitstatus}. Output: {output.strip()}"
) from exc
- # EOF after a successful export is normal
- log.info("CLI process finished (EOF).")
+ log.debug("CLI process finished (EOF).")
- log.info("Export complete. Files in: %s", out_dir)
- return out_dir
+ mail_dir = _find_mail_dir(base_dir, email)
+ log.info("Proton Mail export complete. Files in: %s", mail_dir)
+ return mail_dir
+# ---------------------------------------------------------------------------
+# Interactive prompt driver
+# ---------------------------------------------------------------------------
+
def _drive_cli(
child: pexpect.spawn,
email: str,
@@ -116,14 +127,16 @@ def _drive_cli(
) -> None:
"""Respond to each interactive prompt in sequence."""
patterns = [
- pexpect.TIMEOUT,
- pexpect.EOF,
- PROMPTS["email"],
- PROMPTS["password"],
- PROMPTS["totp"],
- PROMPTS["mailbox_password"],
- PROMPTS["done"],
- PROMPTS["error"],
+ pexpect.TIMEOUT, # 0
+ pexpect.EOF, # 1
+ PROMPTS["username"], # 2
+ PROMPTS["password"], # 3
+ PROMPTS["totp"], # 4
+ PROMPTS["mailbox_password"], # 5
+ PROMPTS["operation"], # 6
+ PROMPTS["proceed"], # 7
+ PROMPTS["starting"], # 8
+ PROMPTS["error"], # 9
]
totp_sent = False
@@ -138,13 +151,11 @@ def _drive_cli(
if idx == 1: # EOF — process exited
return
- if idx == 2: # email prompt
- log.debug("CLI requested email.")
+ if idx == 2: # Username
+ log.debug("CLI requested username.")
child.sendline(email)
- elif idx == 3: # password prompt
- # The CLI may show a password prompt for both the account password
- # and the mailbox password. We track which we've already sent.
+ elif idx == 3: # Password (account or mailbox)
if not mailbox_sent and mailbox_password and totp_sent:
log.debug("CLI requested mailbox password.")
child.sendline(mailbox_password)
@@ -153,21 +164,30 @@ def _drive_cli(
log.debug("CLI requested account password.")
child.sendline(password)
- elif idx == 4: # TOTP prompt
+ elif idx == 4: # TOTP
log.debug("CLI requested TOTP code.")
child.sendline(totp_code)
totp_sent = True
- elif idx == 5: # explicit mailbox password prompt
+ elif idx == 5: # Explicit mailbox password prompt
log.debug("CLI requested mailbox password (explicit prompt).")
child.sendline(mailbox_password or "")
mailbox_sent = True
- elif idx == 6: # export done
- log.info("CLI reported export complete.")
+ elif idx == 6: # Operation prompt
+ log.debug("CLI requested operation — sending 'B' for backup.")
+ child.sendline("B")
+
+ elif idx == 7: # "Do you wish to proceed?"
+ log.debug("CLI asked to confirm export path — sending 'Yes'.")
+ child.sendline("Yes")
+
+ elif idx == 8: # "Starting Export" — export is underway, wait for EOF
+ log.debug("CLI reported export started — waiting for completion.")
+ child.expect(pexpect.EOF, timeout=EXPORT_TIMEOUT)
return
- elif idx == 7: # error line
+ elif idx == 9: # Error line
snippet = (child.before or "").strip().splitlines()[-1]
raise ExportError(f"CLI reported an error: {snippet}")
@@ -176,6 +196,42 @@ def _drive_cli(
# Helpers
# ---------------------------------------------------------------------------
+def _find_mail_dir(base_dir: Path, email: str) -> Path:
+ """Locate the mail_* directory the CLI created under base_dir.
+
+ The CLI creates: base_dir//mail_/
+ The email folder name may differ in capitalisation from the supplied
+ address, so we match case-insensitively.
+ """
+ # Find the email-named subdirectory
+ email_dirs = [
+ d for d in base_dir.iterdir()
+ if d.is_dir() and d.name.lower() == email.lower()
+ ]
+ if not email_dirs:
+ # Fallback: take any subdirectory (there should be exactly one)
+ email_dirs = [d for d in base_dir.iterdir() if d.is_dir()]
+
+ if not email_dirs:
+ raise ExportError(
+ f"Export finished but no output directory found under {base_dir}."
+ )
+
+ email_dir = email_dirs[0]
+
+ mail_dirs = sorted(
+ [d for d in email_dir.iterdir() if d.is_dir() and d.name.startswith("mail_")],
+ key=lambda d: d.stat().st_mtime,
+ reverse=True,
+ )
+ if not mail_dirs:
+ raise ExportError(
+ f"Export finished but no mail_* directory found under {email_dir}."
+ )
+
+ return mail_dirs[0]
+
+
class _PexpectLogger:
"""Thin adapter so pexpect writes its read data to our logger at DEBUG."""