This commit is contained in:
2026-06-19 14:28:20 +02:00
parent c9e592d58f
commit 1bc7950520
43 changed files with 802 additions and 21038 deletions
+88
View File
@@ -0,0 +1,88 @@
# mcp_vbcz_email_v1.6.md
**Verze:** 1.6
**Datum:** 2026-06-09
**Soubor:** `OutlookVBCZ/mcp_vbcz_email_v1.6.py`
## Popis
MCP server pro schránku **vladimir.buzalka@buzalka.cz** přes Microsoft Graph API (application permissions, tenant TrialHelp s.r.o., app **PythonMailer** `4b222bfd-…`). Sdílí credentials s `Knihovny/EmailMessagingGraph.py`.
## Tooly
| Tool | Popis |
|------|-------|
| `list_emails` | Seznam posledních emailů (folder, limit, search, from_email, unread_only) |
| `get_email` | Plné tělo + metadata emailu podle `message_id` |
| `list_attachments` | Seznam příloh emailu |
| `get_attachment` | Stáhne přílohu do `downloads/`, vrátí cestu |
| `create_draft_eml` | Vygeneruje `.eml` draft (X-Unsent) k ruční kontrole a odeslání (+ přílohy, + forward originálu, + odříznutí self-forward obalu) |
| `create_event` | **(v1.6)** Vytvoří událost v kalendáři schránky (+ připomínka) |
| `create_contact` | **(v1.6)** Založí kontakt ve schránce |
## Důležité — kam co jde
- **E-maily lékařům / CTA** = i nadále **`.eml` drafty** (`create_draft_eml`), které se odesílají přes **JNJ server** (vbuzalka@its.jnj.com). NE přes tuto osobní schránku.
- **Kalendář (připomínky) + kontakty** = `create_event` / `create_contact` píšou **přímo do osobní schránky** vladimir.buzalka@buzalka.cz (Graph POST).
## create_event
```
create_event(
subject, # název události
start, # ISO bez offsetu, lokální čas: "2026-06-11T09:00:00"
end=None, # ISO; když None, dopočítá z duration_minutes
duration_minutes=30,
body=None, # poznámka (plain text nebo HTML)
location=None, # místo, např. "tel. 0911 926 046"
reminder_minutes_before=0, # připomínka N min předem (0 = v čase začátku)
timezone="Central European Standard Time",
is_all_day=False,
body_is_html=False,
) -> {id, web_link, subject, start, end, reminder_minutes_before}
```
Vyžaduje **Calendars.ReadWrite** (application). Připomínka je vždy zapnutá.
## create_contact
```
create_contact(
given_name, surname, # povinné
display_name=None, # def "<given> <surname>"
email=None,
mobile_phone=None,
business_phone=None,
company=None, # firma / pracoviště
job_title=None,
note=None, # personalNotes
) -> {id, display_name, email}
```
Vyžaduje **Contacts.ReadWrite** (application).
## Graph oprávnění (app PythonMailer) — Granted 2026-06-09
- Mail.ReadWrite (Application)
- Mail.Send (Application)
- Calendars.ReadWrite (Application) ← v1.6
- Contacts.ReadWrite (Application) ← v1.6
- User.Read (Delegated)
## Kódování draftů (od v1.3)
- Tělo i plain fallback → **base64** (`cte="base64"`), zápis přes **`policy.SMTP`** (CRLF) → správná diakritika v Outlooku.
## Konfigurace `.mcp.json`
```json
"vbcz-email": {
"command": "python",
"args": ["U:\\PythonProject\\Janssen\\OutlookVBCZ\\mcp_vbcz_email_v1.6.py"],
"cwd": "U:\\PythonProject\\Janssen\\OutlookVBCZ"
}
```
## Historie verzí
- **v1.0** — list_emails, get_email, list_attachments, get_attachment
- **v1.1** — + create_draft_eml
- **v1.2** — create_draft_eml: + `attachments`
- **v1.3** — oprava kódování: base64 cte + CRLF (policy.SMTP)
- **v1.4** — create_draft_eml: + `original_message_id` (forward styl)
- **v1.5** — create_draft_eml: + `strip_self_forward` (default True)
- **v1.6** — + `create_event` (kalendář + připomínka), + `create_contact`; helper `_graph_post`; přidána oprávnění Calendars.ReadWrite + Contacts.ReadWrite. E-maily lékařům zůstávají jako `.eml` drafty (JNJ server).
+704
View File
@@ -0,0 +1,704 @@
"""
=======================================================================
Název: mcp_vbcz_email_v1.6.py
Verze: 1.6
Datum: 2026-06-09
Popis: MCP server pro schránku vladimir.buzalka@buzalka.cz
přes Microsoft Graph API (application permissions).
Credentials sdílí s EmailMessagingGraph.py (Knihovny/).
Tooly:
- list_emails — seznam posledních emailů (+ preview)
- get_email — plné tělo + metadata emailu
- list_attachments — přílohy emailu (název, velikost, typ)
- get_attachment — stáhne přílohu na disk, vrátí cestu
- create_draft_eml — vygeneruje .eml draft (X-Unsent) k ruční
kontrole a odeslání v Outlooku (+ přílohy)
- create_event — vytvoří událost v kalendáři (+ připomínka)
- create_contact — vytvoří kontakt ve schránce
Změny v1.1: + create_draft_eml (generování rozepsaných emailů)
Změny v1.2: + create_draft_eml: parametr `attachments`
Změny v1.3: * OPRAVA kódování: base64 (cte) + policy.SMTP (CRLF)
Změny v1.4: + create_draft_eml: `original_message_id` (forward styl)
Změny v1.5: + create_draft_eml: `strip_self_forward` (default True)
Změny v1.6:
+ create_event — zapíše událost do kalendáře schránky
(start/end v lokálním čase, časové pásmo, připomínka N minut
předem). Vrací id + webLink. Vyžaduje Graph oprávnění
Calendars.ReadWrite (application).
+ create_contact — založí kontakt ve schránce (jméno, e-mail,
telefony, firma, funkce, poznámka). Vrací id. Vyžaduje
Contacts.ReadWrite (application).
+ helper _graph_post (POST na Graph).
POZN.: e-maily lékařům/CTA se i nadále generují jako .eml drafty
(odesílají se přes JNJ server), NE přes tuto schránku. Kalendář
a kontakty se píšou do osobní schránky vladimir.buzalka@buzalka.cz.
Spuštění: python mcp_vbcz_email_v1.6.py
=======================================================================
"""
import base64
import mimetypes
import sys
from datetime import datetime
from email.message import EmailMessage
from email.policy import SMTP as SMTP_POLICY
from functools import lru_cache
from pathlib import Path
from typing import Optional, Union
import msal
import requests
from mcp.server.fastmcp import FastMCP
# ── Konfigurace ────────────────────────────────────────────────────────
TENANT_ID = "7d269944-37a4-43a1-8140-c7517dc426e9"
CLIENT_ID = "4b222bfd-78c9-4239-a53f-43006b3ed07f"
CLIENT_SECRET = "Txg8Q~MjhocuopxsJyJBhPmDfMxZ2r5WpTFj1dfk"
MAILBOX = "vladimir.buzalka@buzalka.cz"
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
SCOPE = ["https://graph.microsoft.com/.default"]
GRAPH_BASE = f"https://graph.microsoft.com/v1.0/users/{MAILBOX}"
# Výchozí časové pásmo pro události kalendáře (Windows TZ název pro Graph)
DEFAULT_TIMEZONE = "Central European Standard Time"
DOWNLOADS_DIR = Path(__file__).resolve().parent / "downloads"
DOWNLOADS_DIR.mkdir(exist_ok=True)
# Výchozí cíl pro generované draft .eml soubory
DEFAULT_DRAFT_DIR = Path(r"u:\Dropbox\!!!Days\Downloads Z230")
# Výchozí odesílatel + standardní podpis (ICON / Janssen)
DEFAULT_FROM = "vbuzalka@its.jnj.com"
SIGNATURE_HTML = """
<p>S pozdravem</p>
<p><strong>MUDr. Vladim&iacute;r BUZALKA</strong></p>
<p style="font-size:10pt; color:#444;">
ICON plc<br>
Performing Local Trial Management Services for Janssen &ndash; Cilag s.r.o.<br>
Global Clinical Operations<br>
Mobile: +420 775 735 276<br>
Fax: +420 227 012 284<br>
E-mail: <a href="mailto:vbuzalka@its.jnj.com">vbuzalka@its.jnj.com</a>,
<a href="mailto:vladimir.buzalka@iconplc.com">vladimir.buzalka@iconplc.com</a>
</p>
"""
SIGNATURE_TEXT = (
"\nS pozdravem\n\n"
"MUDr. Vladimír BUZALKA\n"
"ICON plc\n"
"Performing Local Trial Management Services for Janssen Cilag s.r.o.\n"
"Global Clinical Operations\n"
"Mobile: +420 775 735 276\n"
"Fax: +420 227 012 284\n"
"E-mail: vbuzalka@its.jnj.com, vladimir.buzalka@iconplc.com\n"
)
mcp = FastMCP("vbcz-email")
def log(msg: str):
print(msg, file=sys.stderr, flush=True)
@lru_cache(maxsize=1)
def _get_token() -> str:
app = msal.ConfidentialClientApplication(
CLIENT_ID,
authority=AUTHORITY,
client_credential=CLIENT_SECRET,
)
token = app.acquire_token_for_client(scopes=SCOPE)
if "access_token" not in token:
raise RuntimeError(f"Graph auth failed: {token}")
return token["access_token"]
def _headers() -> dict:
# lru_cache drží token dokud server běží; Graph tokeny platí 1 hod
return {
"Authorization": f"Bearer {_get_token()}",
"Content-Type": "application/json",
}
def _graph_get(path: str, params: dict = None) -> dict:
r = requests.get(
f"{GRAPH_BASE}{path}",
headers=_headers(),
params=params or {},
timeout=30,
)
if not r.ok:
raise RuntimeError(f"Graph error [{r.status_code}]: {r.text[:300]}")
return r.json()
def _graph_post(path: str, payload: dict) -> dict:
r = requests.post(
f"{GRAPH_BASE}{path}",
headers=_headers(),
json=payload,
timeout=30,
)
if not r.ok:
raise RuntimeError(f"Graph error [{r.status_code}]: {r.text[:400]}")
return r.json()
def _msg_summary(m: dict) -> dict:
sender = m.get("from", {}).get("emailAddress", {})
return {
"id": m.get("id"),
"subject": m.get("subject"),
"from": sender.get("address"),
"from_name": sender.get("name"),
"date": m.get("receivedDateTime"),
"is_read": m.get("isRead"),
"has_attachments": m.get("hasAttachments"),
"preview": m.get("bodyPreview", "")[:200],
"folder": m.get("parentFolderId"),
}
# ── MCP tooly ──────────────────────────────────────────────────────────
@mcp.tool()
def list_emails(
folder: str = "inbox",
limit: int = 10,
search: Optional[str] = None,
from_email: Optional[str] = None,
unread_only: bool = False,
) -> list[dict]:
"""Vrátí seznam posledních emailů ze schránky vladimir.buzalka@buzalka.cz.
- folder: 'inbox' / 'sentitems' / 'drafts' / 'deleteditems' / ID složky
- limit: max počet (150)
- search: fulltext hledání (subject nebo tělo)
- from_email: filtr odesílatele (substring)
- unread_only: jen nepřečtené
"""
limit = max(1, min(limit, 50))
filters = []
if from_email:
filters.append(f"contains(from/emailAddress/address,'{from_email}')")
if unread_only:
filters.append("isRead eq false")
params = {
"$top": limit,
"$orderby": "receivedDateTime desc",
"$select": "id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,parentFolderId",
}
if filters:
params["$filter"] = " and ".join(filters)
if search:
params["$search"] = f'"{search}"'
params.pop("$filter", None) # $search a $filter nelze kombinovat
data = _graph_get(f"/mailFolders/{folder}/messages", params)
return [_msg_summary(m) for m in data.get("value", [])]
@mcp.tool()
def get_email(message_id: str) -> dict:
"""Načte plný email (tělo + metadata) podle message_id z list_emails."""
m = _graph_get(f"/messages/{message_id}")
body = m.get("body", {})
sender = m.get("from", {}).get("emailAddress", {})
to_list = [
r["emailAddress"]["address"]
for r in m.get("toRecipients", [])
]
return {
"id": m.get("id"),
"subject": m.get("subject"),
"from": sender.get("address"),
"from_name": sender.get("name"),
"to": to_list,
"date": m.get("receivedDateTime"),
"is_read": m.get("isRead"),
"has_attachments": m.get("hasAttachments"),
"body_type": body.get("contentType"), # Text / HTML
"body": body.get("content", ""),
"folder": m.get("parentFolderId"),
}
@mcp.tool()
def list_attachments(message_id: str) -> list[dict]:
"""Vrátí seznam příloh emailu (název, velikost, contentType, attachmentId)."""
data = _graph_get(f"/messages/{message_id}/attachments",
{"$select": "id,name,size,contentType,isInline"})
return [
{
"attachment_id": a.get("id"),
"name": a.get("name"),
"size_bytes": a.get("size"),
"content_type": a.get("contentType"),
"is_inline": a.get("isInline"),
}
for a in data.get("value", [])
]
@mcp.tool()
def get_attachment(message_id: str, attachment_id: str) -> dict:
"""Stáhne přílohu na disk (downloads/) a vrátí cestu + velikost.
attachment_id získáš z list_attachments."""
a = _graph_get(f"/messages/{message_id}/attachments/{attachment_id}")
content_b64 = a.get("contentBytes")
if not content_b64:
raise RuntimeError("Příloha neobsahuje contentBytes (možná referenční typ).")
data = base64.b64decode(content_b64)
name = a.get("name", "attachment")
out_path = DOWNLOADS_DIR / name
# Pokud soubor existuje, přidej suffix
stem, suffix = out_path.stem, out_path.suffix
counter = 1
while out_path.exists():
out_path = DOWNLOADS_DIR / f"{stem}_{counter}{suffix}"
counter += 1
out_path.write_bytes(data)
log(f"saved attachment: {out_path}")
return {
"path": str(out_path),
"name": name,
"size_bytes": len(data),
"content_type": a.get("contentType"),
}
def _slug(text: str, maxlen: int = 40) -> str:
"""Bezpečný kousek názvu souboru z textu (jen ASCII, _ místo zbytku)."""
keep = []
for ch in text:
if ch.isalnum():
keep.append(ch)
elif ch in " -_":
keep.append("_")
s = "".join(keep).strip("_")
# zkrátit vícenásobné podtržítka
while "__" in s:
s = s.replace("__", "_")
return s[:maxlen] or "draft"
def _parse_attachments(attachments: Union[str, list, None]) -> list[Path]:
"""Z parametru attachments (str s ; nebo seznam) udělá seznam ověřených cest."""
if not attachments:
return []
if isinstance(attachments, str):
# akceptuj oddělovač ; nebo nový řádek
raw = [p.strip() for p in attachments.replace("\n", ";").split(";")]
else:
raw = [str(p).strip() for p in attachments]
paths = []
for p in raw:
if not p:
continue
fp = Path(p)
if not fp.exists():
raise FileNotFoundError(f"Příloha nenalezena: {p}")
if not fp.is_file():
raise ValueError(f"Příloha není soubor: {p}")
paths.append(fp)
return paths
def _strip_html_wrapper(html: str) -> str:
"""Vytáhne vnitřek <body>…</body> (nebo vrátí původní), aby šel vnořit."""
import re
m = re.search(r"<body[^>]*>(.*)</body>", html, re.IGNORECASE | re.DOTALL)
if m:
return m.group(1)
# bez body tagu — odstraň aspoň <html>/<head>
html = re.sub(r"</?html[^>]*>", "", html, flags=re.IGNORECASE)
html = re.sub(r"<head[^>]*>.*?</head>", "", html, flags=re.IGNORECASE | re.DOTALL)
return html
def _esc(text: str) -> str:
"""Minimální HTML escape pro hlavičkové hodnoty."""
return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
def _extract_inner_forward_html(inner_html: str) -> Optional[str]:
"""Z přeposlaného (FW) HTML těla vytáhne JEN vnitřní forwardnutý originál:
najde oddělovač forward hlavičky ("From:" blok, příp. border-top div)
a vrátí fragment od něj dál. Tím odřízne horní self-forward obal
(prázdný forward + duplicitní podpis odesílatele). None = nenalezeno."""
import re
# 1) preferuj Outlook oddělovač (border-top) bezprostředně před "From:"
cut = -1
mfrom = re.search(r"(?is)(?:<b>\s*)?From\s*:", inner_html)
if mfrom:
idx = mfrom.start()
# najdi nejbližší otevírací blokový tag před "From:" (div má přednost)
d = inner_html.rfind("<div", 0, idx + 1)
p = inner_html.rfind("<p", 0, idx + 1)
cut = max(d, p)
if cut == -1:
cut = idx
if cut == -1:
return None
return inner_html[cut:]
def _extract_inner_forward_text(text: str) -> Optional[str]:
"""Textová varianta — vrátí vše od prvního řádku začínajícího 'From:'."""
import re
m = re.search(r"(?im)^\s*From\s*:", text)
if not m:
return None
return text[m.start():]
def _build_quote_block(message_id: str, fmt: str,
strip_self_forward: bool = False) -> str:
"""Stáhne původní email z Graphu a sestaví forward-style blok
(oddělovač + From/Sent/To/Subject + původní tělo).
strip_self_forward=True: pokud je `message_id` email přeposlaný k sobě
(FW:), vloží JEN vnitřní skutečně odeslaný originál (i s jeho vlastní
From/Sent/To/Subject hlavičkou) — odřízne horní self-forward obal.
Když vnitřní forward nenajde, spadne zpět na standardní chování."""
m = _graph_get(f"/messages/{message_id}")
body = m.get("body", {})
orig_type = (body.get("contentType") or "text").lower() # 'html' / 'text'
orig_content = body.get("content", "")
if strip_self_forward:
if orig_type == "html":
frag = _extract_inner_forward_html(_strip_html_wrapper(orig_content))
else:
frag = _extract_inner_forward_text(orig_content)
if frag:
return frag # už obsahuje vlastní From/Sent/To/Subject + originál
sender = m.get("from", {}).get("emailAddress", {})
from_name = sender.get("name", "") or ""
from_addr = sender.get("address", "") or ""
to_list = ", ".join(
r["emailAddress"]["address"] for r in m.get("toRecipients", [])
)
cc_list = ", ".join(
r["emailAddress"]["address"] for r in m.get("ccRecipients", [])
)
sent = m.get("sentDateTime") or m.get("receivedDateTime") or ""
subj = m.get("subject", "") or ""
if fmt == "html":
hdr = (
'<div style="border:none;border-top:solid #E1E1E1 1.0pt;'
'padding:6.0pt 0in 0in 0in;margin-top:18px;">'
f'<p style="font-size:11pt;"><b>From:</b> {_esc(from_name)} '
f'&lt;{_esc(from_addr)}&gt;<br>'
f'<b>Sent:</b> {_esc(sent)}<br>'
f'<b>To:</b> {_esc(to_list)}<br>'
)
if cc_list:
hdr += f'<b>Cc:</b> {_esc(cc_list)}<br>'
hdr += f'<b>Subject:</b> {_esc(subj)}</p></div>'
if orig_type == "html":
orig_html = _strip_html_wrapper(orig_content)
else:
orig_html = "<pre style=\"font-family:inherit;white-space:pre-wrap;\">" \
+ _esc(orig_content) + "</pre>"
return hdr + orig_html
else:
lines = [
"",
"________________________________",
f"From: {from_name} <{from_addr}>",
f"Sent: {sent}",
f"To: {to_list}",
]
if cc_list:
lines.append(f"Cc: {cc_list}")
lines.append(f"Subject: {subj}")
lines.append("")
# pokud je originál HTML, dej aspoň hrubý text (bez tagů)
if orig_type == "html":
import re
txt = re.sub(r"<[^>]+>", "", orig_content)
else:
txt = orig_content
lines.append(txt)
return "\n".join(lines)
@mcp.tool()
def create_draft_eml(
to: str,
subject: str,
body: str,
cc: Optional[str] = None,
attachments: Optional[str] = None,
original_message_id: Optional[str] = None,
strip_self_forward: bool = True,
from_addr: str = DEFAULT_FROM,
body_format: str = "html",
add_signature: bool = True,
filename: Optional[str] = None,
output_dir: Optional[str] = None,
) -> dict:
"""Vygeneruje .eml draft (X-Unsent: 1) připravený k ruční kontrole a
odeslání v Outlooku. Po dvojkliku Outlook otevře rozepsaný email
s předvyplněnými To/CC/Subject/tělem/přílohami — uživatel zkontroluje
a pošle.
- to: příjemce (jeden) nebo více oddělených čárkou
- subject: předmět — libovolný text (diakritika OK), klidně
s prefixem typu "[Připomínka] RE: …"
- body: tělo emailu — JEN vnitřní obsah; u HTML stačí <p>…</p>,
plain text se zalomí dle řádků
- cc: kopie, oddělené čárkou (volitelné)
- attachments: cesta k souboru nebo více cest oddělených ';'
(např. "C:\\a.pdf;C:\\b.docx"). MIME typ se detekuje sám.
- original_message_id: message_id původního emailu (z list_emails).
Když je zadán, původní email se vloží POD nový text ve
forward stylu (oddělovač + From/Sent/To/Subject + tělo) —
tj. připomínka nahoře, zachovaný originál pod ní.
- strip_self_forward: (default True) když je `original_message_id`
email přeposlaný k sobě (FW:), odřízne horní self-forward
obal (prázdný forward + duplicitní podpis + FW hlavičku
k sobě) a vloží JEN vnitřní skutečně odeslaný originál.
Když vnitřní forward nenajde, ocituje celý email (v1.4).
- from_addr: odesílatel (default vbuzalka@its.jnj.com)
- body_format: 'html' (default) nebo 'text'
- add_signature:připojí standardní ICON/Janssen podpis (default True)
- filename: vlastní název .eml (default draft_<slug>_<timestamp>.eml)
- output_dir: cílová složka (default Dropbox/Downloads Z230)
Vrací: {path, to, cc, subject, attachments: [{name, size_bytes}],
quoted_original: bool}
"""
fmt = (body_format or "html").lower().strip()
if fmt not in ("html", "text"):
raise ValueError("body_format musí být 'html' nebo 'text'")
att_paths = _parse_attachments(attachments)
# Forward-style blok s původním emailem (pod nový text)
quote_block = ""
if original_message_id:
quote_block = _build_quote_block(
original_message_id, fmt, strip_self_forward=strip_self_forward
)
out_dir = Path(output_dir) if output_dir else DEFAULT_DRAFT_DIR
out_dir.mkdir(parents=True, exist_ok=True)
# Sestavení těla
if fmt == "html":
content = body
if add_signature:
content = f"{content}\n{SIGNATURE_HTML}"
if quote_block:
content = f"{content}\n{quote_block}"
html_doc = (
'<html><head>'
'<meta http-equiv="Content-Type" content="text/html; charset=utf-8">'
'</head>'
'<body style="font-family: Aptos, Arial, sans-serif; font-size: 11pt;">'
f"{content}"
'</body></html>'
)
msg = EmailMessage()
# base64 (cte) vyhne se quoted-printable soft-breakům, které
# Outlook mršil (zbytky =C4/=C3, rozbitá diakritika, "<=a>")
msg.set_content("Tento e-mail vyžaduje HTML zobrazení.",
charset="utf-8", cte="base64") # plain fallback
msg.add_alternative(html_doc, subtype="html",
charset="utf-8", cte="base64")
else:
content = body
if add_signature:
content = f"{content}\n{SIGNATURE_TEXT}"
if quote_block:
content = f"{content}\n{quote_block}"
msg = EmailMessage()
msg.set_content(content, charset="utf-8", cte="base64")
# Hlavičky
msg["From"] = from_addr
msg["To"] = to
if cc:
msg["Cc"] = cc
msg["Subject"] = subject
msg["X-Unsent"] = "1" # Outlook otevře jako rozepsaný (draft)
# Přílohy
attached = []
for fp in att_paths:
ctype, encoding = mimetypes.guess_type(str(fp))
if ctype is None or encoding is not None:
ctype = "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
data = fp.read_bytes()
msg.add_attachment(data, maintype=maintype, subtype=subtype,
filename=fp.name)
attached.append({"name": fp.name, "size_bytes": len(data)})
# Název souboru
if filename:
name = filename if filename.lower().endswith(".eml") else f"{filename}.eml"
else:
ts = datetime.now().strftime("%Y%m%d_%H%M")
name = f"draft_{_slug(subject)}_{ts}.eml"
out_path = out_dir / name
stem, suffix = out_path.stem, out_path.suffix
counter = 1
while out_path.exists():
out_path = out_dir / f"{stem}_{counter}{suffix}"
counter += 1
# SMTP policy → CRLF konce řádků (MIME standard); Outlook tak korektně
# dekóduje base64 i hlavičky
out_path.write_bytes(msg.as_bytes(policy=SMTP_POLICY))
log(f"draft .eml created: {out_path} ({len(attached)} příloh)")
return {
"path": str(out_path),
"to": to,
"cc": cc or "",
"subject": subject,
"attachments": attached,
"quoted_original": bool(quote_block),
}
@mcp.tool()
def create_event(
subject: str,
start: str,
end: Optional[str] = None,
duration_minutes: int = 30,
body: Optional[str] = None,
location: Optional[str] = None,
reminder_minutes_before: int = 0,
timezone: str = DEFAULT_TIMEZONE,
is_all_day: bool = False,
body_is_html: bool = False,
) -> dict:
"""Vytvoří událost v kalendáři schránky vladimir.buzalka@buzalka.cz
(Graph POST /events). Vyžaduje Calendars.ReadWrite (application).
- subject: název události
- start: začátek v LOKÁLNÍM čase, ISO bez offsetu,
např. "2026-06-11T09:00:00"
- end: konec (ISO bez offsetu). Když je None, dopočítá
se z `duration_minutes`.
- duration_minutes: délka v minutách, když není zadán `end` (def 30)
- body: poznámka k události (plain text nebo HTML)
- location: místo (např. "tel. 0911 926 046")
- reminder_minutes_before: připomínka N minut před začátkem (0 = v čase
začátku). Připomínka je vždy zapnutá.
- timezone: Windows TZ název (def "Central European
Standard Time")
- is_all_day: celodenní událost (start/end musí být půlnoci)
- body_is_html: True = `body` je HTML, jinak plain text
Vrací: {id, web_link, subject, start, end, reminder_minutes_before}
"""
from datetime import datetime as _dt, timedelta as _td
if end:
end_val = end
else:
try:
st = _dt.fromisoformat(start)
except ValueError:
raise ValueError("start musí být ISO formát, např. 2026-06-11T09:00:00")
end_val = (st + _td(minutes=max(1, duration_minutes))).isoformat()
payload = {
"subject": subject,
"start": {"dateTime": start, "timeZone": timezone},
"end": {"dateTime": end_val, "timeZone": timezone},
"isAllDay": bool(is_all_day),
"isReminderOn": True,
"reminderMinutesBeforeStart": max(0, int(reminder_minutes_before)),
}
if body is not None:
payload["body"] = {
"contentType": "HTML" if body_is_html else "Text",
"content": body,
}
if location:
payload["location"] = {"displayName": location}
ev = _graph_post("/events", payload)
log(f"event created: {ev.get('id')}{subject}")
return {
"id": ev.get("id"),
"web_link": ev.get("webLink"),
"subject": ev.get("subject"),
"start": ev.get("start"),
"end": ev.get("end"),
"reminder_minutes_before": ev.get("reminderMinutesBeforeStart"),
}
@mcp.tool()
def create_contact(
given_name: str,
surname: str,
display_name: Optional[str] = None,
email: Optional[str] = None,
mobile_phone: Optional[str] = None,
business_phone: Optional[str] = None,
company: Optional[str] = None,
job_title: Optional[str] = None,
note: Optional[str] = None,
) -> dict:
"""Založí kontakt ve schránce vladimir.buzalka@buzalka.cz
(Graph POST /contacts). Vyžaduje Contacts.ReadWrite (application).
- given_name / surname: jméno / příjmení (povinné)
- display_name: zobrazované jméno (def "<given> <surname>")
- email: e-mailová adresa
- mobile_phone: mobil
- business_phone: pracovní telefon
- company: firma / pracoviště
- job_title: funkce
- note: poznámka (personalNotes)
Vrací: {id, display_name, email}
"""
disp = display_name or f"{given_name} {surname}".strip()
payload = {
"givenName": given_name,
"surname": surname,
"displayName": disp,
}
if email:
payload["emailAddresses"] = [{"address": email, "name": disp}]
if mobile_phone:
payload["mobilePhone"] = mobile_phone
if business_phone:
payload["businessPhones"] = [business_phone]
if company:
payload["companyName"] = company
if job_title:
payload["jobTitle"] = job_title
if note:
payload["personalNotes"] = note
c = _graph_post("/contacts", payload)
log(f"contact created: {c.get('id')}{disp}")
return {
"id": c.get("id"),
"display_name": c.get("displayName"),
"email": email or "",
}
if __name__ == "__main__":
log("mcp_vbcz_email v1.6 starting (stdio)…")
mcp.run()