clawd/tmp/imap_migrate.py

176 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""
Proton Bridge → Stalwart IMAP migration
"""
import imaplib, ssl, email, time, sys, traceback
from datetime import datetime
SRC_HOST = '127.0.0.1'; SRC_PORT = 1143
SRC_USER = 'tj@jongsma.me'; SRC_PASS = 'BlcMCKtNDfqv0cq1LmGR9g'
DST_HOST = 'mail.jongsma.me'; DST_PORT = 993
DST_USER = 'tj'; DST_PASS = 'TJ-Migrate-2026!'
SKIP_FOLDERS = {'All Mail', 'Starred', 'Labels/Sent Messages',
'Labels/Deleted Messages', 'Labels/Snoozed'}
SKIP_PREFIXES = ('Labels/',)
BATCH = 10 # smaller batch = more reliable with Proton Bridge
def src_ctx():
c = ssl.create_default_context()
c.check_hostname = False; c.verify_mode = ssl.CERT_NONE
return c
def connect_src():
s = imaplib.IMAP4(SRC_HOST, SRC_PORT)
s.starttls(src_ctx()); s.login(SRC_USER, SRC_PASS)
return s
def connect_dst():
s = imaplib.IMAP4_SSL(DST_HOST, DST_PORT, ssl_context=ssl.create_default_context())
s.login(DST_USER, DST_PASS)
return s
def ensure_dst_folder(dst, folder):
st, _ = dst.select(folder)
if st == 'OK': return
dst.create(folder)
dst.select(folder)
def get_dst_message_ids(dst, folder):
ids = set()
st, _ = dst.select(folder)
if st != 'OK': return ids
st2, data = dst.search(None, 'ALL')
if st2 != 'OK' or not data[0]: return ids
msgs = data[0].split()
for i in range(0, len(msgs), 200):
chunk = b','.join(msgs[i:i+200])
try:
st3, hd = dst.fetch(chunk, '(BODY[HEADER.FIELDS (MESSAGE-ID)])')
for item in hd:
if isinstance(item, tuple):
raw = item[1].decode('utf-8', errors='replace')
for line in raw.splitlines():
if line.lower().startswith('message-id:'):
ids.add(line.split(':',1)[1].strip())
except: pass
return ids
def sync_folder(src, dst, folder):
copied = skipped = errors = 0
src_q = f'"{folder}"' if ' ' in folder or '/' in folder else folder
st, _ = src.select(src_q, readonly=True)
if st != 'OK':
print(f' ✗ Cannot select src: {folder}'); return 0, 0, 1
st2, data = src.search(None, 'ALL')
if st2 != 'OK' or not data[0]: return 0, 0, 0
msg_ids = data[0].split()
src_count = len(msg_ids)
ensure_dst_folder(dst, folder)
existing_ids = get_dst_message_ids(dst, folder)
dst.select(folder)
# Re-select source after dst operations (connection may have gone idle)
src.select(src_q, readonly=True)
for i in range(0, len(msg_ids), BATCH):
chunk = msg_ids[i:i+BATCH]
chunk_str = b','.join(chunk)
try:
st3, msg_data = src.fetch(chunk_str, '(INTERNALDATE FLAGS RFC822)')
except Exception as e:
errors += len(chunk)
# Reconnect source
try: src = connect_src(); src.select(src_q, readonly=True)
except: pass
continue
for item in msg_data:
if not isinstance(item, tuple): continue
try:
meta = item[0].decode('utf-8', errors='replace')
raw_msg = item[1]
# Stalwart v0.15.5 rejects \Flag syntax in APPEND — skip flags
append_flags = None
# Duplicate check by Message-ID
parsed = email.message_from_bytes(raw_msg)
mid = parsed.get('Message-ID', '').strip()
if mid and mid in existing_ids:
skipped += 1; continue
# Append
dst.append(folder, append_flags, None, raw_msg)
if mid: existing_ids.add(mid)
copied += 1
except Exception as e:
errors += 1
if errors <= 3:
sys.stdout.write(f'\n ERR: {e}\n')
sys.stdout.flush()
done = min(i+BATCH, src_count)
sys.stdout.write(f'\r {done}/{src_count} | ✓{copied}{skipped}{errors} ')
sys.stdout.flush()
print(f'\r {src_count} src | ✓{copied}{skipped}{errors} ')
return copied, skipped, errors
def main():
print('=== Proton → Stalwart Migration ===')
print(f'Started: {datetime.now():%Y-%m-%d %H:%M:%S}')
src = connect_src(); dst = connect_dst()
_, folders_raw = src.list()
folders = []
for f in folders_raw:
d = f.decode()
if 'Noselect' in d: continue
parts = d.split('" "')
if len(parts) < 2: continue
folder = parts[-1].strip().strip('"')
if folder in SKIP_FOLDERS: continue
if any(folder.startswith(p) for p in SKIP_PREFIXES): continue
folders.append(folder)
print(f'Syncing {len(folders)} folders\n')
results = []; t0 = time.time()
total_c = total_s = total_e = 0
for folder in folders:
print(f'[{folder}]')
try:
c, s, e = sync_folder(src, dst, folder)
except Exception as ex:
print(f' ✗ Fatal: {ex}')
c, s, e = 0, 0, 1
try: src = connect_src(); dst = connect_dst()
except: pass
results.append((folder, c, s, e))
total_c += c; total_s += s; total_e += e
elapsed = time.time() - t0
print('\n' + '='*55)
print('MIGRATION REPORT')
print('='*55)
print(f'Completed: {datetime.now():%Y-%m-%d %H:%M:%S}')
print(f'Duration: {int(elapsed//60)}m {int(elapsed%60)}s')
print(f'Folders: {len(folders)}')
print(f'Copied: {total_c}')
print(f'Skipped: {total_s} (duplicates)')
print(f'Errors: {total_e}')
print()
print(f' {"Folder":<45} {"Copied":>7} {"Dup":>5} {"Err":>5}')
print(f' {"-"*45} {"-"*7} {"-"*5} {"-"*5}')
for folder, c, s, e in results:
mark = '' if e > 0 else ''
print(f' {folder:<45} {c:>7} {s:>5} {e:>5}{mark}')
src.logout(); dst.logout()
if __name__ == '__main__':
main()