msgraph-mcp-server 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,468 @@
1
+ """Outlook inbox mail-rule tools.
2
+
3
+ Wraps the Graph endpoint /me/mailFolders/inbox/messageRules. All rule
4
+ operations target the inbox folder — Outlook doesn't support per-folder
5
+ rules (the official endpoint is hardcoded to the inbox).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from msgraph.generated.models.email_address import EmailAddress
11
+ from msgraph.generated.models.message_rule import MessageRule
12
+ from msgraph.generated.models.message_rule_actions import MessageRuleActions
13
+ from msgraph.generated.models.message_rule_predicates import MessageRulePredicates
14
+ from msgraph.generated.models.recipient import Recipient
15
+
16
+ from msgraph_mcp.auth.token import NotAuthenticatedError
17
+ from msgraph_mcp.graph.errors import GraphValidationError, map_kiota_error
18
+ from msgraph_mcp.graph.serialize import message_rule_to_dict
19
+ from msgraph_mcp.graph.trimming import trim_message_rule
20
+
21
+
22
+ def _rules_endpoint(graph, mailbox: str | None):
23
+ return (
24
+ graph.mailbox(mailbox)
25
+ .mail_folders.by_mail_folder_id("inbox")
26
+ .message_rules
27
+ )
28
+
29
+
30
+ async def list_rules(*, graph, mailbox: str | None = None, include_raw: bool = False) -> dict:
31
+ """List inbox mail rules.
32
+
33
+ Returns:
34
+ {"items": [trimmed_rule, ...], "next_page_token": None}
35
+ """
36
+ try:
37
+ collection = await _rules_endpoint(graph, mailbox).get()
38
+ except NotAuthenticatedError:
39
+ raise
40
+ except Exception as exc: # noqa: BLE001
41
+ raise map_kiota_error(exc) from exc
42
+ items = [
43
+ trim_message_rule(message_rule_to_dict(r), include_raw=include_raw)
44
+ for r in (collection.value or [])
45
+ ]
46
+ return {"items": items, "next_page_token": None}
47
+
48
+
49
+ async def get_rule(
50
+ *, graph, rule_id: str, mailbox: str | None = None, include_raw: bool = False
51
+ ) -> dict:
52
+ """Get a single inbox mail rule by id.
53
+
54
+ Args:
55
+ rule_id: Graph rule id. Required.
56
+ mailbox: Optional mailbox (email or user id) for shared mailboxes.
57
+ include_raw: Include the raw Graph payload.
58
+
59
+ Returns:
60
+ Trimmed rule dict.
61
+ """
62
+ if not rule_id:
63
+ raise GraphValidationError("`rule_id` is required")
64
+ try:
65
+ rule = await _rules_endpoint(graph, mailbox).by_message_rule_id(rule_id).get()
66
+ except NotAuthenticatedError:
67
+ raise
68
+ except Exception as exc: # noqa: BLE001
69
+ raise map_kiota_error(exc) from exc
70
+ return trim_message_rule(message_rule_to_dict(rule), include_raw=include_raw)
71
+
72
+
73
+ def _recipients_from_addresses(addresses: list[str] | None) -> list[Recipient] | None:
74
+ if not addresses:
75
+ return None
76
+ out: list[Recipient] = []
77
+ for addr in addresses:
78
+ if not addr or "@" not in addr:
79
+ raise GraphValidationError(f"Invalid email address: {addr!r}")
80
+ email = EmailAddress()
81
+ email.address = addr
82
+ recipient = Recipient()
83
+ recipient.email_address = email
84
+ out.append(recipient)
85
+ return out
86
+
87
+
88
+ def _build_predicates(
89
+ *,
90
+ sender_contains: list[str] | None,
91
+ subject_contains: list[str] | None,
92
+ body_contains: list[str] | None,
93
+ body_or_subject_contains: list[str] | None,
94
+ from_addresses: list[str] | None,
95
+ has_attachments: bool | None,
96
+ ) -> MessageRulePredicates | None:
97
+ # Only assign attrs that have real values. Setting `pred.x = None` registers
98
+ # as an explicit null in kiota's backing store and the SDK then emits it as
99
+ # `write_null_value(...)` on the *parent* writer during serialization, which
100
+ # mixes scalar nulls with object keys and trips "Invalid Json output".
101
+ from_recipients = _recipients_from_addresses(from_addresses)
102
+ fields: dict = {}
103
+ if sender_contains:
104
+ fields["sender_contains"] = sender_contains
105
+ if subject_contains:
106
+ fields["subject_contains"] = subject_contains
107
+ if body_contains:
108
+ fields["body_contains"] = body_contains
109
+ if body_or_subject_contains:
110
+ fields["body_or_subject_contains"] = body_or_subject_contains
111
+ if from_recipients:
112
+ fields["from_addresses"] = from_recipients
113
+ if has_attachments is not None:
114
+ fields["has_attachments"] = has_attachments
115
+ if not fields:
116
+ return None
117
+ pred = MessageRulePredicates()
118
+ for name, value in fields.items():
119
+ setattr(pred, name, value)
120
+ return pred
121
+
122
+
123
+ def _build_actions(
124
+ *,
125
+ move_to_folder: str | None,
126
+ mark_as_read: bool | None,
127
+ delete: bool | None,
128
+ stop_processing_rules: bool | None,
129
+ ) -> MessageRuleActions | None:
130
+ # See note in _build_predicates: only set non-None attrs to avoid the
131
+ # backing-store "explicit null" serialization bug.
132
+ fields: dict = {}
133
+ if move_to_folder is not None:
134
+ fields["move_to_folder"] = move_to_folder
135
+ if mark_as_read is not None:
136
+ fields["mark_as_read"] = mark_as_read
137
+ if delete is not None:
138
+ fields["delete"] = delete
139
+ if stop_processing_rules is not None:
140
+ fields["stop_processing_rules"] = stop_processing_rules
141
+ if not fields:
142
+ return None
143
+ actions = MessageRuleActions()
144
+ for name, value in fields.items():
145
+ setattr(actions, name, value)
146
+ return actions
147
+
148
+
149
+ async def create_rule(
150
+ *,
151
+ graph,
152
+ display_name: str,
153
+ sender_contains: list[str] | None = None,
154
+ subject_contains: list[str] | None = None,
155
+ body_contains: list[str] | None = None,
156
+ body_or_subject_contains: list[str] | None = None,
157
+ from_addresses: list[str] | None = None,
158
+ has_attachments: bool | None = None,
159
+ move_to_folder: str | None = None,
160
+ mark_as_read: bool | None = None,
161
+ delete: bool | None = None,
162
+ stop_processing_rules: bool | None = None,
163
+ sequence: int | None = None,
164
+ is_enabled: bool = True,
165
+ mailbox: str | None = None,
166
+ include_raw: bool = False,
167
+ ) -> dict:
168
+ """Create an inbox mail rule.
169
+
170
+ At least one condition AND one action are required. Conditions
171
+ within a rule are AND-ed by Outlook. Pass multiple values in a list
172
+ (e.g. ``sender_contains=["example.com", "monitor.io"]``) for OR
173
+ within that condition.
174
+
175
+ Common condition args:
176
+ sender_contains: Substrings to match against the sender display
177
+ name or address (e.g. ["example.com"]).
178
+ subject_contains: Substrings to match against the subject.
179
+ body_contains: Substrings to match against the message body.
180
+ body_or_subject_contains: Match either subject or body.
181
+ from_addresses: Exact email addresses to match (e.g.
182
+ ["noreply@example.com"]).
183
+ has_attachments: True/False to require/exclude attachments.
184
+
185
+ Common action args:
186
+ move_to_folder: Destination folder id (use list_folders or
187
+ create_folder to get one).
188
+ mark_as_read: Mark matching messages as read.
189
+ delete: Move matching messages to Deleted Items.
190
+ stop_processing_rules: If True, no further rules run after this
191
+ one matches. Recommended for routing-to-folder rules.
192
+
193
+ Other args:
194
+ sequence: Optional rule order (lower runs first).
195
+ is_enabled: Whether the rule is active. Default True.
196
+ mailbox: Optional mailbox for shared mailboxes.
197
+ include_raw: Include the raw Graph payload.
198
+
199
+ Returns:
200
+ The trimmed new rule.
201
+ """
202
+ if not display_name or not display_name.strip():
203
+ raise GraphValidationError("`display_name` is required")
204
+ conditions = _build_predicates(
205
+ sender_contains=sender_contains,
206
+ subject_contains=subject_contains,
207
+ body_contains=body_contains,
208
+ body_or_subject_contains=body_or_subject_contains,
209
+ from_addresses=from_addresses,
210
+ has_attachments=has_attachments,
211
+ )
212
+ if conditions is None:
213
+ raise GraphValidationError(
214
+ "At least one condition is required "
215
+ "(sender_contains, subject_contains, body_contains, "
216
+ "body_or_subject_contains, from_addresses, or has_attachments)"
217
+ )
218
+ actions = _build_actions(
219
+ move_to_folder=move_to_folder,
220
+ mark_as_read=mark_as_read,
221
+ delete=delete,
222
+ stop_processing_rules=stop_processing_rules,
223
+ )
224
+ if actions is None:
225
+ raise GraphValidationError(
226
+ "At least one action is required "
227
+ "(move_to_folder, mark_as_read, delete, or stop_processing_rules)"
228
+ )
229
+
230
+ body = MessageRule()
231
+ body.display_name = display_name
232
+ body.is_enabled = is_enabled
233
+ body.conditions = conditions
234
+ body.actions = actions
235
+ # Graph rejects sequence=0 ("InvalidValue"). Default to 1 so the rule lands
236
+ # at the head of the list — callers can override. Never set None here, or
237
+ # the backing store will emit `write_null_value("sequence")` on the parent
238
+ # writer (which conflicts with the serialized object dict; see kiota's
239
+ # BackingStoreSerializationWriterProxyFactory).
240
+ body.sequence = sequence if sequence is not None else 1
241
+
242
+ try:
243
+ created = await _rules_endpoint(graph, mailbox).post(body)
244
+ except NotAuthenticatedError:
245
+ raise
246
+ except Exception as exc: # noqa: BLE001
247
+ raise map_kiota_error(exc) from exc
248
+ return trim_message_rule(message_rule_to_dict(created), include_raw=include_raw)
249
+
250
+
251
+ async def update_rule(
252
+ *,
253
+ graph,
254
+ rule_id: str,
255
+ display_name: str | None = None,
256
+ is_enabled: bool | None = None,
257
+ sequence: int | None = None,
258
+ sender_contains: list[str] | None = None,
259
+ subject_contains: list[str] | None = None,
260
+ body_contains: list[str] | None = None,
261
+ body_or_subject_contains: list[str] | None = None,
262
+ from_addresses: list[str] | None = None,
263
+ has_attachments: bool | None = None,
264
+ move_to_folder: str | None = None,
265
+ mark_as_read: bool | None = None,
266
+ delete: bool | None = None,
267
+ stop_processing_rules: bool | None = None,
268
+ mailbox: str | None = None,
269
+ include_raw: bool = False,
270
+ ) -> dict:
271
+ """Patch fields of an existing inbox mail rule. Pass None to leave a
272
+ field unchanged.
273
+
274
+ Replacement semantics for nested blocks:
275
+ Graph PATCH on messageRules replaces the entire ``conditions`` or
276
+ ``actions`` block when you send it. If you pass ANY condition arg
277
+ (sender_contains, subject_contains, body_contains,
278
+ body_or_subject_contains, from_addresses, has_attachments) the
279
+ rule's conditions are rebuilt from ONLY the args you pass — any
280
+ existing conditions you don't re-specify are dropped. Same for
281
+ actions (move_to_folder, mark_as_read, delete,
282
+ stop_processing_rules). If you don't pass any condition or
283
+ action args, the existing blocks are preserved.
284
+
285
+ If you want to add to a block without losing the rest, call
286
+ ``get_rule`` first and re-supply the existing values.
287
+
288
+ Args:
289
+ rule_id: Graph rule id. Required.
290
+ display_name: New display name. None = unchanged.
291
+ is_enabled: Enable/disable the rule. None = unchanged.
292
+ sequence: New order (lower runs first). None = unchanged.
293
+ sender_contains / subject_contains / body_contains /
294
+ body_or_subject_contains / from_addresses / has_attachments:
295
+ Passing any of these REPLACES the conditions block.
296
+ move_to_folder / mark_as_read / delete / stop_processing_rules:
297
+ Passing any of these REPLACES the actions block.
298
+ mailbox: Optional mailbox for shared mailboxes.
299
+ include_raw: Include the raw Graph payload.
300
+
301
+ Returns:
302
+ The trimmed updated rule.
303
+ """
304
+ if not rule_id:
305
+ raise GraphValidationError("`rule_id` is required")
306
+
307
+ new_conditions = _build_predicates(
308
+ sender_contains=sender_contains,
309
+ subject_contains=subject_contains,
310
+ body_contains=body_contains,
311
+ body_or_subject_contains=body_or_subject_contains,
312
+ from_addresses=from_addresses,
313
+ has_attachments=has_attachments,
314
+ )
315
+ new_actions = _build_actions(
316
+ move_to_folder=move_to_folder,
317
+ mark_as_read=mark_as_read,
318
+ delete=delete,
319
+ stop_processing_rules=stop_processing_rules,
320
+ )
321
+
322
+ has_top_level = (
323
+ display_name is not None or is_enabled is not None or sequence is not None
324
+ )
325
+ if not has_top_level and new_conditions is None and new_actions is None:
326
+ raise GraphValidationError(
327
+ "At least one field must be updated "
328
+ "(display_name, is_enabled, sequence, a condition, or an action)"
329
+ )
330
+
331
+ patch = MessageRule()
332
+ if display_name is not None:
333
+ if not display_name.strip():
334
+ raise GraphValidationError("`display_name` cannot be empty")
335
+ patch.display_name = display_name
336
+ if is_enabled is not None:
337
+ patch.is_enabled = is_enabled
338
+ if sequence is not None:
339
+ patch.sequence = sequence
340
+ if new_conditions is not None:
341
+ patch.conditions = new_conditions
342
+ if new_actions is not None:
343
+ patch.actions = new_actions
344
+
345
+ try:
346
+ updated = await (
347
+ _rules_endpoint(graph, mailbox).by_message_rule_id(rule_id).patch(patch)
348
+ )
349
+ except NotAuthenticatedError:
350
+ raise
351
+ except Exception as exc: # noqa: BLE001
352
+ raise map_kiota_error(exc) from exc
353
+ return trim_message_rule(message_rule_to_dict(updated), include_raw=include_raw)
354
+
355
+
356
+ async def delete_rule(*, graph, rule_id: str, mailbox: str | None = None) -> dict:
357
+ """Delete an inbox mail rule by id.
358
+
359
+ Args:
360
+ rule_id: Graph rule id. Required.
361
+ mailbox: Optional mailbox (email or user id) for shared mailboxes.
362
+
363
+ Returns:
364
+ {"deleted": True, "id": <rule_id>}
365
+ """
366
+ if not rule_id:
367
+ raise GraphValidationError("`rule_id` is required")
368
+ try:
369
+ await _rules_endpoint(graph, mailbox).by_message_rule_id(rule_id).delete()
370
+ except NotAuthenticatedError:
371
+ raise
372
+ except Exception as exc: # noqa: BLE001
373
+ raise map_kiota_error(exc) from exc
374
+ return {"deleted": True, "id": rule_id}
375
+
376
+
377
+ def register(mcp, *, graph) -> None:
378
+ @mcp.tool(name="list_rules", description=list_rules.__doc__ or "")
379
+ async def _list_rules(mailbox: str | None = None, include_raw: bool = False):
380
+ return await list_rules(graph=graph, mailbox=mailbox, include_raw=include_raw)
381
+
382
+ @mcp.tool(name="get_rule", description=get_rule.__doc__ or "")
383
+ async def _get_rule(
384
+ rule_id: str, mailbox: str | None = None, include_raw: bool = False
385
+ ):
386
+ return await get_rule(
387
+ graph=graph, rule_id=rule_id, mailbox=mailbox, include_raw=include_raw
388
+ )
389
+
390
+ @mcp.tool(name="create_rule", description=create_rule.__doc__ or "")
391
+ async def _create_rule(
392
+ display_name: str,
393
+ sender_contains: list[str] | None = None,
394
+ subject_contains: list[str] | None = None,
395
+ body_contains: list[str] | None = None,
396
+ body_or_subject_contains: list[str] | None = None,
397
+ from_addresses: list[str] | None = None,
398
+ has_attachments: bool | None = None,
399
+ move_to_folder: str | None = None,
400
+ mark_as_read: bool | None = None,
401
+ delete: bool | None = None,
402
+ stop_processing_rules: bool | None = None,
403
+ sequence: int | None = None,
404
+ is_enabled: bool = True,
405
+ mailbox: str | None = None,
406
+ include_raw: bool = False,
407
+ ):
408
+ return await create_rule(
409
+ graph=graph,
410
+ display_name=display_name,
411
+ sender_contains=sender_contains,
412
+ subject_contains=subject_contains,
413
+ body_contains=body_contains,
414
+ body_or_subject_contains=body_or_subject_contains,
415
+ from_addresses=from_addresses,
416
+ has_attachments=has_attachments,
417
+ move_to_folder=move_to_folder,
418
+ mark_as_read=mark_as_read,
419
+ delete=delete,
420
+ stop_processing_rules=stop_processing_rules,
421
+ sequence=sequence,
422
+ is_enabled=is_enabled,
423
+ mailbox=mailbox,
424
+ include_raw=include_raw,
425
+ )
426
+
427
+ @mcp.tool(name="update_rule", description=update_rule.__doc__ or "")
428
+ async def _update_rule(
429
+ rule_id: str,
430
+ display_name: str | None = None,
431
+ is_enabled: bool | None = None,
432
+ sequence: int | None = None,
433
+ sender_contains: list[str] | None = None,
434
+ subject_contains: list[str] | None = None,
435
+ body_contains: list[str] | None = None,
436
+ body_or_subject_contains: list[str] | None = None,
437
+ from_addresses: list[str] | None = None,
438
+ has_attachments: bool | None = None,
439
+ move_to_folder: str | None = None,
440
+ mark_as_read: bool | None = None,
441
+ delete: bool | None = None,
442
+ stop_processing_rules: bool | None = None,
443
+ mailbox: str | None = None,
444
+ include_raw: bool = False,
445
+ ):
446
+ return await update_rule(
447
+ graph=graph,
448
+ rule_id=rule_id,
449
+ display_name=display_name,
450
+ is_enabled=is_enabled,
451
+ sequence=sequence,
452
+ sender_contains=sender_contains,
453
+ subject_contains=subject_contains,
454
+ body_contains=body_contains,
455
+ body_or_subject_contains=body_or_subject_contains,
456
+ from_addresses=from_addresses,
457
+ has_attachments=has_attachments,
458
+ move_to_folder=move_to_folder,
459
+ mark_as_read=mark_as_read,
460
+ delete=delete,
461
+ stop_processing_rules=stop_processing_rules,
462
+ mailbox=mailbox,
463
+ include_raw=include_raw,
464
+ )
465
+
466
+ @mcp.tool(name="delete_rule", description=delete_rule.__doc__ or "")
467
+ async def _delete_rule(rule_id: str, mailbox: str | None = None):
468
+ return await delete_rule(graph=graph, rule_id=rule_id, mailbox=mailbox)