"""Gmail IMAP sync — second email source for task ingestion. Requirements: - Enable IMAP in Gmail settings (Settings → See all settings → Forwarding and POP/IMAP) - Generate an App Password at https://myaccount.google.com/apppasswords (requires 2FA to be enabled on your Google account) - Set GMAIL_USERNAME and GMAIL_PASSWORD env vars """ import os import imaplib import email as email_lib from email.header import decode_header import traceback from app.db.session import SessionLocal from app.models import Task def _get_gmail_connection(): server = os.getenv("GMAIL_IMAP_SERVER", "imap.gmail.com") port = int(os.getenv("GMAIL_IMAP_PORT", "993")) username = os.getenv("GMAIL_USERNAME", "") password = os.getenv("GMAIL_PASSWORD", "") if not username or not password: return None # Not configured, skip silently 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 sync_gmail(): """Fetch unread emails from Gmail inbox and create tasks. Returns True on success (or if not configured), False on error. """ try: conn = _get_gmail_connection() except imaplib.IMAP4.error as e: print(f"Gmail IMAP auth failed: {e}") print("Make sure you're using an App Password (not your regular Google password).") print("Generate one at: https://myaccount.google.com/apppasswords") return False except Exception as e: print(f"Gmail IMAP connection failed: {e}") traceback.print_exc() return False if conn is None: # Not configured — skip silently return True try: conn.select("INBOX") # Search for unread emails status, data = conn.uid("SEARCH", None, "UNSEEN") if status != "OK" or not data[0]: conn.logout() return True # Nothing to process uid_list = data[0].split() db = SessionLocal() synced_count = 0 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)")) sender = _decode_header_value(msg.get("From", "")) 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 # Skip if already tracked (including dismissed) existing = db.query(Task).filter(Task.source_id == message_id).first() if existing: continue # Limit title length title = title[:200] if title else "(No Subject)" description = f"From: {sender}\n\n{body_snippet}" if sender else body_snippet max_pos = db.query(Task).filter(Task.status == "open").count() db.add(Task( title=title, description=description[:2000], # Limit description length priority=4, position=max_pos, status="open", origin="gmail", source_id=message_id, source_url="https://mail.google.com/mail/", created_at=email_date, )) synced_count += 1 except Exception as e: print(f"Error processing Gmail UID {uid_bytes}: {e}") db.commit() db.close() print(f"Gmail sync: {synced_count} new tasks from {len(uid_list)} unread emails") except Exception as e: print(f"Gmail sync error: {e}") traceback.print_exc() return False finally: try: conn.logout() except Exception: pass return True def gmail_archive_email(source_id: str) -> bool: """Archive a Gmail email (remove from Inbox, keep in All Mail).""" try: conn = _get_gmail_connection() except Exception as e: print(f"Gmail connection failed: {e}") return False if conn is None: return False try: conn.select("INBOX") # Find the email uid_bytes = _find_gmail_email(conn, source_id) if not uid_bytes: print(f" Gmail: email not found: {source_id[:40]}") return False # Gmail archive = move to All Mail (remove INBOX label) # In IMAP terms: COPY to [Gmail]/All Mail, then delete from INBOX try: conn.uid("COPY", uid_bytes, "[Gmail]/All Mail") except Exception: pass # Might already be in All Mail conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)") conn.expunge() print(f" Gmail: archived email") return True except Exception as e: print(f"Gmail archive error: {e}") return False finally: try: conn.logout() except Exception: pass def gmail_delete_email(source_id: str) -> bool: """Delete a Gmail email (move to Trash).""" try: conn = _get_gmail_connection() except Exception as e: print(f"Gmail connection failed: {e}") return False if conn is None: return False try: conn.select("INBOX") uid_bytes = _find_gmail_email(conn, source_id) if not uid_bytes: print(f" Gmail: email not found for delete: {source_id[:40]}") return False # Move to Gmail Trash conn.uid("COPY", uid_bytes, "[Gmail]/Trash") conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)") conn.expunge() print(f" Gmail: deleted email") return True except Exception as e: print(f"Gmail delete error: {e}") return False finally: try: conn.logout() except Exception: pass def _find_gmail_email(conn, source_id: str): """Find a Gmail email by Message-ID. Returns UID bytes or None.""" # Try HEADER search status, data = conn.uid("SEARCH", None, f'HEADER Message-ID "{source_id}"') if status == "OK" and data[0]: return data[0].split()[0] # Try without quotes status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}') if status == "OK" and data[0]: return data[0].split()[0] return None def gmail_cleanup_dismissed(db_session=None): """Check if Gmail tasks are still in INBOX, dismiss if not.""" try: conn = _get_gmail_connection() except Exception: return if conn is None: return try: conn.select("INBOX") db = db_session or SessionLocal() open_gmail_tasks = db.query(Task).filter( Task.origin == "gmail", Task.status == "open", Task.source_id != None ).all() dismissed = 0 for task in open_gmail_tasks: uid = _find_gmail_email(conn, task.source_id) if not uid: task.status = "dismissed" dismissed += 1 if dismissed: db.commit() print(f" Gmail: auto-dismissed {dismissed} tasks") if not db_session: db.close() except Exception as e: print(f"Gmail cleanup error: {e}") finally: try: conn.logout() except Exception: pass