cmoss3 commited on
Commit
4c83ab6
·
0 Parent(s):

initial commit

Browse files
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ build-essential \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY entrypoint.sh /entrypoint.sh
13
+ RUN chmod +x /entrypoint.sh
14
+
15
+ COPY app/ ./app/
16
+
17
+ RUN mkdir -p /data /emails /certs
18
+
19
+ EXPOSE 8443
20
+
21
+ ENTRYPOINT ["/entrypoint.sh"]
app/__init__.py ADDED
File without changes
app/auth.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import msal
2
+
3
+ from app.config import Settings
4
+
5
+
6
+ class GraphAuthProvider:
7
+ _SCOPE = ["https://graph.microsoft.com/.default"]
8
+
9
+ def __init__(self, settings: Settings) -> None:
10
+ self._app = msal.ConfidentialClientApplication(
11
+ client_id=settings.azure_client_id,
12
+ authority=f"https://login.microsoftonline.com/{settings.azure_tenant_id}",
13
+ client_credential=settings.azure_client_secret.get_secret_value(),
14
+ )
15
+
16
+ def get_access_token(self) -> str:
17
+ result = self._app.acquire_token_silent(self._SCOPE, account=None)
18
+ if not result:
19
+ result = self._app.acquire_token_for_client(scopes=self._SCOPE)
20
+ if "access_token" not in result:
21
+ raise RuntimeError(
22
+ f"MSAL token error: {result.get('error_description', result.get('error'))}"
23
+ )
24
+ return result["access_token"]
app/config.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from pydantic import HttpUrl, SecretStr
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ azure_tenant_id: str
9
+ azure_client_id: str
10
+ azure_client_secret: SecretStr
11
+
12
+ mail_folder_name: str
13
+ notification_url: HttpUrl
14
+ subscription_expiry_minutes: int = 4230
15
+
16
+ host: str = "0.0.0.0"
17
+ port: int = 8443
18
+ tls_cert_file: Path
19
+ tls_key_file: Path
20
+
21
+ database_path: Path = Path("/data/rcmemail.db")
22
+ email_storage_path: Path = Path("/emails")
23
+
24
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
25
+
26
+
27
+ settings = Settings()
app/database.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from contextlib import asynccontextmanager
3
+ from pathlib import Path
4
+ from typing import AsyncGenerator
5
+
6
+ import aiosqlite
7
+
8
+ _DDL = """
9
+ PRAGMA journal_mode=WAL;
10
+ PRAGMA foreign_keys=ON;
11
+
12
+ CREATE TABLE IF NOT EXISTS subscriptions (
13
+ id TEXT PRIMARY KEY,
14
+ folder_id TEXT NOT NULL,
15
+ folder_name TEXT NOT NULL,
16
+ expiry_datetime TEXT NOT NULL,
17
+ notification_url TEXT NOT NULL,
18
+ client_state TEXT NOT NULL,
19
+ is_active INTEGER NOT NULL DEFAULT 1,
20
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
21
+ last_renewed_at TEXT
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS notifications (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ received_at TEXT NOT NULL DEFAULT (datetime('now')),
27
+ subscription_id TEXT,
28
+ change_type TEXT,
29
+ resource TEXT,
30
+ message_id TEXT,
31
+ processing_status TEXT NOT NULL DEFAULT 'pending',
32
+ error_message TEXT,
33
+ storage_path TEXT
34
+ );
35
+ """
36
+
37
+
38
+ async def init_db(db_path: Path) -> None:
39
+ db_path.parent.mkdir(parents=True, exist_ok=True)
40
+ async with aiosqlite.connect(db_path) as conn:
41
+ await conn.executescript(_DDL)
42
+ await conn.commit()
43
+
44
+
45
+ @asynccontextmanager
46
+ async def get_db(db_path: Path) -> AsyncGenerator[aiosqlite.Connection, None]:
47
+ async with aiosqlite.connect(db_path) as conn:
48
+ conn.row_factory = aiosqlite.Row
49
+ await conn.execute("PRAGMA journal_mode=WAL")
50
+ await conn.execute("PRAGMA foreign_keys=ON")
51
+ yield conn
52
+
53
+
54
+ async def upsert_subscription(
55
+ conn: aiosqlite.Connection,
56
+ sub_id: str,
57
+ folder_id: str,
58
+ folder_name: str,
59
+ expiry_datetime: str,
60
+ notification_url: str,
61
+ client_state: str,
62
+ ) -> None:
63
+ await conn.execute(
64
+ """
65
+ INSERT INTO subscriptions (id, folder_id, folder_name, expiry_datetime,
66
+ notification_url, client_state, is_active)
67
+ VALUES (?, ?, ?, ?, ?, ?, 1)
68
+ ON CONFLICT(id) DO UPDATE SET
69
+ expiry_datetime = excluded.expiry_datetime,
70
+ last_renewed_at = datetime('now'),
71
+ is_active = 1
72
+ """,
73
+ (sub_id, folder_id, folder_name, expiry_datetime, notification_url, client_state),
74
+ )
75
+ await conn.commit()
76
+
77
+
78
+ async def get_active_subscription(conn: aiosqlite.Connection) -> dict | None:
79
+ cursor = await conn.execute(
80
+ "SELECT * FROM subscriptions WHERE is_active = 1 ORDER BY created_at DESC LIMIT 1"
81
+ )
82
+ row = await cursor.fetchone()
83
+ return dict(row) if row else None
84
+
85
+
86
+ async def mark_subscription_inactive(conn: aiosqlite.Connection, sub_id: str) -> None:
87
+ await conn.execute(
88
+ "UPDATE subscriptions SET is_active = 0 WHERE id = ?", (sub_id,)
89
+ )
90
+ await conn.commit()
91
+
92
+
93
+ async def log_notification(
94
+ conn: aiosqlite.Connection,
95
+ subscription_id: str | None,
96
+ change_type: str | None,
97
+ resource: str | None,
98
+ message_id: str | None,
99
+ ) -> int:
100
+ cursor = await conn.execute(
101
+ """
102
+ INSERT INTO notifications (subscription_id, change_type, resource, message_id)
103
+ VALUES (?, ?, ?, ?)
104
+ """,
105
+ (subscription_id, change_type, resource, message_id),
106
+ )
107
+ await conn.commit()
108
+ return cursor.lastrowid
109
+
110
+
111
+ async def update_subscription_expiry(
112
+ conn: aiosqlite.Connection, sub_id: str, expiry_datetime: str
113
+ ) -> None:
114
+ await conn.execute(
115
+ """
116
+ UPDATE subscriptions
117
+ SET expiry_datetime = ?, last_renewed_at = datetime('now')
118
+ WHERE id = ?
119
+ """,
120
+ (expiry_datetime, sub_id),
121
+ )
122
+ await conn.commit()
123
+
124
+
125
+ async def update_notification_status(
126
+ conn: aiosqlite.Connection,
127
+ notif_id: int,
128
+ status: str,
129
+ error_message: str | None = None,
130
+ storage_path: str | None = None,
131
+ ) -> None:
132
+ await conn.execute(
133
+ """
134
+ UPDATE notifications
135
+ SET processing_status = ?, error_message = ?, storage_path = ?
136
+ WHERE id = ?
137
+ """,
138
+ (status, error_message, storage_path, notif_id),
139
+ )
140
+ await conn.commit()
141
+
142
+
143
+ if __name__ == "__main__":
144
+ import asyncio
145
+
146
+ if "--init" in sys.argv:
147
+ from app.config import settings
148
+
149
+ asyncio.run(init_db(settings.database_path))
150
+ print(f"Database initialized at {settings.database_path}")
app/email_processor.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ import aiosqlite
6
+
7
+ from app.database import update_notification_status
8
+ from app.graph_client import GraphClient
9
+ from app.storage import write_email
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _LARGE_ATTACHMENT_THRESHOLD = 3 * 1024 * 1024 # 3 MB
14
+
15
+
16
+ async def fetch_and_store(
17
+ client: GraphClient,
18
+ message_id: str,
19
+ notif_db_id: int,
20
+ storage_path: Path,
21
+ conn: aiosqlite.Connection,
22
+ ) -> Path:
23
+ try:
24
+ email_data = await client.get(
25
+ f"/me/messages/{message_id}",
26
+ params={"$expand": "attachments", "$select": (
27
+ "id,subject,from,toRecipients,ccRecipients,receivedDateTime,"
28
+ "body,uniqueBody,hasAttachments,attachments,webLink"
29
+ )},
30
+ )
31
+
32
+ attachment_bytes: list[tuple[str, str, bytes]] = []
33
+ for att in email_data.get("attachments", []):
34
+ if att.get("@odata.type") != "#microsoft.graph.fileAttachment":
35
+ continue
36
+ filename = att.get("name", "attachment")
37
+ content_type = att.get("contentType", "application/octet-stream")
38
+ size = att.get("size", 0)
39
+
40
+ if size > _LARGE_ATTACHMENT_THRESHOLD or not att.get("contentBytes"):
41
+ raw = await _fetch_large_attachment(client, message_id, att["id"])
42
+ else:
43
+ raw = base64.b64decode(att["contentBytes"])
44
+
45
+ attachment_bytes.append((filename, content_type, raw))
46
+
47
+ dest = await write_email(email_data, attachment_bytes, storage_path)
48
+ await update_notification_status(
49
+ conn, notif_db_id, "fetched", storage_path=str(dest)
50
+ )
51
+ logger.info("Stored email %s at %s", message_id, dest)
52
+ return dest
53
+
54
+ except Exception as exc:
55
+ logger.exception("Failed to process message %s", message_id)
56
+ await update_notification_status(conn, notif_db_id, "error", error_message=str(exc))
57
+ raise
58
+
59
+
60
+ async def _fetch_large_attachment(
61
+ client: GraphClient, message_id: str, attachment_id: str
62
+ ) -> bytes:
63
+ return await client.get_raw(f"/me/messages/{message_id}/attachments/{attachment_id}/$value")
app/folder_resolver.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.graph_client import GraphClient
2
+
3
+ _BASE = "https://graph.microsoft.com/v1.0"
4
+
5
+
6
+ async def resolve_folder_id(client: GraphClient, display_name: str) -> str:
7
+ path = "/me/mailFolders"
8
+ params: dict = {"$top": 100}
9
+
10
+ while path:
11
+ data = await client.get(path, params=params)
12
+ for folder in data.get("value", []):
13
+ if folder["displayName"].lower() == display_name.lower():
14
+ return folder["id"]
15
+ next_link: str | None = data.get("@odata.nextLink")
16
+ if next_link:
17
+ # nextLink is a full URL; strip the base so GraphClient can prefix it again.
18
+ path = next_link.removeprefix(_BASE)
19
+ params = {}
20
+ else:
21
+ break
22
+
23
+ raise ValueError(
24
+ f"Mail folder '{display_name}' not found. "
25
+ f"Verify it exists as a top-level folder in the monitored mailbox."
26
+ )
app/graph_client.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
+ import httpx
4
+
5
+ from app.auth import GraphAuthProvider
6
+
7
+ _BASE_URL = "https://graph.microsoft.com/v1.0"
8
+
9
+
10
+ class GraphClient:
11
+ def __init__(self, auth: GraphAuthProvider) -> None:
12
+ self._auth = auth
13
+ self._client = httpx.AsyncClient(timeout=30.0)
14
+
15
+ async def _headers(self) -> dict[str, str]:
16
+ token = await asyncio.to_thread(self._auth.get_access_token)
17
+ return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
18
+
19
+ async def get(self, path: str, params: dict | None = None) -> dict:
20
+ resp = await self._client.get(
21
+ f"{_BASE_URL}{path}", headers=await self._headers(), params=params
22
+ )
23
+ resp.raise_for_status()
24
+ return resp.json()
25
+
26
+ async def get_raw(self, path: str) -> bytes:
27
+ resp = await self._client.get(
28
+ f"{_BASE_URL}{path}", headers=await self._headers()
29
+ )
30
+ resp.raise_for_status()
31
+ return resp.content
32
+
33
+ async def post(self, path: str, body: dict) -> dict:
34
+ resp = await self._client.post(
35
+ f"{_BASE_URL}{path}", headers=await self._headers(), json=body
36
+ )
37
+ resp.raise_for_status()
38
+ return resp.json()
39
+
40
+ async def patch(self, path: str, body: dict) -> dict:
41
+ resp = await self._client.patch(
42
+ f"{_BASE_URL}{path}", headers=await self._headers(), json=body
43
+ )
44
+ resp.raise_for_status()
45
+ return resp.json()
46
+
47
+ async def delete(self, path: str) -> None:
48
+ resp = await self._client.delete(
49
+ f"{_BASE_URL}{path}", headers=await self._headers()
50
+ )
51
+ resp.raise_for_status()
52
+
53
+ async def aclose(self) -> None:
54
+ await self._client.aclose()
app/main.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ from contextlib import asynccontextmanager
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import AsyncGenerator
6
+
7
+ from fastapi import FastAPI
8
+
9
+ from app.auth import GraphAuthProvider
10
+ from app.config import settings
11
+ from app.database import get_active_subscription, get_db, init_db
12
+ from app.folder_resolver import resolve_folder_id
13
+ from app.graph_client import GraphClient
14
+ from app.scheduler import RenewalScheduler
15
+ from app.subscription import SubscriptionManager
16
+ from app.webhook import router as webhook_router
17
+
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ async def _deferred_subscription_setup(
26
+ app: FastAPI,
27
+ sub_manager: SubscriptionManager,
28
+ folder_id: str,
29
+ scheduler: RenewalScheduler,
30
+ ) -> None:
31
+ """
32
+ Runs as a background task after the server is live so that Graph's
33
+ validation-token POST can reach us during subscription registration.
34
+ """
35
+ try:
36
+ sub_id = await sub_manager.ensure_subscription(folder_id)
37
+ app.state.subscription_id = sub_id
38
+ scheduler.start(sub_id)
39
+ logger.info("Registered new subscription %s", sub_id)
40
+ except Exception:
41
+ logger.exception("Failed to register Graph subscription on startup")
42
+
43
+
44
+ @asynccontextmanager
45
+ async def lifespan(app: FastAPI) -> AsyncGenerator:
46
+ # --- Startup ---
47
+ await init_db(settings.database_path)
48
+
49
+ auth = GraphAuthProvider(settings)
50
+ client = GraphClient(auth)
51
+ app.state.client = client
52
+ app.state.settings = settings
53
+ app.state.subscription_id = None
54
+
55
+ folder_id = await resolve_folder_id(client, settings.mail_folder_name)
56
+ app.state.folder_id = folder_id
57
+ logger.info("Monitoring folder '%s' (id=%s)", settings.mail_folder_name, folder_id)
58
+
59
+ sub_manager = SubscriptionManager(client, settings, settings.database_path)
60
+ app.state.sub_manager = sub_manager
61
+
62
+ scheduler = RenewalScheduler(sub_manager, settings)
63
+ app.state.scheduler = scheduler
64
+
65
+ # If a valid subscription already exists in the DB we can start the
66
+ # scheduler immediately (no Graph network call needed).
67
+ async with get_db(settings.database_path) as conn:
68
+ existing = await get_active_subscription(conn)
69
+
70
+ needs_new_subscription = True
71
+ if existing:
72
+ expiry = datetime.fromisoformat(existing["expiry_datetime"].replace("Z", "+00:00"))
73
+ cutoff = datetime.now(timezone.utc) + timedelta(minutes=60)
74
+ if expiry > cutoff:
75
+ app.state.subscription_id = existing["id"]
76
+ scheduler.start(existing["id"])
77
+ logger.info("Reusing existing subscription %s", existing["id"])
78
+ needs_new_subscription = False
79
+
80
+ if needs_new_subscription:
81
+ # Schedule subscription creation as a background task that fires
82
+ # after yield — at that point uvicorn is accepting connections so
83
+ # Graph's validation-token POST will succeed.
84
+ asyncio.create_task(
85
+ _deferred_subscription_setup(app, sub_manager, folder_id, scheduler)
86
+ )
87
+
88
+ yield
89
+
90
+ # --- Shutdown ---
91
+ scheduler.shutdown()
92
+ await client.aclose()
93
+ logger.info("Shutdown complete")
94
+
95
+
96
+ app = FastAPI(title="RCMEmail", version="1.0.0", lifespan=lifespan)
97
+ app.include_router(webhook_router)
98
+
99
+
100
+ @app.get("/health")
101
+ async def health() -> dict:
102
+ return {
103
+ "status": "ok",
104
+ "folder": settings.mail_folder_name,
105
+ "folder_id": getattr(app.state, "folder_id", None),
106
+ "subscription_id": getattr(app.state, "subscription_id", None),
107
+ }
app/scheduler.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
4
+
5
+ from app.config import Settings
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class RenewalScheduler:
11
+ def __init__(self, subscription_manager, settings: Settings) -> None:
12
+ self._scheduler = AsyncIOScheduler()
13
+ self._manager = subscription_manager
14
+ self._settings = settings
15
+
16
+ def start(self, subscription_id: str) -> None:
17
+ interval_seconds = int(self._settings.subscription_expiry_minutes * 60 * 0.80)
18
+ self._scheduler.add_job(
19
+ self._renew_job,
20
+ trigger="interval",
21
+ seconds=interval_seconds,
22
+ id="subscription_renewal",
23
+ replace_existing=True,
24
+ args=[subscription_id],
25
+ )
26
+ self._scheduler.start()
27
+ logger.info(
28
+ "Renewal scheduler started; will renew subscription every %d seconds",
29
+ interval_seconds,
30
+ )
31
+
32
+ async def _renew_job(self, subscription_id: str) -> None:
33
+ logger.info("Renewing subscription %s", subscription_id)
34
+ try:
35
+ await self._manager.renew_subscription(subscription_id)
36
+ except Exception:
37
+ logger.warning(
38
+ "Renewal failed for %s; attempting to recreate subscription", subscription_id
39
+ )
40
+ try:
41
+ folder_id = await self._get_folder_id()
42
+ new_id = await self._manager.ensure_subscription(folder_id)
43
+ self._reschedule(new_id)
44
+ except Exception:
45
+ logger.exception("Failed to recreate subscription after renewal failure")
46
+
47
+ async def _get_folder_id(self) -> str:
48
+ from app.database import get_active_subscription, get_db
49
+ async with get_db(self._manager._db_path) as conn:
50
+ sub = await get_active_subscription(conn)
51
+ if sub:
52
+ return sub["folder_id"]
53
+ raise RuntimeError("No active subscription found to determine folder_id for recreation")
54
+
55
+ def _reschedule(self, new_subscription_id: str) -> None:
56
+ interval_seconds = int(self._settings.subscription_expiry_minutes * 60 * 0.80)
57
+ self._scheduler.reschedule_job(
58
+ "subscription_renewal",
59
+ trigger="interval",
60
+ seconds=interval_seconds,
61
+ args=[new_subscription_id],
62
+ )
63
+ logger.info("Rescheduled renewal for new subscription %s", new_subscription_id)
64
+
65
+ def shutdown(self) -> None:
66
+ if self._scheduler.running:
67
+ self._scheduler.shutdown(wait=False)
app/storage.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import uuid
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+
8
+ def _safe_filename(name: str) -> str:
9
+ return "".join(c if (c.isalnum() or c in "._- ") else "_" for c in name).strip()
10
+
11
+
12
+ def build_email_dir(base_path: Path, received_datetime: str, message_id: str) -> Path:
13
+ try:
14
+ dt = datetime.fromisoformat(received_datetime.replace("Z", "+00:00"))
15
+ except ValueError:
16
+ dt = datetime.now(timezone.utc)
17
+ date_folder = dt.strftime("%Y-%m-%d")
18
+ time_prefix = dt.strftime("%Y%m%d_%H%M%S")
19
+ folder_name = f"{time_prefix}_{message_id[:8]}"
20
+ return base_path / date_folder / folder_name
21
+
22
+
23
+ async def write_email(
24
+ email_data: dict,
25
+ attachment_bytes: list[tuple[str, str, bytes]],
26
+ base_path: Path,
27
+ ) -> Path:
28
+ received = email_data.get("receivedDateTime", "")
29
+ message_id = email_data.get("id", str(uuid.uuid4()))
30
+ final_dir = build_email_dir(base_path, received, message_id)
31
+
32
+ tmp_dir = base_path / ".tmp" / str(uuid.uuid4())
33
+ tmp_dir.mkdir(parents=True, exist_ok=True)
34
+
35
+ try:
36
+ body = email_data.get("body", {})
37
+ if body.get("contentType", "").lower() == "html":
38
+ (tmp_dir / "body.html").write_text(body.get("content", ""), encoding="utf-8")
39
+ else:
40
+ (tmp_dir / "body.txt").write_text(body.get("content", ""), encoding="utf-8")
41
+
42
+ if email_data.get("uniqueBody"):
43
+ unique = email_data["uniqueBody"]
44
+ if unique.get("contentType", "").lower() == "html":
45
+ (tmp_dir / "body_unique.html").write_text(
46
+ unique.get("content", ""), encoding="utf-8"
47
+ )
48
+ else:
49
+ (tmp_dir / "body_unique.txt").write_text(
50
+ unique.get("content", ""), encoding="utf-8"
51
+ )
52
+
53
+ stored_attachments = []
54
+ if attachment_bytes:
55
+ att_dir = tmp_dir / "attachments"
56
+ att_dir.mkdir()
57
+ for filename, content_type, raw in attachment_bytes:
58
+ safe_name = _safe_filename(filename) or "attachment"
59
+ dest = att_dir / safe_name
60
+ dest.write_bytes(raw)
61
+ stored_attachments.append(
62
+ {
63
+ "filename": safe_name,
64
+ "content_type": content_type,
65
+ "size_bytes": len(raw),
66
+ }
67
+ )
68
+
69
+ from_addr = email_data.get("from", {}).get("emailAddress", {})
70
+ metadata = {
71
+ "message_id": message_id,
72
+ "subject": email_data.get("subject", ""),
73
+ "from": {"name": from_addr.get("name", ""), "address": from_addr.get("address", "")},
74
+ "to": [
75
+ {"name": r.get("emailAddress", {}).get("name", ""),
76
+ "address": r.get("emailAddress", {}).get("address", "")}
77
+ for r in email_data.get("toRecipients", [])
78
+ ],
79
+ "cc": [
80
+ {"name": r.get("emailAddress", {}).get("name", ""),
81
+ "address": r.get("emailAddress", {}).get("address", "")}
82
+ for r in email_data.get("ccRecipients", [])
83
+ ],
84
+ "received_datetime": received,
85
+ "has_attachments": email_data.get("hasAttachments", False),
86
+ "attachment_count": len(stored_attachments),
87
+ "attachments": stored_attachments,
88
+ "graph_web_link": email_data.get("webLink", ""),
89
+ "stored_at": datetime.now(timezone.utc).isoformat(),
90
+ }
91
+ (tmp_dir / "metadata.json").write_text(
92
+ json.dumps(metadata, indent=2), encoding="utf-8"
93
+ )
94
+
95
+ final_dir.parent.mkdir(parents=True, exist_ok=True)
96
+ os.rename(tmp_dir, final_dir)
97
+ return final_dir
98
+
99
+ except Exception:
100
+ import shutil
101
+ shutil.rmtree(tmp_dir, ignore_errors=True)
102
+ raise
app/subscription.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import uuid
3
+ from datetime import datetime, timedelta, timezone
4
+ from pathlib import Path
5
+
6
+ import httpx
7
+
8
+ from app.config import Settings
9
+ from app.database import (
10
+ get_db,
11
+ get_active_subscription,
12
+ mark_subscription_inactive,
13
+ update_subscription_expiry,
14
+ upsert_subscription,
15
+ )
16
+ from app.graph_client import GraphClient
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _RENEWAL_MARGIN_MINUTES = 60
21
+
22
+
23
+ class SubscriptionManager:
24
+ def __init__(self, client: GraphClient, settings: Settings, db_path: Path) -> None:
25
+ self._client = client
26
+ self._settings = settings
27
+ self._db_path = db_path
28
+
29
+ async def ensure_subscription(self, folder_id: str) -> str:
30
+ async with get_db(self._db_path) as conn:
31
+ existing = await get_active_subscription(conn)
32
+
33
+ if existing:
34
+ expiry = datetime.fromisoformat(existing["expiry_datetime"].replace("Z", "+00:00"))
35
+ cutoff = datetime.now(timezone.utc) + timedelta(minutes=_RENEWAL_MARGIN_MINUTES)
36
+ if expiry > cutoff:
37
+ logger.info("Reusing existing subscription %s (expires %s)", existing["id"], expiry)
38
+ return existing["id"]
39
+ logger.info("Existing subscription %s is near expiry; creating new one", existing["id"])
40
+
41
+ return await self._create_subscription(folder_id)
42
+
43
+ async def _create_subscription(self, folder_id: str) -> str:
44
+ client_state = str(uuid.uuid4())
45
+ expiry = datetime.now(timezone.utc) + timedelta(
46
+ minutes=self._settings.subscription_expiry_minutes
47
+ )
48
+ expiry_str = expiry.strftime("%Y-%m-%dT%H:%M:%S.0000000Z")
49
+
50
+ body = {
51
+ "changeType": "created",
52
+ "notificationUrl": str(self._settings.notification_url),
53
+ "resource": f"me/mailFolders/{folder_id}/messages",
54
+ "expirationDateTime": expiry_str,
55
+ "clientState": client_state,
56
+ }
57
+
58
+ data = await self._client.post("/subscriptions", body)
59
+ sub_id = data["id"]
60
+ actual_expiry = data.get("expirationDateTime", expiry_str)
61
+
62
+ async with get_db(self._db_path) as conn:
63
+ await upsert_subscription(
64
+ conn,
65
+ sub_id=sub_id,
66
+ folder_id=folder_id,
67
+ folder_name=self._settings.mail_folder_name,
68
+ expiry_datetime=actual_expiry,
69
+ notification_url=str(self._settings.notification_url),
70
+ client_state=client_state,
71
+ )
72
+
73
+ logger.info("Created subscription %s expiring %s", sub_id, actual_expiry)
74
+ return sub_id
75
+
76
+ async def renew_subscription(self, sub_id: str) -> None:
77
+ expiry = datetime.now(timezone.utc) + timedelta(
78
+ minutes=self._settings.subscription_expiry_minutes
79
+ )
80
+ expiry_str = expiry.strftime("%Y-%m-%dT%H:%M:%S.0000000Z")
81
+
82
+ try:
83
+ data = await self._client.patch(
84
+ f"/subscriptions/{sub_id}",
85
+ {"expirationDateTime": expiry_str},
86
+ )
87
+ actual_expiry = data.get("expirationDateTime", expiry_str)
88
+ async with get_db(self._db_path) as conn:
89
+ await update_subscription_expiry(conn, sub_id, actual_expiry)
90
+ logger.info("Renewed subscription %s until %s", sub_id, actual_expiry)
91
+ except httpx.HTTPStatusError as exc:
92
+ if exc.response.status_code == 404:
93
+ logger.warning("Subscription %s not found on renewal; will recreate", sub_id)
94
+ async with get_db(self._db_path) as conn:
95
+ await mark_subscription_inactive(conn, sub_id)
96
+ raise
97
+ raise
98
+
99
+ async def get_client_state(self, sub_id: str) -> str | None:
100
+ async with get_db(self._db_path) as conn:
101
+ existing = await get_active_subscription(conn)
102
+ if existing and existing["id"] == sub_id:
103
+ return existing["client_state"]
104
+ return None
app/webhook.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ from fastapi import APIRouter, BackgroundTasks, Request, Response
4
+ from fastapi.responses import PlainTextResponse
5
+
6
+ from app.database import get_db, log_notification, update_notification_status
7
+ from app.email_processor import fetch_and_store
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ router = APIRouter(prefix="/webhook")
12
+
13
+
14
+ @router.post("/notify")
15
+ async def handle_notification(
16
+ request: Request,
17
+ background_tasks: BackgroundTasks,
18
+ validationToken: str | None = None,
19
+ ) -> Response:
20
+ if validationToken:
21
+ logger.info("Graph webhook validation request received")
22
+ return PlainTextResponse(content=validationToken, status_code=200)
23
+
24
+ try:
25
+ body = await request.json()
26
+ except Exception:
27
+ logger.warning("Webhook received non-JSON body")
28
+ return Response(status_code=400)
29
+
30
+ background_tasks.add_task(_process_notifications, request, body)
31
+ return Response(status_code=202)
32
+
33
+
34
+ async def _process_notifications(request: Request, body: dict) -> None:
35
+ client = request.app.state.client
36
+ settings = request.app.state.settings
37
+ db_path = settings.database_path
38
+ storage_path = settings.email_storage_path
39
+
40
+ for notification in body.get("value", []):
41
+ change_type = notification.get("changeType", "")
42
+ if change_type != "created":
43
+ logger.debug("Skipping notification with changeType=%s", change_type)
44
+ continue
45
+
46
+ sub_id = notification.get("subscriptionId")
47
+ resource = notification.get("resource", "")
48
+ incoming_state = notification.get("clientState")
49
+
50
+ resource_data = notification.get("resourceData", {})
51
+ message_id = resource_data.get("id") or _extract_message_id(resource)
52
+
53
+ if not message_id:
54
+ logger.warning("Could not extract message ID from notification: %s", notification)
55
+ continue
56
+
57
+ async with get_db(db_path) as conn:
58
+ expected_state = await _get_client_state(conn, sub_id)
59
+ if expected_state and incoming_state != expected_state:
60
+ logger.warning(
61
+ "clientState mismatch for subscription %s — discarding notification", sub_id
62
+ )
63
+ continue
64
+
65
+ notif_id = await log_notification(conn, sub_id, change_type, resource, message_id)
66
+
67
+ try:
68
+ async with get_db(db_path) as conn:
69
+ await fetch_and_store(client, message_id, notif_id, storage_path, conn)
70
+ except Exception:
71
+ logger.exception("Error processing message %s", message_id)
72
+
73
+
74
+ def _extract_message_id(resource: str) -> str | None:
75
+ parts = resource.split("/")
76
+ for i, part in enumerate(parts):
77
+ if part.lower() == "messages" and i + 1 < len(parts):
78
+ return parts[i + 1]
79
+ return None
80
+
81
+
82
+ async def _get_client_state(conn, sub_id: str) -> str | None:
83
+ from app.database import get_active_subscription
84
+ sub = await get_active_subscription(conn)
85
+ if sub and sub.get("id") == sub_id:
86
+ return sub.get("client_state")
87
+ return None
docker-compose.yml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ rcmemail:
3
+ build: .
4
+ env_file: .env
5
+ ports:
6
+ - "8443:8443"
7
+ volumes:
8
+ - ./certs:/certs:ro
9
+ - rcmemail-data:/data
10
+ - rcmemail-emails:/emails
11
+ restart: unless-stopped
12
+
13
+ volumes:
14
+ rcmemail-data:
15
+ rcmemail-emails:
entrypoint.sh ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ python -m app.database --init
5
+
6
+ exec uvicorn app.main:app \
7
+ --host "${HOST:-0.0.0.0}" \
8
+ --port "${PORT:-8443}" \
9
+ --ssl-certfile "${TLS_CERT_FILE}" \
10
+ --ssl-keyfile "${TLS_KEY_FILE}"
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.0
3
+ msal==1.31.0
4
+ httpx==0.27.0
5
+ pydantic-settings==2.3.0
6
+ apscheduler==3.10.4
7
+ aiosqlite==0.20.0