291 lines
8.8 KiB
Python
291 lines
8.8 KiB
Python
"""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
|