This commit is contained in:
2026-06-04 11:40:45 +02:00
parent 9b12745e1d
commit de2145899d
375 changed files with 15343 additions and 0 deletions
+10
View File
@@ -5,6 +5,11 @@
"args": ["U:\\PythonProject\\Janssen\\mcp_mongo.py"], "args": ["U:\\PythonProject\\Janssen\\mcp_mongo.py"],
"cwd": "U:\\PythonProject\\Janssen" "cwd": "U:\\PythonProject\\Janssen"
}, },
"janssen-postgres": {
"command": "python",
"args": ["U:\\PythonProject\\Janssen\\mcp_postgres.py"],
"cwd": "U:\\PythonProject\\Janssen"
},
"jnjemails": { "jnjemails": {
"command": "python", "command": "python",
"args": ["U:\\PythonProject\\Janssen\\EmailsImport\\mcp_jnjemails.py"], "args": ["U:\\PythonProject\\Janssen\\EmailsImport\\mcp_jnjemails.py"],
@@ -19,6 +24,11 @@
"command": "U:\\janssen\\.venv\\Scripts\\python.exe", "command": "U:\\janssen\\.venv\\Scripts\\python.exe",
"args": ["U:\\janssen\\EmailsImport\\mcp_emaily.py"], "args": ["U:\\janssen\\EmailsImport\\mcp_emaily.py"],
"cwd": "U:\\janssen\\EmailsImport" "cwd": "U:\\janssen\\EmailsImport"
},
"owa": {
"command": "python",
"args": ["U:\\PythonProject\\Janssen\\Outlook\\mcp_owa_v1.3.py"],
"cwd": "U:\\PythonProject\\Janssen\\Outlook"
} }
} }
} }
+23
View File
@@ -0,0 +1,23 @@
"""Smoke test — spustí stejný launch jako mcp_owa, drží 8 s, zavře."""
import asyncio
from pathlib import Path
from playwright.async_api import async_playwright
PROFILE = Path(__file__).resolve().parent / "outlook_profile"
async def main():
pw = await async_playwright().start()
ctx = await pw.chromium.launch_persistent_context(
user_data_dir=str(PROFILE),
headless=False,
no_viewport=True,
args=["--disable-blink-features=AutomationControlled", "--start-maximized"],
)
page = ctx.pages[0] if ctx.pages else await ctx.new_page()
await page.goto("https://outlook.cloud.microsoft/mail/")
print("OK, page open:", page.url)
await asyncio.sleep(8)
await ctx.close()
await pw.stop()
asyncio.run(main())
+68
View File
@@ -0,0 +1,68 @@
# mcp_owa_v1.0
MCP server pro OWA (outlook.cloud.microsoft) — drží persistentní Playwright session a umožňuje claude-cowork:
- vyhledat email v MongoDB `OperativniEmailyJNJ.messages`,
- otevřít ho v OWA UI,
- udělat Forward, vepsat úvodní text na začátek těla,
- zavřít původní čtecí panel.
Odeslání forwardu **dělá uživatel ručně** v okně, které zůstává otevřené.
## Spuštění
Profil sdílí s `import_emails_to_mongo_v1.0.py` (`outlook_profile/`). Pokud profil neexistuje, spusť nejdřív `outlook_login_v1.0.py`.
Registrace v `.mcp.json` (stdio):
```json
"owa": {
"command": "U:\\PythonProject\\Janssen\\.venv\\Scripts\\python.exe",
"args": ["U:\\PythonProject\\Janssen\\Outlook\\mcp_owa_v1.0.py"]
}
```
## Tools
| Tool | Účel |
|------|------|
| `start_owa` | Spustí Playwright + otevře OWA |
| `stop_owa` | Zavře okno |
| `status` | Stav session |
| `find_emails(query, from_email, folder, since_iso, limit)` | Hledání v MongoDB |
| `find_last_email(from_email, folder)` | Nejnovější email |
| `open_email_by_subject(subject)` | Otevře v OWA přes search |
| `forward_current(body_prefix, subject_prefix)` | Ctrl+Shift+F, předvyplní |
| `write_at_top(text)` | Vepíše text na začátek body draftu |
| `set_recipients(to, cc)` | Doplní To/Cc |
| `close_reading_pane` | Escape (zavře čtecí panel) |
| `screenshot(path)` | Diagnostický screenshot |
## Typický flow
```
start_owa
find_last_email(from_email="...") → vrátí subject + metadata
open_email_by_subject("<subject>") → otevře v reading pane
forward_current(body_prefix="Posílám dále, prosím o vyjádření.\n")
→ Ctrl+Shift+F, vepíše úvod
set_recipients(to=["adresat@..."]) → vyplní příjemce
→ uživatel zkontroluje a odešle ručně
close_reading_pane (až po odeslání)
```
## Poznámky / známá omezení
- **Forward draft = stejný tab** jako reading pane (inline composer). `close_reading_pane`
proto NEZAVŘE draft, ale Escape může composer minimalizovat — preferuj zavírat
až po odeslání.
- **Podpis** se vkládá automaticky podle nastavení OWA — skript ho nevkládá.
- **Subject prefix** — OWA si sám předřadí `FW: `, `subject_prefix` se přidá před to.
- **Hledání emailu v UI** je přes search bar (subject substring). Pokud má více
výsledků se stejným subjectem, otevře první (nejnovější).
- Pokud OWA změní lokalizaci aria-labelů, uprav selektory v `forward_current` /
`write_at_top` / `set_recipients`.
## TODO pro další verzi
- Otevření přímo z `message_id` (přes search `messageid:`)
- Detekce, zda forward composer otevřel popup tab místo inline
- `send_forward()` tool s explicitním potvrzením
- Volba složky před `open_email_by_subject`
+317
View File
@@ -0,0 +1,317 @@
"""
=======================================================================
Název: mcp_owa_v1.0.py
Verze: 1.0
Datum: 2026-06-04
Popis: MCP server pro práci s otevřeným OWA oknem (Playwright).
Drží persistent session a vystavuje tooly:
- vyhledání emailu v MongoDB OperativniEmailyJNJ.messages
- otevření emailu v OWA UI přes search
- Forward → vepsání úvodního textu na začátek body
- zavření původního čtecího panelu
Odeslání forwardu dělá uživatel sám.
Profil sdílí s import_emails_to_mongo_v1.0.py (outlook_profile/).
Spuštění: python mcp_owa_v1.0.py (registrace přes .mcp.json)
=======================================================================
"""
import asyncio
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from mcp.server.fastmcp import FastMCP
from playwright.async_api import async_playwright, Page, BrowserContext
from pymongo import MongoClient, DESCENDING
# ── Konfigurace ────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent
PROFILE_DIR = BASE_DIR / "outlook_profile"
START_URL = "https://outlook.cloud.microsoft/mail/"
MONGO_URI = "mongodb://192.168.1.76:27017"
DB_NAME = "OperativniEmailyJNJ"
COL_NAME = "messages"
SEARCH_READY = (
'[placeholder*="Search"], [aria-label*="Search"], '
'[placeholder*="Hledat"], [aria-label*="Hledat"]'
)
def log(msg: str):
print(msg, file=sys.stderr, flush=True)
# ── Globální stav (persistent session) ─────────────────────────────────
class State:
pw = None # playwright instance
context: Optional[BrowserContext] = None
main_page: Optional[Page] = None
draft_page: Optional[Page] = None # forward composer (může být totéž okno)
lock = asyncio.Lock()
_mongo = MongoClient(MONGO_URI)
_col = _mongo[DB_NAME][COL_NAME]
mcp = FastMCP("owa")
# ── Helpers ────────────────────────────────────────────────────────────
def _doc_summary(doc: dict) -> dict:
return {
"message_id": doc.get("message_id"),
"subject": doc.get("subject"),
"from": doc.get("from"),
"to": doc.get("to", [])[:5],
"date": doc.get("date").isoformat() if doc.get("date") else None,
"folder": doc.get("folder"),
"has_attachments": doc.get("has_attachments", False),
}
async def _ensure_started():
if State.context is None:
raise RuntimeError("OWA není spuštěné. Zavolej nejprve start_owa.")
async def _wait_ready(page: Page):
await page.wait_for_load_state("domcontentloaded")
await page.wait_for_selector(SEARCH_READY, timeout=30_000)
# ── Tools: lifecycle ───────────────────────────────────────────────────
@mcp.tool()
async def start_owa() -> dict:
"""Spustí Playwright s persistent profilem a otevře OWA. Okno zůstane otevřené."""
async with State.lock:
if State.context is not None:
return {"status": "already_running", "url": State.main_page.url if State.main_page else None}
if not PROFILE_DIR.exists():
return {"status": "error", "error": f"Profil nenalezen: {PROFILE_DIR}. Spusť outlook_login_v1.0.py."}
State.pw = await async_playwright().start()
State.context = await State.pw.chromium.launch_persistent_context(
user_data_dir=str(PROFILE_DIR),
headless=False,
no_viewport=True,
accept_downloads=True,
args=[
"--disable-blink-features=AutomationControlled",
"--start-maximized",
],
)
State.main_page = State.context.pages[0] if State.context.pages else await State.context.new_page()
await State.main_page.goto(START_URL)
await _wait_ready(State.main_page)
return {"status": "started", "url": State.main_page.url}
@mcp.tool()
async def stop_owa() -> dict:
"""Zavře Playwright context (a tím i okno OWA)."""
async with State.lock:
if State.context is None:
return {"status": "not_running"}
try:
await State.context.close()
finally:
if State.pw:
await State.pw.stop()
State.context = None
State.main_page = None
State.draft_page = None
State.pw = None
return {"status": "stopped"}
@mcp.tool()
async def status() -> dict:
"""Vrátí stav session: běží/neběží, URL, počet tabů, draft otevřen?"""
if State.context is None:
return {"running": False}
return {
"running": True,
"url": State.main_page.url if State.main_page else None,
"tabs": len(State.context.pages),
"draft_open": State.draft_page is not None and not State.draft_page.is_closed(),
}
# ── Tools: MongoDB lookup ──────────────────────────────────────────────
@mcp.tool()
async def find_emails(
query: Optional[str] = None,
from_email: Optional[str] = None,
folder: Optional[str] = None,
since_iso: Optional[str] = None,
limit: int = 10,
) -> list[dict]:
"""Hledá v MongoDB. `query` = substring v subjectu (case-insensitive).
`since_iso` = ISO datum, vrátí jen emaily od něj. Seřazeno od nejnovějšího."""
flt: dict = {}
if query:
flt["subject"] = {"$regex": query, "$options": "i"}
if from_email:
flt["from.email"] = {"$regex": from_email, "$options": "i"}
if folder:
flt["folder"] = folder
if since_iso:
try:
dt = datetime.fromisoformat(since_iso.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
flt["date"] = {"$gte": dt}
except Exception:
pass
cur = _col.find(flt).sort("date", DESCENDING).limit(max(1, min(limit, 50)))
return [_doc_summary(d) for d in cur]
@mcp.tool()
async def find_last_email(from_email: Optional[str] = None, folder: Optional[str] = None) -> Optional[dict]:
"""Vrátí nejnovější email (volitelně filtr podle odesílatele / složky)."""
flt: dict = {}
if from_email:
flt["from.email"] = {"$regex": from_email, "$options": "i"}
if folder:
flt["folder"] = folder
doc = _col.find(flt).sort("date", DESCENDING).limit(1)
docs = list(doc)
return _doc_summary(docs[0]) if docs else None
# ── Tools: OWA UI ──────────────────────────────────────────────────────
@mcp.tool()
async def open_email_by_subject(subject: str) -> dict:
"""Vyhledá v OWA podle subjectu a otevře první výsledek v reading pane."""
await _ensure_started()
page = State.main_page
search = page.locator(SEARCH_READY).first
await search.click()
await search.fill("")
await search.type(subject, delay=20)
await page.keyboard.press("Enter")
await page.wait_for_timeout(2_000)
# první výsledek v listu
msgs = page.locator('div[role="option"]')
try:
await msgs.first.wait_for(state="visible", timeout=10_000)
except Exception:
return {"status": "no_results"}
await msgs.first.click()
await page.wait_for_timeout(800)
return {"status": "opened", "count_visible": await msgs.count()}
@mcp.tool()
async def forward_current(body_prefix: str = "", subject_prefix: str = "") -> dict:
"""Klikne Forward na otevřeném emailu. Pokud je `body_prefix`, vepíše ho na začátek body
(Home → text → Enter). Pokud `subject_prefix`, předřadí ho do předmětu draftu."""
await _ensure_started()
page = State.main_page
# Forward přes klávesovou zkratku (funguje, když je reading pane focus)
await page.keyboard.press("Control+Shift+F")
await page.wait_for_timeout(1_500)
# Body editor
body = page.locator('div[aria-label*="Message body" i][contenteditable="true"], '
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]').first
try:
await body.wait_for(state="visible", timeout=10_000)
except Exception:
return {"status": "forward_failed", "hint": "Body editor forwardu nenalezen."}
State.draft_page = page # OWA forward bývá v tom samém tabu (inline composer)
if subject_prefix:
subj = page.locator('input[aria-label*="subject" i], input[aria-label*="předmět" i]').first
try:
await subj.wait_for(state="visible", timeout=5_000)
current = await subj.input_value()
await subj.fill(f"{subject_prefix}{current}")
except Exception:
pass
if body_prefix:
await body.click()
await page.keyboard.press("Control+Home")
await page.keyboard.type(body_prefix, delay=10)
await page.keyboard.press("Enter")
return {"status": "forward_ready"}
@mcp.tool()
async def write_at_top(text: str) -> dict:
"""Vepíše text na začátek body otevřeného draftu (před existující obsah / podpis)."""
await _ensure_started()
if State.draft_page is None or State.draft_page.is_closed():
return {"status": "no_draft"}
page = State.draft_page
body = page.locator('div[aria-label*="Message body" i][contenteditable="true"], '
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]').first
await body.click()
await page.keyboard.press("Control+Home")
await page.keyboard.type(text, delay=10)
return {"status": "written", "chars": len(text)}
@mcp.tool()
async def set_recipients(to: list[str], cc: Optional[list[str]] = None) -> dict:
"""Vyplní To / Cc v otevřeném draftu. Adresy oddělené středníkem, OWA si je sám zvalidčuje."""
await _ensure_started()
if State.draft_page is None or State.draft_page.is_closed():
return {"status": "no_draft"}
page = State.draft_page
to_field = page.locator('div[aria-label*="To" i][contenteditable="true"], '
'div[aria-label*="Komu" i][contenteditable="true"]').first
await to_field.click()
await page.keyboard.type("; ".join(to) + ";", delay=15)
await page.wait_for_timeout(500)
if cc:
cc_field = page.locator('div[aria-label*="Cc" i][contenteditable="true"], '
'div[aria-label*="Kopie" i][contenteditable="true"]').first
if await cc_field.count():
await cc_field.click()
await page.keyboard.type("; ".join(cc) + ";", delay=15)
return {"status": "filled", "to": to, "cc": cc or []}
@mcp.tool()
async def close_reading_pane() -> dict:
"""Zavře otevřený email v reading pane (Escape). Forward draft tím nezavře."""
await _ensure_started()
await State.main_page.keyboard.press("Escape")
await State.main_page.wait_for_timeout(300)
return {"status": "closed"}
@mcp.tool()
async def screenshot(path: str = "owa_screenshot.png") -> dict:
"""Uloží screenshot aktivního okna pro orientaci."""
await _ensure_started()
out = (BASE_DIR / path).resolve()
await State.main_page.screenshot(path=str(out), full_page=False)
return {"status": "ok", "path": str(out)}
# ── Entry ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
log("mcp_owa v1.0 starting (stdio)…")
mcp.run()
+68
View File
@@ -0,0 +1,68 @@
# mcp_owa_v1.1
MCP server pro OWA (outlook.cloud.microsoft) — drží persistentní Playwright session a umožňuje claude-cowork:
- vyhledat email v MongoDB `OperativniEmailyJNJ.messages`,
- otevřít ho v OWA UI,
- udělat Forward, vepsat úvodní text na začátek těla,
- zavřít původní čtecí panel.
Odeslání forwardu **dělá uživatel ručně** v okně, které zůstává otevřené.
## Změny v1.1
- Sync Playwright v dedikovaném worker threadu místo `async_playwright`.
Důvod: na Windows s persistent contextem `async_playwright` selhává — Chrome se zavře hned po startu (problém s asyncio ProactorEventLoop + `--remote-debugging-pipe`). Sync API funguje spolehlivě (potvrzeno v `import_emails_to_mongo_v1.0.py`).
- FastMCP tooly jsou synchronní, dispatch na worker přes `queue.Queue` + `concurrent.futures.Future`.
## Spuštění
Registrace v `.mcp.json`:
```json
"owa": {
"command": "python",
"args": ["U:\\PythonProject\\Janssen\\Outlook\\mcp_owa_v1.1.py"],
"cwd": "U:\\PythonProject\\Janssen\\Outlook"
}
```
Profil sdílí s `import_emails_to_mongo_v1.0.py` (`outlook_profile/`). Pokud profil neexistuje, spusť nejdřív `outlook_login_v1.0.py`.
## Tools
| Tool | Účel |
|------|------|
| `start_owa` | Spustí Playwright + otevře OWA |
| `stop_owa` | Zavře okno |
| `status` | Stav session |
| `find_emails(query, from_email, folder, since_iso, limit)` | Hledání v MongoDB |
| `find_last_email(from_email, folder)` | Nejnovější email |
| `open_email_by_subject(subject)` | Otevře v OWA přes search |
| `forward_current(body_prefix, subject_prefix)` | Ctrl+Shift+F, předvyplní |
| `write_at_top(text)` | Vepíše text na začátek body draftu |
| `set_recipients(to, cc)` | Doplní To/Cc |
| `close_reading_pane` | Escape (zavře čtecí panel) |
| `screenshot(path)` | Diagnostický screenshot |
## Typický flow
```
start_owa
find_last_email(from_email="...") → vrátí subject + metadata
open_email_by_subject("<subject>") → otevře v reading pane
forward_current(body_prefix="Posílám dále, prosím o vyjádření.\n")
→ Ctrl+Shift+F, vepíše úvod
set_recipients(to=["adresat@..."]) → vyplní příjemce
→ uživatel zkontroluje a odešle ručně
close_reading_pane (až po odeslání)
```
## Poznámky / známá omezení
- Forward draft = stejný tab jako reading pane (inline composer).
- Podpis se vkládá automaticky podle nastavení OWA.
- OWA si sám předřadí `FW: ` k předmětu; `subject_prefix` se přidá před to.
- Hledání emailu v UI přes search bar (subject substring).
- Pokud OWA změní lokalizaci aria-labelů, uprav selektory v `_forward`, `_write_at_top`, `_set_recipients`.
## TODO pro další verzi
- Otevření přímo z `message_id`
- Detekce, zda forward composer otevřel popup tab místo inline
- `send_forward()` tool s explicitním potvrzením
- Volba složky před `open_email_by_subject`
+371
View File
@@ -0,0 +1,371 @@
"""
=======================================================================
Název: mcp_owa_v1.1.py
Verze: 1.1
Datum: 2026-06-04
Popis: MCP server pro práci s otevřeným OWA oknem (Playwright).
Drží persistent session a vystavuje tooly:
- vyhledání emailu v MongoDB OperativniEmailyJNJ.messages
- otevření emailu v OWA UI přes search
- Forward → vepsání úvodního textu na začátek body
- zavření původního čtecího panelu
Odeslání forwardu dělá uživatel sám.
Profil sdílí s import_emails_to_mongo_v1.0.py (outlook_profile/).
Změny v1.1:
- sync_playwright v dedikovaném worker threadu (Windows + async_playwright
v persistent contextu padá: Chrome se zavře hned po startu).
- FastMCP tooly synchronní, dispatch na worker přes queue + Future.
Spuštění: python mcp_owa_v1.1.py
=======================================================================
"""
import queue
import sys
import threading
from concurrent.futures import Future
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from mcp.server.fastmcp import FastMCP
from playwright.sync_api import sync_playwright, Page, BrowserContext
from pymongo import MongoClient, DESCENDING
# ── Konfigurace ────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent
PROFILE_DIR = BASE_DIR / "outlook_profile"
START_URL = "https://outlook.cloud.microsoft/mail/"
MONGO_URI = "mongodb://192.168.1.76:27017"
DB_NAME = "OperativniEmailyJNJ"
COL_NAME = "messages"
SEARCH_READY = (
'[placeholder*="Search"], [aria-label*="Search"], '
'[placeholder*="Hledat"], [aria-label*="Hledat"]'
)
def log(msg: str):
print(msg, file=sys.stderr, flush=True)
# ── Worker thread (drží Playwright) ────────────────────────────────────
class PWWorker:
def __init__(self):
self.q: queue.Queue = queue.Queue()
self.ready = threading.Event()
self.pw = None
self.context: Optional[BrowserContext] = None
self.main_page: Optional[Page] = None
self.draft_page: Optional[Page] = None
self.thread = threading.Thread(target=self._run, daemon=True, name="pw-worker")
self.thread.start()
self.ready.wait(timeout=10)
def _run(self):
with sync_playwright() as p:
self.pw = p
self.ready.set()
log("pw-worker: sync_playwright ready")
while True:
item = self.q.get()
if item is None:
break
fn, args, kwargs, fut = item
try:
fut.set_result(fn(self, *args, **kwargs))
except Exception as e:
fut.set_exception(e)
def call(self, fn, *args, **kwargs):
fut: Future = Future()
self.q.put((fn, args, kwargs, fut))
return fut.result()
WORKER = PWWorker()
_mongo = MongoClient(MONGO_URI)
_col = _mongo[DB_NAME][COL_NAME]
mcp = FastMCP("owa")
# ── Helpers ────────────────────────────────────────────────────────────
def _doc_summary(doc: dict) -> dict:
return {
"message_id": doc.get("message_id"),
"subject": doc.get("subject"),
"from": doc.get("from"),
"to": doc.get("to", [])[:5],
"date": doc.get("date").isoformat() if doc.get("date") else None,
"folder": doc.get("folder"),
"has_attachments": doc.get("has_attachments", False),
}
def _wait_ready(page: Page):
page.wait_for_load_state("domcontentloaded")
page.wait_for_selector(SEARCH_READY, timeout=30_000)
# ── Worker functions (běží na pw threadu, dostávají worker jako 1. arg) ─
def _start(w: PWWorker) -> dict:
if w.context is not None:
return {"status": "already_running", "url": w.main_page.url if w.main_page else None}
if not PROFILE_DIR.exists():
return {"status": "error", "error": f"Profil nenalezen: {PROFILE_DIR}. Spusť outlook_login_v1.0.py."}
w.context = w.pw.chromium.launch_persistent_context(
user_data_dir=str(PROFILE_DIR),
headless=False,
no_viewport=True,
accept_downloads=True,
args=[
"--disable-blink-features=AutomationControlled",
"--start-maximized",
],
)
w.main_page = w.context.pages[0] if w.context.pages else w.context.new_page()
w.main_page.goto(START_URL)
_wait_ready(w.main_page)
return {"status": "started", "url": w.main_page.url}
def _stop(w: PWWorker) -> dict:
if w.context is None:
return {"status": "not_running"}
try:
w.context.close()
finally:
w.context = None
w.main_page = None
w.draft_page = None
return {"status": "stopped"}
def _status(w: PWWorker) -> dict:
if w.context is None:
return {"running": False}
return {
"running": True,
"url": w.main_page.url if w.main_page else None,
"tabs": len(w.context.pages),
"draft_open": w.draft_page is not None and not w.draft_page.is_closed(),
}
def _open_by_subject(w: PWWorker, subject: str) -> dict:
if w.context is None:
return {"status": "not_running"}
page = w.main_page
search = page.locator(SEARCH_READY).first
search.click()
search.fill("")
search.type(subject, delay=20)
page.keyboard.press("Enter")
page.wait_for_timeout(2_000)
msgs = page.locator('div[role="option"]')
try:
msgs.first.wait_for(state="visible", timeout=10_000)
except Exception:
return {"status": "no_results"}
msgs.first.click()
page.wait_for_timeout(800)
return {"status": "opened", "count_visible": msgs.count()}
def _forward(w: PWWorker, body_prefix: str = "", subject_prefix: str = "") -> dict:
if w.context is None:
return {"status": "not_running"}
page = w.main_page
page.keyboard.press("Control+Shift+F")
page.wait_for_timeout(1_500)
body = page.locator(
'div[aria-label*="Message body" i][contenteditable="true"], '
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
).first
try:
body.wait_for(state="visible", timeout=10_000)
except Exception:
return {"status": "forward_failed", "hint": "Body editor forwardu nenalezen."}
w.draft_page = page # inline composer ve stejném tabu
if subject_prefix:
subj = page.locator(
'input[aria-label*="subject" i], input[aria-label*="předmět" i]'
).first
try:
subj.wait_for(state="visible", timeout=5_000)
current = subj.input_value()
subj.fill(f"{subject_prefix}{current}")
except Exception:
pass
if body_prefix:
body.click()
page.keyboard.press("Control+Home")
page.keyboard.type(body_prefix, delay=10)
page.keyboard.press("Enter")
return {"status": "forward_ready"}
def _write_at_top(w: PWWorker, text: str) -> dict:
if w.draft_page is None or w.draft_page.is_closed():
return {"status": "no_draft"}
page = w.draft_page
body = page.locator(
'div[aria-label*="Message body" i][contenteditable="true"], '
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
).first
body.click()
page.keyboard.press("Control+Home")
page.keyboard.type(text, delay=10)
return {"status": "written", "chars": len(text)}
def _set_recipients(w: PWWorker, to: list[str], cc: Optional[list[str]] = None) -> dict:
if w.draft_page is None or w.draft_page.is_closed():
return {"status": "no_draft"}
page = w.draft_page
to_field = page.locator(
'div[aria-label*="To" i][contenteditable="true"], '
'div[aria-label*="Komu" i][contenteditable="true"]'
).first
to_field.click()
page.keyboard.type("; ".join(to) + ";", delay=15)
page.wait_for_timeout(500)
if cc:
cc_field = page.locator(
'div[aria-label*="Cc" i][contenteditable="true"], '
'div[aria-label*="Kopie" i][contenteditable="true"]'
).first
if cc_field.count():
cc_field.click()
page.keyboard.type("; ".join(cc) + ";", delay=15)
return {"status": "filled", "to": to, "cc": cc or []}
def _close_pane(w: PWWorker) -> dict:
if w.context is None:
return {"status": "not_running"}
w.main_page.keyboard.press("Escape")
w.main_page.wait_for_timeout(300)
return {"status": "closed"}
def _screenshot(w: PWWorker, path: str) -> dict:
if w.context is None:
return {"status": "not_running"}
out = (BASE_DIR / path).resolve()
w.main_page.screenshot(path=str(out), full_page=False)
return {"status": "ok", "path": str(out)}
# ── MCP tooly (synchronní; dispatch na worker) ─────────────────────────
@mcp.tool()
def start_owa() -> dict:
"""Spustí Playwright s persistent profilem a otevře OWA. Okno zůstane otevřené."""
return WORKER.call(_start)
@mcp.tool()
def stop_owa() -> dict:
"""Zavře Playwright context (a tím i okno OWA)."""
return WORKER.call(_stop)
@mcp.tool()
def status() -> dict:
"""Vrátí stav session: běží/neběží, URL, počet tabů, draft otevřen?"""
return WORKER.call(_status)
@mcp.tool()
def find_emails(
query: Optional[str] = None,
from_email: Optional[str] = None,
folder: Optional[str] = None,
since_iso: Optional[str] = None,
limit: int = 10,
) -> list[dict]:
"""Hledá v MongoDB. `query` = substring v subjectu (case-insensitive).
`since_iso` = ISO datum, vrátí jen emaily od něj. Seřazeno od nejnovějšího."""
flt: dict = {}
if query:
flt["subject"] = {"$regex": query, "$options": "i"}
if from_email:
flt["from.email"] = {"$regex": from_email, "$options": "i"}
if folder:
flt["folder"] = folder
if since_iso:
try:
dt = datetime.fromisoformat(since_iso.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
flt["date"] = {"$gte": dt}
except Exception:
pass
cur = _col.find(flt).sort("date", DESCENDING).limit(max(1, min(limit, 50)))
return [_doc_summary(d) for d in cur]
@mcp.tool()
def find_last_email(from_email: Optional[str] = None, folder: Optional[str] = None) -> Optional[dict]:
"""Vrátí nejnovější email (volitelně filtr podle odesílatele / složky)."""
flt: dict = {}
if from_email:
flt["from.email"] = {"$regex": from_email, "$options": "i"}
if folder:
flt["folder"] = folder
docs = list(_col.find(flt).sort("date", DESCENDING).limit(1))
return _doc_summary(docs[0]) if docs else None
@mcp.tool()
def open_email_by_subject(subject: str) -> dict:
"""Vyhledá v OWA podle subjectu a otevře první výsledek v reading pane."""
return WORKER.call(_open_by_subject, subject)
@mcp.tool()
def forward_current(body_prefix: str = "", subject_prefix: str = "") -> dict:
"""Klikne Forward (Ctrl+Shift+F). Pokud je `body_prefix`, vepíše ho na začátek body.
Pokud `subject_prefix`, předřadí ho do předmětu draftu."""
return WORKER.call(_forward, body_prefix, subject_prefix)
@mcp.tool()
def write_at_top(text: str) -> dict:
"""Vepíše text na začátek body otevřeného draftu (před existující obsah / podpis)."""
return WORKER.call(_write_at_top, text)
@mcp.tool()
def set_recipients(to: list[str], cc: Optional[list[str]] = None) -> dict:
"""Vyplní To / Cc v otevřeném draftu."""
return WORKER.call(_set_recipients, to, cc)
@mcp.tool()
def close_reading_pane() -> dict:
"""Zavře otevřený email v reading pane (Escape). Forward draft tím nezavře."""
return WORKER.call(_close_pane)
@mcp.tool()
def screenshot(path: str = "owa_screenshot.png") -> dict:
"""Uloží screenshot aktivního okna pro orientaci."""
return WORKER.call(_screenshot, path)
if __name__ == "__main__":
log("mcp_owa v1.1 starting (stdio)…")
mcp.run()
+68
View File
@@ -0,0 +1,68 @@
# mcp_owa_v1.2
MCP server pro OWA (outlook.cloud.microsoft) — drží persistentní Playwright session ve **vlastním profilu** `owa_mcp_profile/`.
## Změny v1.2
- **Vlastní profil** `owa_mcp_profile/` — nesdílí se s `import_emails_to_mongo_v1.0.py`. Lze tak používat oba současně bez konfliktu zámku Chrome.
- **Nový tool `login()`** — pro první přihlášení / přepnutí účtu. Otevře okno, počká až 5 minut, než dokončíš login ručně. Profil se uloží automaticky.
- `start_owa` vrátí `no_profile`, pokud `owa_mcp_profile/` neexistuje — pak zavolej `login`.
- `status` vrací i `profile_exists` + `profile_dir`.
## Změny v1.1
- Sync Playwright v dedikovaném worker threadu místo `async_playwright` (na Windows + persistent context async API padá hned po startu Chrome).
## První použití
1. Zavolej `login` → otevře se Chrome s OWA.
2. Přihlas se ručně (Microsoft SSO atd.).
3. Po načtení inboxu se tool vrátí `logged_in` (nebo `window_open_login_pending` při timeoutu — to neva, profil se i tak ukládá průběžně).
4. Příště stačí `start_owa`.
## Spuštění
Registrace v `.mcp.json`:
```json
"owa": {
"command": "python",
"args": ["U:\\PythonProject\\Janssen\\Outlook\\mcp_owa_v1.2.py"],
"cwd": "U:\\PythonProject\\Janssen\\Outlook"
}
```
## Tools
| Tool | Účel |
|------|------|
| `login` | První přihlášení; otevře OWA na 5 min, profil se uloží |
| `start_owa` | Spustí Playwright + otevře OWA (vyžaduje existující profil) |
| `stop_owa` | Zavře okno (profil zůstává uložený) |
| `status` | Stav session + existuje profil? |
| `find_emails(query, from_email, folder, since_iso, limit)` | Hledání v MongoDB |
| `find_last_email(from_email, folder)` | Nejnovější email |
| `open_email_by_subject(subject)` | Otevře v OWA přes search |
| `forward_current(body_prefix, subject_prefix)` | Ctrl+Shift+F, předvyplní |
| `write_at_top(text)` | Vepíše text na začátek body draftu |
| `set_recipients(to, cc)` | Doplní To/Cc |
| `close_reading_pane` | Escape |
| `screenshot(path)` | Diagnostický screenshot |
## Typický flow (po prvním loginu)
```
start_owa
find_last_email(from_email="...")
open_email_by_subject("<subject>")
forward_current(body_prefix="Posílám dále, prosím o vyjádření.\n")
set_recipients(to=["adresat@..."])
# uživatel zkontroluje a odešle ručně
```
## Známá omezení
- Forward draft je inline composer ve stejném tabu.
- Podpis vkládá OWA automaticky podle nastavení účtu.
- OWA si sám předřadí `FW:` k předmětu; `subject_prefix` se přidá před to.
- Pokud OWA změní lokalizaci aria-labelů, uprav selektory v `_forward`, `_write_at_top`, `_set_recipients`.
## TODO
- Otevření přímo z `message_id`
- Detekce popup composer vs. inline
- `send_forward()` s explicitním potvrzením
- Volba složky před `open_email_by_subject`
- `logout()` (smaže profil)
+426
View File
@@ -0,0 +1,426 @@
"""
=======================================================================
Název: mcp_owa_v1.2.py
Verze: 1.2
Datum: 2026-06-04
Popis: MCP server pro práci s otevřeným OWA oknem (Playwright).
Drží persistent session s vlastním profilem `owa_mcp_profile/`
a vystavuje tooly:
- login (první přihlášení; uživatel se přihlásí ručně,
profil se uloží do owa_mcp_profile/)
- vyhledání emailu v MongoDB OperativniEmailyJNJ.messages
- otevření emailu v OWA UI přes search
- Forward → vepsání úvodního textu na začátek body
- zavření původního čtecího panelu
Odeslání forwardu dělá uživatel sám.
Změny v1.2:
- Vlastní profil `owa_mcp_profile/` (nesdílí se s import_emails_to_mongo).
- Nový tool `login()` — otevře OWA pro ruční přihlášení;
po úspěšném loginu zůstane profil v owa_mcp_profile/ pro další běhy.
- `start_owa` automaticky upozorní, když profil neexistuje (-> volej login).
Změny v1.1:
- sync_playwright v dedikovaném worker threadu (Windows + async_playwright
v persistent contextu padá: Chrome se zavře hned po startu).
- FastMCP tooly synchronní, dispatch na worker přes queue + Future.
Spuštění: python mcp_owa_v1.2.py
=======================================================================
"""
import queue
import sys
import threading
from concurrent.futures import Future
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from mcp.server.fastmcp import FastMCP
from playwright.sync_api import sync_playwright, Page, BrowserContext
from pymongo import MongoClient, DESCENDING
# ── Konfigurace ────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent
PROFILE_DIR = BASE_DIR / "owa_mcp_profile" # ← vlastní profil pro tento MCP server
START_URL = "https://outlook.cloud.microsoft/mail/"
MONGO_URI = "mongodb://192.168.1.76:27017"
DB_NAME = "OperativniEmailyJNJ"
COL_NAME = "messages"
SEARCH_READY = (
'[placeholder*="Search"], [aria-label*="Search"], '
'[placeholder*="Hledat"], [aria-label*="Hledat"]'
)
def log(msg: str):
print(msg, file=sys.stderr, flush=True)
# ── Worker thread (drží Playwright) ────────────────────────────────────
class PWWorker:
def __init__(self):
self.q: queue.Queue = queue.Queue()
self.ready = threading.Event()
self.pw = None
self.context: Optional[BrowserContext] = None
self.main_page: Optional[Page] = None
self.draft_page: Optional[Page] = None
self.thread = threading.Thread(target=self._run, daemon=True, name="pw-worker")
self.thread.start()
self.ready.wait(timeout=10)
def _run(self):
with sync_playwright() as p:
self.pw = p
self.ready.set()
log("pw-worker: sync_playwright ready")
while True:
item = self.q.get()
if item is None:
break
fn, args, kwargs, fut = item
try:
fut.set_result(fn(self, *args, **kwargs))
except Exception as e:
fut.set_exception(e)
def call(self, fn, *args, **kwargs):
fut: Future = Future()
self.q.put((fn, args, kwargs, fut))
return fut.result()
WORKER = PWWorker()
_mongo = MongoClient(MONGO_URI)
_col = _mongo[DB_NAME][COL_NAME]
mcp = FastMCP("owa")
# ── Helpers ────────────────────────────────────────────────────────────
def _doc_summary(doc: dict) -> dict:
return {
"message_id": doc.get("message_id"),
"subject": doc.get("subject"),
"from": doc.get("from"),
"to": doc.get("to", [])[:5],
"date": doc.get("date").isoformat() if doc.get("date") else None,
"folder": doc.get("folder"),
"has_attachments": doc.get("has_attachments", False),
}
def _wait_ready(page: Page, timeout: int = 30_000):
page.wait_for_load_state("domcontentloaded")
page.wait_for_selector(SEARCH_READY, timeout=timeout)
def _launch_context(w: PWWorker) -> BrowserContext:
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
return w.pw.chromium.launch_persistent_context(
user_data_dir=str(PROFILE_DIR),
headless=False,
no_viewport=True,
accept_downloads=True,
args=[
"--disable-blink-features=AutomationControlled",
"--start-maximized",
],
)
# ── Worker functions ───────────────────────────────────────────────────
def _login(w: PWWorker) -> dict:
"""Otevře OWA pro ruční přihlášení. Profil se uloží do owa_mcp_profile/.
Po přihlášení uživatel volá zavřít okno (nebo nechá běžet a pokračuje)."""
if w.context is not None:
return {
"status": "already_running",
"url": w.main_page.url if w.main_page else None,
"hint": "Pokud se chceš přihlásit znovu/jiným účtem, nejprve stop_owa a smaž owa_mcp_profile/."
}
w.context = _launch_context(w)
w.main_page = w.context.pages[0] if w.context.pages else w.context.new_page()
w.main_page.goto(START_URL)
# Po loginu se objeví searchbar; čekej dlouho, uživatel může klikat
try:
_wait_ready(w.main_page, timeout=300_000) # až 5 minut na login
status_msg = "logged_in"
except Exception:
status_msg = "window_open_login_pending"
return {
"status": status_msg,
"profile_dir": str(PROFILE_DIR),
"url": w.main_page.url,
"hint": "Profil je uložen. Příště volej start_owa a budeš rovnou přihlášen.",
}
def _start(w: PWWorker) -> dict:
if w.context is not None:
return {"status": "already_running", "url": w.main_page.url if w.main_page else None}
if not PROFILE_DIR.exists() or not any(PROFILE_DIR.iterdir()):
return {
"status": "no_profile",
"error": f"Profil {PROFILE_DIR} neexistuje nebo je prázdný.",
"hint": "Zavolej nejprve `login` a přihlas se ručně v otevřeném okně.",
}
w.context = _launch_context(w)
w.main_page = w.context.pages[0] if w.context.pages else w.context.new_page()
w.main_page.goto(START_URL)
_wait_ready(w.main_page)
return {"status": "started", "url": w.main_page.url}
def _stop(w: PWWorker) -> dict:
if w.context is None:
return {"status": "not_running"}
try:
w.context.close()
finally:
w.context = None
w.main_page = None
w.draft_page = None
return {"status": "stopped"}
def _status(w: PWWorker) -> dict:
profile_exists = PROFILE_DIR.exists() and any(PROFILE_DIR.iterdir())
if w.context is None:
return {"running": False, "profile_exists": profile_exists, "profile_dir": str(PROFILE_DIR)}
return {
"running": True,
"url": w.main_page.url if w.main_page else None,
"tabs": len(w.context.pages),
"draft_open": w.draft_page is not None and not w.draft_page.is_closed(),
"profile_exists": profile_exists,
"profile_dir": str(PROFILE_DIR),
}
def _open_by_subject(w: PWWorker, subject: str) -> dict:
if w.context is None:
return {"status": "not_running"}
page = w.main_page
search = page.locator(SEARCH_READY).first
search.click()
search.fill("")
search.type(subject, delay=20)
page.keyboard.press("Enter")
page.wait_for_timeout(2_000)
msgs = page.locator('div[role="option"]')
try:
msgs.first.wait_for(state="visible", timeout=10_000)
except Exception:
return {"status": "no_results"}
msgs.first.click()
page.wait_for_timeout(800)
return {"status": "opened", "count_visible": msgs.count()}
def _forward(w: PWWorker, body_prefix: str = "", subject_prefix: str = "") -> dict:
if w.context is None:
return {"status": "not_running"}
page = w.main_page
page.keyboard.press("Control+Shift+F")
page.wait_for_timeout(1_500)
body = page.locator(
'div[aria-label*="Message body" i][contenteditable="true"], '
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
).first
try:
body.wait_for(state="visible", timeout=10_000)
except Exception:
return {"status": "forward_failed", "hint": "Body editor forwardu nenalezen."}
w.draft_page = page
if subject_prefix:
subj = page.locator(
'input[aria-label*="subject" i], input[aria-label*="předmět" i]'
).first
try:
subj.wait_for(state="visible", timeout=5_000)
current = subj.input_value()
subj.fill(f"{subject_prefix}{current}")
except Exception:
pass
if body_prefix:
body.click()
page.keyboard.press("Control+Home")
page.keyboard.type(body_prefix, delay=10)
page.keyboard.press("Enter")
return {"status": "forward_ready"}
def _write_at_top(w: PWWorker, text: str) -> dict:
if w.draft_page is None or w.draft_page.is_closed():
return {"status": "no_draft"}
page = w.draft_page
body = page.locator(
'div[aria-label*="Message body" i][contenteditable="true"], '
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
).first
body.click()
page.keyboard.press("Control+Home")
page.keyboard.type(text, delay=10)
return {"status": "written", "chars": len(text)}
def _set_recipients(w: PWWorker, to: list[str], cc: Optional[list[str]] = None) -> dict:
if w.draft_page is None or w.draft_page.is_closed():
return {"status": "no_draft"}
page = w.draft_page
to_field = page.locator(
'div[aria-label*="To" i][contenteditable="true"], '
'div[aria-label*="Komu" i][contenteditable="true"]'
).first
to_field.click()
page.keyboard.type("; ".join(to) + ";", delay=15)
page.wait_for_timeout(500)
if cc:
cc_field = page.locator(
'div[aria-label*="Cc" i][contenteditable="true"], '
'div[aria-label*="Kopie" i][contenteditable="true"]'
).first
if cc_field.count():
cc_field.click()
page.keyboard.type("; ".join(cc) + ";", delay=15)
return {"status": "filled", "to": to, "cc": cc or []}
def _close_pane(w: PWWorker) -> dict:
if w.context is None:
return {"status": "not_running"}
w.main_page.keyboard.press("Escape")
w.main_page.wait_for_timeout(300)
return {"status": "closed"}
def _screenshot(w: PWWorker, path: str) -> dict:
if w.context is None:
return {"status": "not_running"}
out = (BASE_DIR / path).resolve()
w.main_page.screenshot(path=str(out), full_page=False)
return {"status": "ok", "path": str(out)}
# ── MCP tooly ──────────────────────────────────────────────────────────
@mcp.tool()
def login() -> dict:
"""První přihlášení (nebo přihlášení jiným účtem). Otevře OWA okno
a počká až 5 minut, než dokončíš login ručně. Profil se uloží do
owa_mcp_profile/ a příště stačí volat start_owa."""
return WORKER.call(_login)
@mcp.tool()
def start_owa() -> dict:
"""Spustí Playwright s persistent profilem a otevře OWA. Pokud profil neexistuje,
vrátí no_profile — pak zavolej login."""
return WORKER.call(_start)
@mcp.tool()
def stop_owa() -> dict:
"""Zavře Playwright context (a tím i okno OWA). Profil zůstane uložený."""
return WORKER.call(_stop)
@mcp.tool()
def status() -> dict:
"""Vrátí stav session: běží/neběží, URL, počet tabů, draft otevřen, existuje profil?"""
return WORKER.call(_status)
@mcp.tool()
def find_emails(
query: Optional[str] = None,
from_email: Optional[str] = None,
folder: Optional[str] = None,
since_iso: Optional[str] = None,
limit: int = 10,
) -> list[dict]:
"""Hledá v MongoDB. `query` = substring v subjectu (case-insensitive).
`since_iso` = ISO datum, vrátí jen emaily od něj. Seřazeno od nejnovějšího."""
flt: dict = {}
if query:
flt["subject"] = {"$regex": query, "$options": "i"}
if from_email:
flt["from.email"] = {"$regex": from_email, "$options": "i"}
if folder:
flt["folder"] = folder
if since_iso:
try:
dt = datetime.fromisoformat(since_iso.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
flt["date"] = {"$gte": dt}
except Exception:
pass
cur = _col.find(flt).sort("date", DESCENDING).limit(max(1, min(limit, 50)))
return [_doc_summary(d) for d in cur]
@mcp.tool()
def find_last_email(from_email: Optional[str] = None, folder: Optional[str] = None) -> Optional[dict]:
"""Vrátí nejnovější email (volitelně filtr podle odesílatele / složky)."""
flt: dict = {}
if from_email:
flt["from.email"] = {"$regex": from_email, "$options": "i"}
if folder:
flt["folder"] = folder
docs = list(_col.find(flt).sort("date", DESCENDING).limit(1))
return _doc_summary(docs[0]) if docs else None
@mcp.tool()
def open_email_by_subject(subject: str) -> dict:
"""Vyhledá v OWA podle subjectu a otevře první výsledek v reading pane."""
return WORKER.call(_open_by_subject, subject)
@mcp.tool()
def forward_current(body_prefix: str = "", subject_prefix: str = "") -> dict:
"""Klikne Forward (Ctrl+Shift+F). Pokud je `body_prefix`, vepíše ho na začátek body.
Pokud `subject_prefix`, předřadí ho do předmětu draftu."""
return WORKER.call(_forward, body_prefix, subject_prefix)
@mcp.tool()
def write_at_top(text: str) -> dict:
"""Vepíše text na začátek body otevřeného draftu (před existující obsah / podpis)."""
return WORKER.call(_write_at_top, text)
@mcp.tool()
def set_recipients(to: list[str], cc: Optional[list[str]] = None) -> dict:
"""Vyplní To / Cc v otevřeném draftu."""
return WORKER.call(_set_recipients, to, cc)
@mcp.tool()
def close_reading_pane() -> dict:
"""Zavře otevřený email v reading pane (Escape). Forward draft tím nezavře."""
return WORKER.call(_close_pane)
@mcp.tool()
def screenshot(path: str = "owa_screenshot.png") -> dict:
"""Uloží screenshot aktivního okna pro orientaci."""
return WORKER.call(_screenshot, path)
if __name__ == "__main__":
log("mcp_owa v1.2 starting (stdio)…")
mcp.run()
+399
View File
@@ -0,0 +1,399 @@
"""
=======================================================================
Název: import_emails_to_mongo_v1.0.py
Verze: 1.0
Datum: 2026-06-04
Popis: Stáhne emaily z OWA složek, zparsuje EML a uloží
do MongoDB OperativniEmailyJNJ.messages.
ONLY_NEW = False → stáhne vše od DATE_FROM (28.05.2026)
zastav se při emailu starším než DATE_FROM
ONLY_NEW = True → stáhne jen nové (nezávislé na DATE_FROM)
zastav se při prvním emailu už v DB
(emaily jsou od nejnovějšího, takže vše
starší už máme)
Přílohy do MAX_ATTACHMENT_SIZE uloží jako BinData,
větší označí downloaded=False.
Deduplikace přes message_id + sha256.
Používá persistent profil z outlook_login_v1.0.py.
=======================================================================
"""
import email as email_lib
import email.utils
import hashlib
from datetime import datetime, timezone
from email.header import decode_header
from pathlib import Path
from playwright.sync_api import sync_playwright
from pymongo import MongoClient, ASCENDING
# ── Konfigurace ────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent
PROFILE_DIR = BASE_DIR / "outlook_profile"
START_URL = "https://outlook.cloud.microsoft/mail/"
MONGO_URI = "mongodb://192.168.1.76:27017"
DB_NAME = "OperativniEmailyJNJ"
COL_NAME = "messages"
# ── Hlavní přepínač ───────────────────────────────────────────────────
ONLY_NEW = True # False = od DATE_FROM; True = jen nové (zastav při 1. duplikátu)
DATE_FROM = datetime(2026, 5, 28, tzinfo=timezone.utc) # platí jen pro ONLY_NEW=False
MAX_PER_FOLDER = 2000 # pojistka — max emailů na složku
MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 # 5 MB — větší přílohy se neuloží
# (zobrazovaný název, způsob navigace, hodnota)
FOLDERS = [
("Inbox", "url", "https://outlook.cloud.microsoft/mail/"),
("TMP", "click", "TMP"),
("Sent Items", "url", "https://outlook.cloud.microsoft/mail/sentitems"),
("Deleted Items", "url", "https://outlook.cloud.microsoft/mail/deleteditems"),
("Archive", "url", "https://outlook.cloud.microsoft/mail/archive"),
]
SEARCH_READY = (
'[placeholder*="Search"], [aria-label*="Search"], '
'[placeholder*="Hledat"], [aria-label*="Hledat"]'
)
# ── EML parsování ──────────────────────────────────────────────────────
def _decode_str(value):
"""Dekóduje encoded-word hlavičky (=?utf-8?...) na čistý string."""
if value is None:
return None
parts = decode_header(value)
result = []
for part, charset in parts:
if isinstance(part, bytes):
result.append(part.decode(charset or "utf-8", errors="replace"))
else:
result.append(part)
return " ".join(result).strip()
def _parse_addresses(header_value):
if not header_value:
return []
pairs = email.utils.getaddresses([header_value])
return [{"name": name.strip(), "email": addr.strip()} for name, addr in pairs]
def parse_eml(data: bytes, folder_name: str) -> dict:
msg = email_lib.message_from_bytes(data)
# Datum
date_dt = None
date_str = msg.get("date")
if date_str:
try:
date_dt = email.utils.parsedate_to_datetime(date_str)
except Exception:
pass
# Tělo + přílohy
body_plain = None
body_html = None
attachments = []
for part in msg.walk():
ctype = part.get_content_type()
disposition = part.get_content_disposition() or ""
filename = _decode_str(part.get_filename())
is_attachment = (
"attachment" in disposition
or ("inline" in disposition and filename)
or (filename and ctype not in ("text/plain", "text/html"))
)
if is_attachment:
payload = part.get_payload(decode=True) or b""
size = len(payload)
att = {
"filename": filename or "unknown",
"content_type": ctype,
"size": size,
"downloaded": size <= MAX_ATTACHMENT_SIZE,
}
if att["downloaded"] and payload:
att["data"] = payload
attachments.append(att)
elif ctype == "text/plain" and body_plain is None and "attachment" not in disposition:
raw = part.get_payload(decode=True)
if raw:
cs = part.get_content_charset() or "utf-8"
body_plain = raw.decode(cs, errors="replace")
elif ctype == "text/html" and body_html is None and "attachment" not in disposition:
raw = part.get_payload(decode=True)
if raw:
cs = part.get_content_charset() or "utf-8"
body_html = raw.decode(cs, errors="replace")
from_parsed = _parse_addresses(msg.get("from", ""))
return {
"message_id": (msg.get("message-id") or "").strip(),
"sha256": hashlib.sha256(data).hexdigest(),
"folder": folder_name,
"eml_size": len(data),
"imported_at": datetime.now(timezone.utc),
"subject": _decode_str(msg.get("subject")),
"date": date_dt,
"from": from_parsed[0] if from_parsed else {"name": "", "email": ""},
"to": _parse_addresses(msg.get("to", "")),
"cc": _parse_addresses(msg.get("cc", "")),
"bcc": _parse_addresses(msg.get("bcc", "")),
"in_reply_to": (msg.get("in-reply-to") or "").strip() or None,
"references": [r.strip() for r in (msg.get("references") or "").split() if r.strip()],
"importance": (msg.get("importance") or msg.get("x-priority") or "normal").strip().lower(),
"body_plain": body_plain,
"body_html": body_html,
"has_attachments": bool(attachments),
"attachments": attachments,
}
# ── Playwright helpers ─────────────────────────────────────────────────
def wait_ready(page):
page.wait_for_load_state("domcontentloaded")
page.wait_for_selector(SEARCH_READY, timeout=30_000)
def navigate_to_folder(page, nav_type, value):
if nav_type == "url":
page.goto(value)
wait_ready(page)
else:
loc = page.locator(f'div[role="treeitem"]:has-text("{value}")').last
loc.wait_for(state="visible", timeout=10_000)
loc.click()
page.wait_for_timeout(1_500)
def download_email_at_index(page, idx):
"""Stáhne email na pozici idx. Vrátí bytes nebo None."""
msgs = page.locator('div[role="option"]')
# Zkus načíst dostatek položek scrollováním
last_count = -1
while True:
count = msgs.count()
if count > idx:
break
if count == last_count:
return None # konec složky
last_count = count
if count > 0:
msgs.last.scroll_into_view_if_needed()
page.wait_for_timeout(800)
else:
try:
page.wait_for_selector('div[role="option"]', timeout=5_000)
except Exception:
return None
item = msgs.nth(idx)
item.scroll_into_view_if_needed()
page.wait_for_timeout(400)
item.click() # nejdřív vyber email
page.wait_for_timeout(600)
item.click(button="right") # pak kontextové menu
page.wait_for_timeout(700)
# Najdi Download v kontextovém menu
download_parent = None
for name in ("Download", "Stáhnout"):
loc = page.get_by_role("menuitem", name=name).first
if loc.count() and loc.is_visible():
download_parent = loc
break
if download_parent is None:
items = page.get_by_role("menuitem").all()
print(f" ! 'Download' nenalezen. Menu: {[i.inner_text() for i in items[:8]]}")
page.keyboard.press("Escape")
return None
download_parent.hover()
page.wait_for_timeout(600)
eml_item = None
for name in ("Download as EML", "Stáhnout jako EML", "Stáhnout jako .eml"):
loc = page.get_by_role("menuitem", name=name).first
if loc.count() and loc.is_visible():
eml_item = loc
break
try:
target = eml_item if eml_item else download_parent
with page.expect_download(timeout=20_000) as dl_info:
target.click()
dl = dl_info.value
path = dl.path()
if path:
return Path(path).read_bytes()
return None
except Exception as e:
print(f" ! Stažení selhalo: {e}")
page.keyboard.press("Escape")
return None
# ── MongoDB helpers ────────────────────────────────────────────────────
def ensure_indexes(col):
col.create_index([("message_id", ASCENDING)], unique=True, sparse=True,
name="message_id_unique")
col.create_index([("sha256", ASCENDING)], unique=True,
name="sha256_unique")
col.create_index([("folder", ASCENDING)], name="folder")
col.create_index([("date", ASCENDING)], name="date")
col.create_index([("from.email", ASCENDING)], name="from_email")
col.create_index([("subject", "text"), ("body_plain", "text")],
name="fulltext")
def save_doc(col, doc) -> str:
"""Uloží dokument. Vrátí 'saved', 'duplicate_mid', 'duplicate_sha', nebo 'error:...'"""
# Deduplikace přes message_id
if doc["message_id"] and col.find_one({"message_id": doc["message_id"]}):
return "duplicate_mid"
# Deduplikace přes sha256
if col.find_one({"sha256": doc["sha256"]}):
return "duplicate_sha"
try:
col.insert_one(doc)
return "saved"
except Exception as e:
return f"error: {e}"
# ── Hlavní smyčka ──────────────────────────────────────────────────────
def main():
if not PROFILE_DIR.exists():
print(f"Profil nenalezen: {PROFILE_DIR}")
print("Nejprve spusť outlook_login_v1.0.py.")
return
client = MongoClient(MONGO_URI)
col = client[DB_NAME][COL_NAME]
ensure_indexes(col)
mode_label = "ONLY_NEW (zastav pri duplikatu)" if ONLY_NEW else f"od {DATE_FROM.date()} (zastav pri starsim emailu)"
print(f"MongoDB: {MONGO_URI} -> {DB_NAME}.{COL_NAME}")
print(f"Rezim: {mode_label} | Max attachment: {MAX_ATTACHMENT_SIZE // 1024 // 1024} MB\n")
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=str(PROFILE_DIR),
headless=False,
no_viewport=True,
accept_downloads=True,
args=[
"--disable-blink-features=AutomationControlled",
"--start-maximized",
],
)
page = context.pages[0] if context.pages else context.new_page()
print("Otevírám Outlook...")
page.goto(START_URL)
wait_ready(page)
results = []
for folder_name, nav_type, value in FOLDERS:
print(f"\n[{folder_name}]")
folder_stats = {"saved": 0, "duplicate": 0, "skip": 0, "error": 0}
try:
navigate_to_folder(page, nav_type, value)
except Exception as e:
print(f" ! Navigace selhala: {e}")
results.append((folder_name, f"nav error: {e}"))
continue
mode_info = "ONLY_NEW" if ONLY_NEW else f"od {DATE_FROM.date()}"
print(f" rezim: {mode_info}")
for idx in range(MAX_PER_FOLDER):
print(f" email #{idx + 1} ... ", end="", flush=True)
try:
data = download_email_at_index(page, idx)
if data is None:
print("konec slozky")
break
doc = parse_eml(data, folder_name)
email_date = doc.get("date")
# ── ONLY_NEW = False: zastav při emailu starším než DATE_FROM ──
if not ONLY_NEW:
if email_date:
# normalizuj na aware datetime
if email_date.tzinfo is None:
email_date = email_date.replace(tzinfo=timezone.utc)
if email_date < DATE_FROM:
date_str = email_date.strftime("%Y-%m-%d")
print(f"prilis stary ({date_str}) -> stop")
break
status = save_doc(col, doc)
att_info = ""
if doc["has_attachments"]:
total = len(doc["attachments"])
saved_att = sum(1 for a in doc["attachments"] if a["downloaded"])
att_info = f" [{saved_att}/{total} priloh]"
date_str = email_date.strftime("%Y-%m-%d") if email_date else "?"
print(f"{status} {date_str} {doc['eml_size']:,} B {(doc['subject'] or '')[:45]}{att_info}")
if status == "saved":
folder_stats["saved"] += 1
elif status.startswith("duplicate"):
folder_stats["duplicate"] += 1
# ── ONLY_NEW = True: zastav při prvním duplikátu ──
if ONLY_NEW:
print(f" -> prvni duplikat na #{idx + 1}, stop")
break
else:
folder_stats["error"] += 1
except Exception as e:
print(f"chyba: {e}")
folder_stats["error"] += 1
page.keyboard.press("Escape")
results.append((folder_name, folder_stats))
context.close()
print("\n=== Výsledky ===")
total_saved = 0
for name, stats in results:
if isinstance(stats, dict):
print(f" {name:<25} saved={stats['saved']} dup={stats['duplicate']} skip={stats['skip']} err={stats['error']}")
total_saved += stats["saved"]
else:
print(f" {name:<25} {stats}")
total_db = col.count_documents({})
print(f"\nNově uloženo: {total_saved} | Celkem v DB: {total_db}")
client.close()
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
Traceback (most recent call last):
File "U:\PythonProject\Janssen\Outlook\import_emails_to_mongo_v1.0.py", line 370, in <module>
main()
File "U:\PythonProject\Janssen\Outlook\import_emails_to_mongo_v1.0.py", line 285, in main
ensure_indexes(col)
File "U:\PythonProject\Janssen\Outlook\import_emails_to_mongo_v1.0.py", line 249, in ensure_indexes
col.create_index([("message_id", ASCENDING)], unique=True, sparse=True,
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\collection.py", line 2391, in create_index
return (self._create_indexes([index], session, **cmd_options))[0]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\_csot.py", line 125, in csot_wrapper
return func(self, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\collection.py", line 2273, in _create_indexes
return self.database.client._retryable_write(False, inner, session, _Op.CREATE_INDEXES)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\mongo_client.py", line 2113, in _retryable_write
return self._retry_with_session(retryable, func, s, bulk, operation, operation_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\mongo_client.py", line 1986, in _retry_with_session
return self._retry_internal(
^^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\_csot.py", line 125, in csot_wrapper
return func(self, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\mongo_client.py", line 2038, in _retry_internal
).run()
^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\mongo_client.py", line 2811, in run
res = self._read() if self._is_read else self._write()
^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\mongo_client.py", line 2992, in _write
self._server = self._get_server()
^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\mongo_client.py", line 2975, in _get_server
return self._client._select_server(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\mongo_client.py", line 1851, in _select_server
server = topology.select_server(
^^^^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\topology.py", line 428, in select_server
server = self._select_server(
^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\topology.py", line 402, in _select_server
servers = self.select_servers(
^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\topology.py", line 298, in select_servers
server_descriptions = self._select_servers_loop(
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "U:\PythonProject\Janssen\.venv\Lib\site-packages\pymongo\synchronous\topology.py", line 359, in _select_servers_loop
raise ServerSelectionTimeoutError(
pymongo.errors.ServerSelectionTimeoutError: localhost:27017: [WinError 10061] No connection could be made because the target machine actively refused it (configured timeouts: socketTimeoutMS: 20000.0ms, connectTimeoutMS: 20000.0ms), Timeout: 30s, Topology Description: <TopologyDescription id: 6a2125ac7fc169e1cd7044c5, topology_type: Unknown, servers: [<ServerDescription ('localhost', 27017) server_type: Unknown, rtt: None, error=AutoReconnect('localhost:27017: [WinError 10061] No connection could be made because the target machine actively refused it (configured timeouts: socketTimeoutMS: 20000.0ms, connectTimeoutMS: 20000.0ms)')>]>
+28
View File
@@ -0,0 +1,28 @@
MongoDB: mongodb://192.168.1.76:27017 -> OperativniEmailyJNJ.messages
Emails per folder: 1 | Max attachment: 5 MB
Otevrm Outlook...
[Inbox]
email #1 ... duplicate_mid 9,822 B subj: Declined: 77242113UCO3001 LTM SMs CTA meeting week
[TMP]
email #1 ... duplicate_mid 37,158 B subj: [EXTERNAL] Re: 77242113UCO3001/DD5-CZ10022/Hrab
[Sent Items]
email #1 ... saved 44,523 B subj: Fw: ICONIC-UC - CZ100132002 randomization with mMa [2/2 ploh]
[Deleted Items]
email #1 ... duplicate_mid 23,859,184 B subj: RE: ICONIC-CD_Week I-12 data cleaning status_03Jun [15/17 ploh]
[Archive]
email #1 ... duplicate_mid 18,459 B subj: Result Alert: Sponsor: Janssen Pharmaceutica NV, P
=== Vsledky ===
Inbox saved=0 dup=1 skip=0 err=0
TMP saved=0 dup=1 skip=0 err=0
Sent Items saved=1 dup=0 skip=0 err=0
Deleted Items saved=0 dup=1 skip=0 err=0
Archive saved=0 dup=1 skip=0 err=0
Nov uloeno: 1 | Celkem v DB: 11
+38
View File
@@ -0,0 +1,38 @@
# mcp_owa_v1.3
MCP server pro OWA — drží persistent session ve **skutečném MS Edge** ve vlastním profilu `owa_mcp_profile/`.
## Změny v1.3
- **`channel="msedge"`** — Playwright spustí nainstalovaný Microsoft Edge místo bundled Chromium. Důvod: JNJ Conditional Access vrací **chybu 53003** pro neschválené prohlížeče. Edge na managed JNJ workstation má důvěryhodný OS cert, WAM SSO a je v CA policy povolený.
- **`ignore_default_args=["--enable-automation"]`** — odstraňuje flag, který signalizuje "browser je automatizovaný" (Microsoft může detekovat a blokovat).
- Konstanta `BROWSER_CHANNEL` na začátku souboru (`"msedge"` / `"chrome"` / `""` pro bundled).
## Pokud login pořád selhává
1. Zkus `BROWSER_CHANNEL = "chrome"` (vyžaduje nainstalovaný Chrome).
2. Pokud i to selže, je politika přísnější (vyžaduje Hybrid Join / Intune device cert) — pak je potřeba spustit Edge tak, aby nesl OS-level identitu. Možnosti:
- **`connect_over_cdp`** — pustíš Edge ručně s `--remote-debugging-port=9222` ze svého běžného profilu (`%LOCALAPPDATA%\Microsoft\Edge\User Data`), MCP se připojí k běžícímu oknu.
- Použít OWA přes desktopovou Outlook aplikaci místo webu.
## Změny v1.2
- Vlastní profil `owa_mcp_profile/`.
- Nový tool `login()` — počká až 5 min na ruční přihlášení.
## Změny v1.1
- Sync Playwright v dedikovaném worker threadu (Windows async + persistent context padá).
## Spuštění
```json
"owa": {
"command": "python",
"args": ["U:\\PythonProject\\Janssen\\Outlook\\mcp_owa_v1.3.py"],
"cwd": "U:\\PythonProject\\Janssen\\Outlook"
}
```
## Tools (stejné jako v1.2)
`login`, `start_owa`, `stop_owa`, `status`, `find_emails`, `find_last_email`, `open_email_by_subject`, `forward_current`, `write_at_top`, `set_recipients`, `close_reading_pane`, `screenshot`.
## TODO
- `connect_over_cdp` fallback pro tvrdé CA politiky
- `logout()` (smaže profil)
- Detekce CA error 53003 v `_login` a vrácení čitelné chyby
+446
View File
@@ -0,0 +1,446 @@
"""
=======================================================================
Název: mcp_owa_v1.3.py
Verze: 1.3
Datum: 2026-06-04
Popis: MCP server pro práci s otevřeným OWA oknem (Playwright).
Drží persistent session s vlastním profilem `owa_mcp_profile/`
a vystavuje tooly:
- login (první přihlášení; uživatel se přihlásí ručně,
profil se uloží do owa_mcp_profile/)
- vyhledání emailu v MongoDB OperativniEmailyJNJ.messages
- otevření emailu v OWA UI přes search
- Forward → vepsání úvodního textu na začátek body
- zavření původního čtecího panelu
Odeslání forwardu dělá uživatel sám.
Změny v1.3:
- Použití skutečného MS Edge (channel="msedge") místo bundled Chromium —
JNJ Conditional Access (chyba 53003) blokuje neschválené prohlížeče.
Edge na managed JNJ workstation má důvěryhodný OS cert, WAM SSO a je
v CA policy povolený.
- Odstranění `--enable-automation` flagu (signalizuje robota) přes
ignore_default_args.
- Konfigurace přes proměnnou BROWSER_CHANNEL ("msedge" / "chrome" / "").
Změny v1.2:
- Vlastní profil `owa_mcp_profile/` (nesdílí se s import_emails_to_mongo).
- Nový tool `login()` — otevře OWA pro ruční přihlášení;
po úspěšném loginu zůstane profil v owa_mcp_profile/ pro další běhy.
- `start_owa` automaticky upozorní, když profil neexistuje (-> volej login).
Změny v1.1:
- sync_playwright v dedikovaném worker threadu (Windows + async_playwright
v persistent contextu padá: Chrome se zavře hned po startu).
- FastMCP tooly synchronní, dispatch na worker přes queue + Future.
Spuštění: python mcp_owa_v1.3.py
=======================================================================
"""
import queue
import sys
import threading
from concurrent.futures import Future
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from mcp.server.fastmcp import FastMCP
from playwright.sync_api import sync_playwright, Page, BrowserContext
from pymongo import MongoClient, DESCENDING
# ── Konfigurace ────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent
PROFILE_DIR = BASE_DIR / "owa_mcp_profile" # ← vlastní profil pro tento MCP server
START_URL = "https://outlook.cloud.microsoft/mail/"
# "msedge" = skutečný MS Edge (kvůli JNJ Conditional Access)
# "chrome" = nainstalovaný Chrome
# "" = bundled Playwright Chromium
BROWSER_CHANNEL = "msedge"
MONGO_URI = "mongodb://192.168.1.76:27017"
DB_NAME = "OperativniEmailyJNJ"
COL_NAME = "messages"
SEARCH_READY = (
'[placeholder*="Search"], [aria-label*="Search"], '
'[placeholder*="Hledat"], [aria-label*="Hledat"]'
)
def log(msg: str):
print(msg, file=sys.stderr, flush=True)
# ── Worker thread (drží Playwright) ────────────────────────────────────
class PWWorker:
def __init__(self):
self.q: queue.Queue = queue.Queue()
self.ready = threading.Event()
self.pw = None
self.context: Optional[BrowserContext] = None
self.main_page: Optional[Page] = None
self.draft_page: Optional[Page] = None
self.thread = threading.Thread(target=self._run, daemon=True, name="pw-worker")
self.thread.start()
self.ready.wait(timeout=10)
def _run(self):
with sync_playwright() as p:
self.pw = p
self.ready.set()
log("pw-worker: sync_playwright ready")
while True:
item = self.q.get()
if item is None:
break
fn, args, kwargs, fut = item
try:
fut.set_result(fn(self, *args, **kwargs))
except Exception as e:
fut.set_exception(e)
def call(self, fn, *args, **kwargs):
fut: Future = Future()
self.q.put((fn, args, kwargs, fut))
return fut.result()
WORKER = PWWorker()
_mongo = MongoClient(MONGO_URI)
_col = _mongo[DB_NAME][COL_NAME]
mcp = FastMCP("owa")
# ── Helpers ────────────────────────────────────────────────────────────
def _doc_summary(doc: dict) -> dict:
return {
"message_id": doc.get("message_id"),
"subject": doc.get("subject"),
"from": doc.get("from"),
"to": doc.get("to", [])[:5],
"date": doc.get("date").isoformat() if doc.get("date") else None,
"folder": doc.get("folder"),
"has_attachments": doc.get("has_attachments", False),
}
def _wait_ready(page: Page, timeout: int = 30_000):
page.wait_for_load_state("domcontentloaded")
page.wait_for_selector(SEARCH_READY, timeout=timeout)
def _launch_context(w: PWWorker) -> BrowserContext:
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
launch_kwargs = dict(
user_data_dir=str(PROFILE_DIR),
headless=False,
no_viewport=True,
accept_downloads=True,
args=[
"--disable-blink-features=AutomationControlled",
"--start-maximized",
],
# Odstraň automatizační flag (signalizuje robota CA politikám)
ignore_default_args=["--enable-automation"],
)
if BROWSER_CHANNEL:
launch_kwargs["channel"] = BROWSER_CHANNEL
log(f"launching browser: channel={BROWSER_CHANNEL or 'bundled-chromium'}")
return w.pw.chromium.launch_persistent_context(**launch_kwargs)
# ── Worker functions ───────────────────────────────────────────────────
def _login(w: PWWorker) -> dict:
"""Otevře OWA pro ruční přihlášení. Profil se uloží do owa_mcp_profile/.
Po přihlášení uživatel volá zavřít okno (nebo nechá běžet a pokračuje)."""
if w.context is not None:
return {
"status": "already_running",
"url": w.main_page.url if w.main_page else None,
"hint": "Pokud se chceš přihlásit znovu/jiným účtem, nejprve stop_owa a smaž owa_mcp_profile/."
}
w.context = _launch_context(w)
w.main_page = w.context.pages[0] if w.context.pages else w.context.new_page()
w.main_page.goto(START_URL)
# Po loginu se objeví searchbar; čekej dlouho, uživatel může klikat
try:
_wait_ready(w.main_page, timeout=300_000) # až 5 minut na login
status_msg = "logged_in"
except Exception:
status_msg = "window_open_login_pending"
return {
"status": status_msg,
"profile_dir": str(PROFILE_DIR),
"url": w.main_page.url,
"hint": "Profil je uložen. Příště volej start_owa a budeš rovnou přihlášen.",
}
def _start(w: PWWorker) -> dict:
if w.context is not None:
return {"status": "already_running", "url": w.main_page.url if w.main_page else None}
if not PROFILE_DIR.exists() or not any(PROFILE_DIR.iterdir()):
return {
"status": "no_profile",
"error": f"Profil {PROFILE_DIR} neexistuje nebo je prázdný.",
"hint": "Zavolej nejprve `login` a přihlas se ručně v otevřeném okně.",
}
w.context = _launch_context(w)
w.main_page = w.context.pages[0] if w.context.pages else w.context.new_page()
w.main_page.goto(START_URL)
_wait_ready(w.main_page)
return {"status": "started", "url": w.main_page.url}
def _stop(w: PWWorker) -> dict:
if w.context is None:
return {"status": "not_running"}
try:
w.context.close()
finally:
w.context = None
w.main_page = None
w.draft_page = None
return {"status": "stopped"}
def _status(w: PWWorker) -> dict:
profile_exists = PROFILE_DIR.exists() and any(PROFILE_DIR.iterdir())
if w.context is None:
return {"running": False, "profile_exists": profile_exists, "profile_dir": str(PROFILE_DIR)}
return {
"running": True,
"url": w.main_page.url if w.main_page else None,
"tabs": len(w.context.pages),
"draft_open": w.draft_page is not None and not w.draft_page.is_closed(),
"profile_exists": profile_exists,
"profile_dir": str(PROFILE_DIR),
}
def _open_by_subject(w: PWWorker, subject: str) -> dict:
if w.context is None:
return {"status": "not_running"}
page = w.main_page
search = page.locator(SEARCH_READY).first
search.click()
search.fill("")
search.type(subject, delay=20)
page.keyboard.press("Enter")
page.wait_for_timeout(2_000)
msgs = page.locator('div[role="option"]')
try:
msgs.first.wait_for(state="visible", timeout=10_000)
except Exception:
return {"status": "no_results"}
msgs.first.click()
page.wait_for_timeout(800)
return {"status": "opened", "count_visible": msgs.count()}
def _forward(w: PWWorker, body_prefix: str = "", subject_prefix: str = "") -> dict:
if w.context is None:
return {"status": "not_running"}
page = w.main_page
page.keyboard.press("Control+Shift+F")
page.wait_for_timeout(1_500)
body = page.locator(
'div[aria-label*="Message body" i][contenteditable="true"], '
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
).first
try:
body.wait_for(state="visible", timeout=10_000)
except Exception:
return {"status": "forward_failed", "hint": "Body editor forwardu nenalezen."}
w.draft_page = page
if subject_prefix:
subj = page.locator(
'input[aria-label*="subject" i], input[aria-label*="předmět" i]'
).first
try:
subj.wait_for(state="visible", timeout=5_000)
current = subj.input_value()
subj.fill(f"{subject_prefix}{current}")
except Exception:
pass
if body_prefix:
body.click()
page.keyboard.press("Control+Home")
page.keyboard.type(body_prefix, delay=10)
page.keyboard.press("Enter")
return {"status": "forward_ready"}
def _write_at_top(w: PWWorker, text: str) -> dict:
if w.draft_page is None or w.draft_page.is_closed():
return {"status": "no_draft"}
page = w.draft_page
body = page.locator(
'div[aria-label*="Message body" i][contenteditable="true"], '
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
).first
body.click()
page.keyboard.press("Control+Home")
page.keyboard.type(text, delay=10)
return {"status": "written", "chars": len(text)}
def _set_recipients(w: PWWorker, to: list[str], cc: Optional[list[str]] = None) -> dict:
if w.draft_page is None or w.draft_page.is_closed():
return {"status": "no_draft"}
page = w.draft_page
to_field = page.locator(
'div[aria-label*="To" i][contenteditable="true"], '
'div[aria-label*="Komu" i][contenteditable="true"]'
).first
to_field.click()
page.keyboard.type("; ".join(to) + ";", delay=15)
page.wait_for_timeout(500)
if cc:
cc_field = page.locator(
'div[aria-label*="Cc" i][contenteditable="true"], '
'div[aria-label*="Kopie" i][contenteditable="true"]'
).first
if cc_field.count():
cc_field.click()
page.keyboard.type("; ".join(cc) + ";", delay=15)
return {"status": "filled", "to": to, "cc": cc or []}
def _close_pane(w: PWWorker) -> dict:
if w.context is None:
return {"status": "not_running"}
w.main_page.keyboard.press("Escape")
w.main_page.wait_for_timeout(300)
return {"status": "closed"}
def _screenshot(w: PWWorker, path: str) -> dict:
if w.context is None:
return {"status": "not_running"}
out = (BASE_DIR / path).resolve()
w.main_page.screenshot(path=str(out), full_page=False)
return {"status": "ok", "path": str(out)}
# ── MCP tooly ──────────────────────────────────────────────────────────
@mcp.tool()
def login() -> dict:
"""První přihlášení (nebo přihlášení jiným účtem). Otevře OWA okno
a počká až 5 minut, než dokončíš login ručně. Profil se uloží do
owa_mcp_profile/ a příště stačí volat start_owa."""
return WORKER.call(_login)
@mcp.tool()
def start_owa() -> dict:
"""Spustí Playwright s persistent profilem a otevře OWA. Pokud profil neexistuje,
vrátí no_profile — pak zavolej login."""
return WORKER.call(_start)
@mcp.tool()
def stop_owa() -> dict:
"""Zavře Playwright context (a tím i okno OWA). Profil zůstane uložený."""
return WORKER.call(_stop)
@mcp.tool()
def status() -> dict:
"""Vrátí stav session: běží/neběží, URL, počet tabů, draft otevřen, existuje profil?"""
return WORKER.call(_status)
@mcp.tool()
def find_emails(
query: Optional[str] = None,
from_email: Optional[str] = None,
folder: Optional[str] = None,
since_iso: Optional[str] = None,
limit: int = 10,
) -> list[dict]:
"""Hledá v MongoDB. `query` = substring v subjectu (case-insensitive).
`since_iso` = ISO datum, vrátí jen emaily od něj. Seřazeno od nejnovějšího."""
flt: dict = {}
if query:
flt["subject"] = {"$regex": query, "$options": "i"}
if from_email:
flt["from.email"] = {"$regex": from_email, "$options": "i"}
if folder:
flt["folder"] = folder
if since_iso:
try:
dt = datetime.fromisoformat(since_iso.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
flt["date"] = {"$gte": dt}
except Exception:
pass
cur = _col.find(flt).sort("date", DESCENDING).limit(max(1, min(limit, 50)))
return [_doc_summary(d) for d in cur]
@mcp.tool()
def find_last_email(from_email: Optional[str] = None, folder: Optional[str] = None) -> Optional[dict]:
"""Vrátí nejnovější email (volitelně filtr podle odesílatele / složky)."""
flt: dict = {}
if from_email:
flt["from.email"] = {"$regex": from_email, "$options": "i"}
if folder:
flt["folder"] = folder
docs = list(_col.find(flt).sort("date", DESCENDING).limit(1))
return _doc_summary(docs[0]) if docs else None
@mcp.tool()
def open_email_by_subject(subject: str) -> dict:
"""Vyhledá v OWA podle subjectu a otevře první výsledek v reading pane."""
return WORKER.call(_open_by_subject, subject)
@mcp.tool()
def forward_current(body_prefix: str = "", subject_prefix: str = "") -> dict:
"""Klikne Forward (Ctrl+Shift+F). Pokud je `body_prefix`, vepíše ho na začátek body.
Pokud `subject_prefix`, předřadí ho do předmětu draftu."""
return WORKER.call(_forward, body_prefix, subject_prefix)
@mcp.tool()
def write_at_top(text: str) -> dict:
"""Vepíše text na začátek body otevřeného draftu (před existující obsah / podpis)."""
return WORKER.call(_write_at_top, text)
@mcp.tool()
def set_recipients(to: list[str], cc: Optional[list[str]] = None) -> dict:
"""Vyplní To / Cc v otevřeném draftu."""
return WORKER.call(_set_recipients, to, cc)
@mcp.tool()
def close_reading_pane() -> dict:
"""Zavře otevřený email v reading pane (Escape). Forward draft tím nezavře."""
return WORKER.call(_close_pane)
@mcp.tool()
def screenshot(path: str = "owa_screenshot.png") -> dict:
"""Uloží screenshot aktivního okna pro orientaci."""
return WORKER.call(_screenshot, path)
if __name__ == "__main__":
log("mcp_owa v1.3 starting (stdio)…")
mcp.run()
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1 @@
level=none expiry=0
Binary file not shown.
@@ -0,0 +1 @@
MANIFEST-000001
@@ -0,0 +1,2 @@
2026/06/04-10:07:47.517 5810 Creating DB U:\PythonProject\Janssen\Outlook\owa_mcp_profile\Default\Asset Store\assets.db since it was missing.
2026/06/04-10:07:48.387 5810 Reusing MANIFEST U:\PythonProject\Janssen\Outlook\owa_mcp_profile\Default\Asset Store\assets.db/MANIFEST-000001
@@ -0,0 +1,2 @@
{
}
@@ -0,0 +1,12 @@
{
"epochs": [ {
"calculation_time": "13425034067255785",
"config_version": 0,
"model_version": "0",
"padded_top_topics_start_index": 0,
"taxonomy_version": 0,
"top_topics_and_observing_domains": [ ]
} ],
"hex_encoded_hmac_key": "DFF3EE606AC498018F9C872B4D7BBEB78818CA275651F74A24F9C38C564BDAEE",
"next_scheduled_calculation_time": "13425638867255829"
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1 @@
$F~

Some files were not shown because too many files have changed in this diff Show More