z230
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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`
|
||||||
@@ -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()
|
||||||
@@ -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`
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)')>]>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
MongoDB: mongodb://192.168.1.76:27017 -> OperativniEmailyJNJ.messages
|
||||||
|
Emails per folder: 1 | Max attachment: 5 MB
|
||||||
|
|
||||||
|
Otev�r�m 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 p��loh]
|
||||||
|
|
||||||
|
[Deleted Items]
|
||||||
|
email #1 ... duplicate_mid 23,859,184 B subj: RE: ICONIC-CD_Week I-12 data cleaning status_03Jun [15/17 p��loh]
|
||||||
|
|
||||||
|
[Archive]
|
||||||
|
email #1 ... duplicate_mid 18,459 B subj: Result Alert: Sponsor: Janssen Pharmaceutica NV, P
|
||||||
|
|
||||||
|
=== V�sledky ===
|
||||||
|
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� ulo�eno: 1 | Celkem v DB: 11
|
||||||
@@ -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
|
||||||
@@ -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.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
level=none expiry=0
|
||||||
Binary file not shown.
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
|
||||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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"
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 290 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
$F~
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user