176 lines
5.8 KiB
Python
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()
|