feat: group private apps into andreknie-privat

This commit is contained in:
2026-07-25 15:47:01 +02:00
parent 4c7d48ae6d
commit 2e83750cb7
109 changed files with 0 additions and 0 deletions
@@ -0,0 +1,339 @@
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