import os import imaplib import email as email_lib import traceback from email.header import decode_header from app.db.session import SessionLocal from app.models import Task # IMAP label constants LABEL_TODO = "todo" LABEL_NO_TODO = "no_todo" LABEL_DONE = "done" def _get_imap_connection(): server = os.getenv("IMAP_SERVER") port = int(os.getenv("IMAP_PORT", "993")) username = os.getenv("EMAIL_USERNAME") password = os.getenv("EMAIL_PASSWORD") if not all([server, username, password]): raise ValueError("IMAP credentials missing. Set IMAP_SERVER, IMAP_PORT, EMAIL_USERNAME, EMAIL_PASSWORD.") conn = imaplib.IMAP4_SSL(server, port) conn.login(username, password) return conn def _decode_header_value(value) -> str: if value is None: return "" parts = decode_header(value) decoded = [] for part, charset in parts: if isinstance(part, bytes): decoded.append(part.decode(charset or "utf-8", errors="replace")) else: decoded.append(part) return " ".join(decoded) def _get_body_snippet(msg, max_chars: int = 300) -> str: if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == "text/plain" and part.get("Content-Disposition") is None: payload = part.get_payload(decode=True) if payload: return payload.decode(part.get_content_charset() or "utf-8", errors="replace")[:max_chars] else: payload = msg.get_payload(decode=True) if payload: return payload.decode(msg.get_content_charset() or "utf-8", errors="replace")[:max_chars] return "" def _apply_label(conn, uid_bytes: bytes, label: str): try: conn.uid("STORE", uid_bytes, "+FLAGS", f"({label})") except Exception as e: print(f"Warning: could not apply label '{label}': {e}") def _remove_label(conn, uid_bytes: bytes, label: str): try: conn.uid("STORE", uid_bytes, "-FLAGS", f"({label})") except Exception as e: print(f"Warning: could not remove label '{label}': {e}") def _archive_email(conn, uid_bytes: bytes): """Move email to archive folder. Uses IONOS year-based Archiv folders.""" from datetime import datetime current_year = str(datetime.now().year) archive_folders = [ f"Archiv/{current_year}", # IONOS: "Archiv/2026" "Archiv", "Archive", "INBOX.Archive", ] for folder in archive_folders: try: result = conn.uid("COPY", uid_bytes, folder) if result[0] == "OK": conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)") conn.expunge() print(f"Email archived to: {folder}") return True except Exception: continue print(f"Could not archive email. Tried: {archive_folders}") return False def _delete_email(conn, uid_bytes: bytes): """Permanently delete email (move to Papierkorb/Trash).""" trash_folders = [ "Papierkorb", # IONOS German "Trash", "INBOX.Trash", "Deleted Items", ] for folder in trash_folders: try: result = conn.uid("COPY", uid_bytes, folder) if result[0] == "OK": conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)") conn.expunge() print(f"Email moved to trash: {folder}") return True except Exception: continue # Fallback: just mark as deleted in INBOX try: conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)") conn.expunge() print("Email deleted from INBOX (no trash folder found)") return True except Exception as e: print(f"Could not delete email: {e}") return False def sync_emails(): """Fetch untracked INBOX emails and create tasks.""" try: conn = _get_imap_connection() except ValueError as e: print(f"IMAP not configured: {e}") return True except Exception as e: print(f"IMAP connection failed: {e}") traceback.print_exc() return False try: conn.select("INBOX") # Try keyword-based search, fall back to UNSEEN try: status, data = conn.uid( "SEARCH", None, f"UNKEYWORD {LABEL_TODO} UNKEYWORD {LABEL_NO_TODO} UNKEYWORD {LABEL_DONE}" ) except Exception: print("UNKEYWORD not supported, falling back to UNSEEN") status, data = conn.uid("SEARCH", None, "UNSEEN") if status != "OK" or not data[0]: return True # Nothing to process uid_list = data[0].split() db = SessionLocal() for uid_bytes in uid_list: try: _, msg_data = conn.uid("FETCH", uid_bytes, "(RFC822)") if not msg_data or not msg_data[0]: continue raw = msg_data[0][1] msg = email_lib.message_from_bytes(raw) message_id = msg.get("Message-ID", "").strip() if not message_id: continue title = _decode_header_value(msg.get("Subject", "(No Subject)")) body_snippet = _get_body_snippet(msg) # Parse email send date from email.utils import parsedate_to_datetime email_date = None try: date_str = msg.get("Date") if date_str: email_date = parsedate_to_datetime(date_str) except Exception: pass existing = db.query(Task).filter(Task.source_id == message_id).first() if existing: _apply_label(conn, uid_bytes, LABEL_TODO) continue max_pos = db.query(Task).filter(Task.status == "open").count() db.add(Task( title=title[:200], description=body_snippet[:2000], priority=4, position=max_pos, status="open", origin="dhive_email", source_id=message_id, source_url=os.getenv("EMAIL_WEBMAIL_URL", ""), created_at=email_date, )) _apply_label(conn, uid_bytes, LABEL_TODO) except Exception as e: print(f"Error processing email UID {uid_bytes}: {e}") # Cleanup: dismiss tasks whose emails are no longer in INBOX open_email_tasks = db.query(Task).filter( Task.origin == "dhive_email", Task.status == "open", Task.source_id != None ).all() dismissed_count = 0 for task in open_email_tasks: try: # Search by Message-ID header status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{task.source_id}"') if status == "OK" and data[0]: # Email still in INBOX — keep task open continue # Also try without quotes (some servers are picky) status2, data2 = conn.uid("SEARCH", None, f'HEADER Message-ID {task.source_id}') if status2 == "OK" and data2[0]: continue # Email not found in INBOX — dismiss task.status = "dismissed" dismissed_count += 1 except Exception as e: print(f" Cleanup check failed for {task.source_id[:30]}: {e}") if dismissed_count: print(f" Auto-dismissed {dismissed_count} tasks (emails no longer in INBOX)") db.commit() db.close() return True except Exception as e: print(f"Email sync error: {e}") traceback.print_exc() return False finally: try: conn.logout() except Exception: pass def mark_email_done_and_archive(source_id: str) -> bool: """Find an email by Message-ID, mark done, and archive it.""" try: conn = _get_imap_connection() except Exception as e: print(f"IMAP connection failed: {e}") return False try: conn.select("INBOX") # Try multiple search approaches uid_bytes = None # Approach 1: HEADER search with quotes status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{source_id}"') if status == "OK" and data[0]: uid_bytes = data[0].split()[0] # Approach 2: Without CHARSET if not uid_bytes: status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}') if status == "OK" and data[0]: uid_bytes = data[0].split()[0] # Approach 3: Search ALL and match manually (slow but reliable) if not uid_bytes: print(f" Header search failed, trying full scan for: {source_id[:40]}...") status, data = conn.uid("SEARCH", None, "ALL") if status == "OK" and data[0]: for uid in data[0].split()[-100:]: # Check last 100 emails try: _, msg_data = conn.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (Message-ID)])") if msg_data and msg_data[0] and source_id.encode() in msg_data[0][1]: uid_bytes = uid break except Exception: continue if not uid_bytes: print(f" Email not found in INBOX: {source_id[:50]}") return False print(f" Found email UID: {uid_bytes}") _remove_label(conn, uid_bytes, LABEL_TODO) _apply_label(conn, uid_bytes, LABEL_DONE) result = _archive_email(conn, uid_bytes) return result except Exception as e: print(f"Error archiving email {source_id[:50]}: {e}") traceback.print_exc() return False finally: try: conn.logout() except Exception: pass def delete_email_from_server(source_id: str) -> bool: """Find an email by Message-ID and permanently delete it (move to Trash).""" try: conn = _get_imap_connection() except Exception as e: print(f"IMAP connection failed: {e}") return False try: conn.select("INBOX") # Try multiple search approaches uid_bytes = None status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{source_id}"') if status == "OK" and data[0]: uid_bytes = data[0].split()[0] if not uid_bytes: status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}') if status == "OK" and data[0]: uid_bytes = data[0].split()[0] if not uid_bytes: print(f" Email not found for delete: {source_id[:50]}") return False _delete_email(conn, uid_bytes) return True except Exception as e: print(f"Error deleting email {source_id[:50]}: {e}") return False finally: try: conn.logout() except Exception: pass