From 61b16c117c5ed3a0c7aa148b7bf4a05cddcbe9a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=20Ha=CC=88nninen?= Date: Tue, 10 Feb 2026 14:34:14 +0200 Subject: [PATCH 1/4] All changes --- actions/microsoft-mail/CHANGELOG.md | 13 + .../microsoft_mail/email_action.py | 267 +++++++++++------- actions/microsoft-mail/package.yaml | 2 +- 3 files changed, 183 insertions(+), 99 deletions(-) diff --git a/actions/microsoft-mail/CHANGELOG.md b/actions/microsoft-mail/CHANGELOG.md index 97fd498f..8cce11de 100644 --- a/actions/microsoft-mail/CHANGELOG.md +++ b/actions/microsoft-mail/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.3.0] - 2026-02-10 + +### Added + +- Added `max_emails_to_return` parameter to `emails_as_csv` action to limit the number of emails exported + +### Changed + +- `get_email_by_id` now accepts `email_ids: str | list[str]` to retrieve multiple emails in one call + - Handles duplicate attachment names across emails by appending a counter +- `add_category` now accepts `email_ids: str | list[str]` to add category to multiple emails in one call +- `remove_category` now accepts `email_ids: str | list[str]` to remove category from multiple emails in one call + ## [2.2.0] - 2026-01-30 ### Added diff --git a/actions/microsoft-mail/microsoft_mail/email_action.py b/actions/microsoft-mail/microsoft_mail/email_action.py index d3639684..884edf77 100644 --- a/actions/microsoft-mail/microsoft_mail/email_action.py +++ b/actions/microsoft-mail/microsoft_mail/email_action.py @@ -286,6 +286,7 @@ def emails_as_csv( csv_filename: str, properties_to_return: str = "id,subject,from,bodyPreview,receivedDateTime,hasAttachments", folder_to_search: str = "inbox", + max_emails_to_return: int = -1, ) -> Response[str]: """List emails matching a search query and save them to a CSV file. @@ -335,6 +336,7 @@ def emails_as_csv( csv_filename: The filename for the CSV output file (will be created in temp directory). properties_to_return: Comma separated list of properties to include as CSV columns. Default is 'id,subject,from,bodyPreview,receivedDateTime,hasAttachments'. folder_to_search: The folder to search for emails. Default is 'inbox'. + max_emails_to_return: Maximum number of emails to include in the CSV. Default is -1 (include all emails). Returns: The path to the created CSV file. @@ -345,6 +347,7 @@ def emails_as_csv( search_query=search_query, properties_to_return=properties_to_return, folder_to_search=folder_to_search, + max_emails_to_return=max_emails_to_return, ).result if not emails_result.items: @@ -824,12 +827,12 @@ def get_email_by_id( Literal["microsoft"], list[Literal["Mail.Read"]], ], - email_id: str, + email_ids: str | list[str], show_full_body: bool = False, save_attachments: bool = False, ) -> Response: """ - Get the details of a specific email and optionally attach files to the chat. + Get the details of one or more emails and optionally attach files to the chat. By default shows email's body preview. If you want to see the full body, set 'show_full_body' to True. @@ -838,56 +841,89 @@ def get_email_by_id( Args: token: OAuth2 token to use for the operation. - email_id: The unique identifier of the email to retrieve. + email_ids: The unique identifier(s) of the email(s) to retrieve. + Can be a single email ID string or a list of email IDs. show_full_body: Whether to show the full body content. save_attachments: Whether to attach the email attachments to the chat using sema4ai.actions.chat. Returns: - The message details. + The message details. Returns a single message dict for one email, + or a list of message dicts for multiple emails. """ + # Normalize email_ids to a list + if isinstance(email_ids, str): + email_ids = [email_ids] + # The environment variable is used to test the action - email_id = email_id or os.getenv("EMAIL_WITH_ATTACHMENTS") - if not email_id: - raise ActionError("Email ID is required") + if not email_ids or (len(email_ids) == 1 and not email_ids[0]): + test_email_id = os.getenv("EMAIL_WITH_ATTACHMENTS") + if test_email_id: + email_ids = [test_email_id] + else: + raise ActionError("Email ID is required") + headers = build_headers(token) - message = send_request( - "get", - f"/me/messages/{email_id}", - "get email", - headers=headers, - ) - if show_full_body: - message.pop("bodyPreview", None) - else: - message.pop("body", None) + messages = [] + # Track attachment names to handle duplicates across emails + attachment_name_counts: dict[str, int] = {} - if save_attachments: - # Fetch attachments separately - attachments_response = send_request( + for email_id in email_ids: + message = send_request( "get", - f"/me/messages/{email_id}/attachments", - "get email attachments", + f"/me/messages/{email_id}", + "get email", headers=headers, ) - attachments = attachments_response.get("value", []) - - message["attachments"] = [] - for attachment in attachments: - attachment_name = attachment["name"] - attachment_content = attachment["contentBytes"] - - # Create a temporary file to save the attachment - with tempfile.NamedTemporaryFile( - delete=False, suffix=f"_{attachment_name}" - ) as temp_file: - temp_file.write(base64.b64decode(attachment_content)) - temp_file_path = temp_file.name - - # Attach the file to the chat using sema4ai.actions.chat - attach_file(temp_file_path, name=attachment_name) - message["attachments"].append(attachment_name) - - return Response(result=message) + if show_full_body: + message.pop("bodyPreview", None) + else: + message.pop("body", None) + + if save_attachments: + # Fetch attachments separately + attachments_response = send_request( + "get", + f"/me/messages/{email_id}/attachments", + "get email attachments", + headers=headers, + ) + attachments = attachments_response.get("value", []) + + message["attachments"] = [] + for attachment in attachments: + attachment_name = attachment["name"] + attachment_content = attachment["contentBytes"] + + # Handle duplicate attachment names across emails + if attachment_name in attachment_name_counts: + attachment_name_counts[attachment_name] += 1 + # Split name and extension to insert counter + name_parts = attachment_name.rsplit(".", 1) + if len(name_parts) == 2: + unique_name = f"{name_parts[0]}_{attachment_name_counts[attachment_name]}.{name_parts[1]}" + else: + unique_name = f"{attachment_name}_{attachment_name_counts[attachment_name]}" + else: + attachment_name_counts[attachment_name] = 1 + unique_name = attachment_name + + # Create a temporary file to save the attachment + with tempfile.NamedTemporaryFile( + delete=False, suffix=f"_{unique_name}" + ) as temp_file: + temp_file.write(base64.b64decode(attachment_content)) + temp_file_path = temp_file.name + + # Attach the file to the chat using sema4ai.actions.chat + attach_file(temp_file_path, name=unique_name) + message["attachments"].append(unique_name) + + messages.append(message) + + # Return single message for single email, list for multiple + if len(messages) == 1: + return Response(result=messages[0]) + return Response(result=messages) @action @@ -1231,16 +1267,17 @@ def flag_email( @action(is_consequential=True) def add_category( token: OAuth2Secret[Literal["microsoft"], list[Literal["Mail.ReadWrite"]]], - email_id: str, + email_ids: str | list[str], category: Category, ) -> Response: """ - Add a category to an email while preserving existing categories. + Add a category to one or more emails while preserving existing categories. Creates the category in master categories if it doesn't exist. Args: token: The OAuth2 token for authentication. - email_id: The unique identifier of the email to add the category to. + email_ids: The unique identifier(s) of the email(s) to add the category to. + Can be a single email ID string or a list of email IDs. category: The category to add to the email (includes display_name and color). Returns: @@ -1248,55 +1285,72 @@ def add_category( """ headers = build_headers(token) + # Normalize email_ids to a list + if isinstance(email_ids, str): + email_ids = [email_ids] + # 1. Creates the category if it doesn't exist - Check if category exists in master categories and create if needed _ensure_category_exists(token, category.display_name, headers, category.color) - # 2. Preserves existing categories - Get the current categories and add the new one without removing existing ones - current_message = send_request( - "get", - f"/me/messages/{email_id}", - "get email for categories", - headers=headers, - ) + results = [] + for email_id in email_ids: + # 2. Preserves existing categories - Get the current categories and add the new one without removing existing ones + current_message = send_request( + "get", + f"/me/messages/{email_id}", + "get email for categories", + headers=headers, + ) - # Get existing categories or initialize empty list - existing_categories = current_message.get("categories", []) + # Get existing categories or initialize empty list + existing_categories = current_message.get("categories", []) - # Add the new category if it doesn't already exist (preserve existing categories) - if category.display_name not in existing_categories: - existing_categories.append(category.display_name) - else: - return Response( - result=f"Category '{category.display_name}' already exists on this email" - ) + # Add the new category if it doesn't already exist (preserve existing categories) + if category.display_name not in existing_categories: + existing_categories.append(category.display_name) - # Update the email with the new categories - data = {"categories": existing_categories} + # Update the email with the new categories + data = {"categories": existing_categories} + + send_request( + "patch", + f"/me/messages/{email_id}", + "add category to email", + data=data, + headers=headers, + ) + results.append(f"{email_id}: added") + else: + results.append(f"{email_id}: already exists") + + if len(email_ids) == 1: + if "added" in results[0]: + return Response( + result=f"Category '{category.display_name}' added to email successfully" + ) + else: + return Response( + result=f"Category '{category.display_name}' already exists on this email" + ) - send_request( - "patch", - f"/me/messages/{email_id}", - "add category to email", - data=data, - headers=headers, - ) return Response( - result=f"Category '{category.display_name}' added to email successfully" + result=f"Category '{category.display_name}' processed for {len(email_ids)} emails: {', '.join(results)}" ) @action(is_consequential=True) def remove_category( token: OAuth2Secret[Literal["microsoft"], list[Literal["Mail.ReadWrite"]]], - email_id: str, + email_ids: str | list[str], category_name: str, ) -> Response: """ - Remove a category from an email. + Remove a category from one or more emails. Args: token: The OAuth2 token for authentication. - email_id: The unique identifier of the email to remove the category from. + email_ids: The unique identifier(s) of the email(s) to remove the category from. + Can be a single email ID string or a list of email IDs. category_name: The name of the category to remove. Returns: @@ -1304,37 +1358,54 @@ def remove_category( """ headers = build_headers(token) - # First, get the current categories of the email - current_message = send_request( - "get", - f"/me/messages/{email_id}", - "get email for categories", - headers=headers, - ) + # Normalize email_ids to a list + if isinstance(email_ids, str): + email_ids = [email_ids] - # Get existing categories or initialize empty list - # Categories are returned as an array of strings (category names) - existing_categories = current_message.get("categories", []) + results = [] + for email_id in email_ids: + # First, get the current categories of the email + current_message = send_request( + "get", + f"/me/messages/{email_id}", + "get email for categories", + headers=headers, + ) - # Check if category exists and remove it - if category_name not in existing_categories: - raise ActionError(f"Category '{category_name}' not found on this email") + # Get existing categories or initialize empty list + # Categories are returned as an array of strings (category names) + existing_categories = current_message.get("categories", []) - # Remove the category from the list - updated_categories = [cat for cat in existing_categories if cat != category_name] + # Check if category exists and remove it + if category_name not in existing_categories: + results.append(f"{email_id}: not found") + continue - # Update the email with the updated categories - data = {"categories": updated_categories} + # Remove the category from the list + updated_categories = [cat for cat in existing_categories if cat != category_name] + + # Update the email with the updated categories + data = {"categories": updated_categories} + + send_request( + "patch", + f"/me/messages/{email_id}", + "remove category from email", + data=data, + headers=headers, + ) + results.append(f"{email_id}: removed") + + if len(email_ids) == 1: + if "removed" in results[0]: + return Response( + result=f"Category '{category_name}' removed from email successfully" + ) + else: + raise ActionError(f"Category '{category_name}' not found on this email") - send_request( - "patch", - f"/me/messages/{email_id}", - "remove category from email", - data=data, - headers=headers, - ) return Response( - result=f"Category '{category_name}' removed from email successfully" + result=f"Category '{category_name}' processed for {len(email_ids)} emails: {', '.join(results)}" ) diff --git a/actions/microsoft-mail/package.yaml b/actions/microsoft-mail/package.yaml index a1bb5b82..7d8d9d9d 100644 --- a/actions/microsoft-mail/package.yaml +++ b/actions/microsoft-mail/package.yaml @@ -5,7 +5,7 @@ name: Microsoft Mail description: Actions for Microsoft 365 Outlook emails including category management (add/remove categories) and CSV export. # Package version number, recommend using semver.org -version: 2.2.0 +version: 2.3.0 # The version of the `package.yaml` format. spec-version: v2 From e7e4087fff1564e962ab36799319e9d154771645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=20Ha=CC=88nninen?= Date: Tue, 10 Feb 2026 15:20:06 +0200 Subject: [PATCH 2/4] Batch category operations with Pydantic models - add_category now accepts EmailCategoryAssignment model - Different emails can receive different categories in one call - Categories are deduplicated and created only once - remove_category now accepts EmailCategoryRemoval model - Different categories can be removed from different emails in one call - Added EmailCategoryAssignment and EmailCategoryRemoval models Co-Authored-By: Claude Opus 4.5 --- actions/microsoft-mail/CHANGELOG.md | 8 +- .../microsoft_mail/email_action.py | 115 ++++++++++-------- .../microsoft-mail/microsoft_mail/models.py | 14 +++ 3 files changed, 85 insertions(+), 52 deletions(-) diff --git a/actions/microsoft-mail/CHANGELOG.md b/actions/microsoft-mail/CHANGELOG.md index 8cce11de..8668e512 100644 --- a/actions/microsoft-mail/CHANGELOG.md +++ b/actions/microsoft-mail/CHANGELOG.md @@ -10,13 +10,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `max_emails_to_return` parameter to `emails_as_csv` action to limit the number of emails exported +- Added `EmailCategoryAssignment` and `EmailCategoryRemoval` models for batch category operations ### Changed - `get_email_by_id` now accepts `email_ids: str | list[str]` to retrieve multiple emails in one call - Handles duplicate attachment names across emails by appending a counter -- `add_category` now accepts `email_ids: str | list[str]` to add category to multiple emails in one call -- `remove_category` now accepts `email_ids: str | list[str]` to remove category from multiple emails in one call +- `add_category` now accepts batch assignments via `EmailCategoryAssignment` model + - Different emails can receive different categories in a single call + - Categories are deduplicated and created only once +- `remove_category` now accepts batch removals via `EmailCategoryRemoval` model + - Different categories can be removed from different emails in a single call ## [2.2.0] - 2026-01-30 diff --git a/actions/microsoft-mail/microsoft_mail/email_action.py b/actions/microsoft-mail/microsoft_mail/email_action.py index 884edf77..78493ecc 100644 --- a/actions/microsoft-mail/microsoft_mail/email_action.py +++ b/actions/microsoft-mail/microsoft_mail/email_action.py @@ -27,7 +27,15 @@ from sema4ai.actions import action, OAuth2Secret, Response, ActionError from sema4ai.actions.chat import attach_file, attach_file_content -from microsoft_mail.models import Email, EmailAttachment, Emails, MessageFlag, Category +from microsoft_mail.models import ( + Email, + EmailAttachment, + Emails, + MessageFlag, + Category, + EmailCategoryAssignment, + EmailCategoryRemoval, +) from microsoft_mail.support import ( _find_folder, _get_inbox_folder_id, @@ -1267,34 +1275,45 @@ def flag_email( @action(is_consequential=True) def add_category( token: OAuth2Secret[Literal["microsoft"], list[Literal["Mail.ReadWrite"]]], - email_ids: str | list[str], - category: Category, + assignments: EmailCategoryAssignment | list[EmailCategoryAssignment], ) -> Response: """ - Add a category to one or more emails while preserving existing categories. - Creates the category in master categories if it doesn't exist. + Add categories to emails while preserving existing categories. + Creates categories in master categories if they don't exist. + + Supports batch operations where different emails can receive different categories + in a single call. Args: token: The OAuth2 token for authentication. - email_ids: The unique identifier(s) of the email(s) to add the category to. - Can be a single email ID string or a list of email IDs. - category: The category to add to the email (includes display_name and color). + assignments: One or more email-category assignments. Each assignment specifies + an email_id and the category to add to it. Different emails can have + different categories assigned in one call. Returns: - Response indicating the result of the category addition operation. + Response indicating the result of the category addition operations. """ headers = build_headers(token) - # Normalize email_ids to a list - if isinstance(email_ids, str): - email_ids = [email_ids] + # Normalize to list + if isinstance(assignments, EmailCategoryAssignment): + assignments = [assignments] + + # 1. Collect unique categories and create them first (deduplicated) + unique_categories: dict[str, Category] = {} + for assignment in assignments: + if assignment.category.display_name not in unique_categories: + unique_categories[assignment.category.display_name] = assignment.category - # 1. Creates the category if it doesn't exist - Check if category exists in master categories and create if needed - _ensure_category_exists(token, category.display_name, headers, category.color) + for category in unique_categories.values(): + _ensure_category_exists(token, category.display_name, headers, category.color) + # 2. Process each assignment results = [] - for email_id in email_ids: - # 2. Preserves existing categories - Get the current categories and add the new one without removing existing ones + for assignment in assignments: + email_id = assignment.email_id + category_name = assignment.category.display_name + current_message = send_request( "get", f"/me/messages/{email_id}", @@ -1302,14 +1321,10 @@ def add_category( headers=headers, ) - # Get existing categories or initialize empty list existing_categories = current_message.get("categories", []) - # Add the new category if it doesn't already exist (preserve existing categories) - if category.display_name not in existing_categories: - existing_categories.append(category.display_name) - - # Update the email with the new categories + if category_name not in existing_categories: + existing_categories.append(category_name) data = {"categories": existing_categories} send_request( @@ -1319,52 +1334,57 @@ def add_category( data=data, headers=headers, ) - results.append(f"{email_id}: added") + results.append(f"{email_id}: added '{category_name}'") else: - results.append(f"{email_id}: already exists") + results.append(f"{email_id}: '{category_name}' already exists") - if len(email_ids) == 1: + if len(assignments) == 1: + category_name = assignments[0].category.display_name if "added" in results[0]: return Response( - result=f"Category '{category.display_name}' added to email successfully" + result=f"Category '{category_name}' added to email successfully" ) else: return Response( - result=f"Category '{category.display_name}' already exists on this email" + result=f"Category '{category_name}' already exists on this email" ) return Response( - result=f"Category '{category.display_name}' processed for {len(email_ids)} emails: {', '.join(results)}" + result=f"Processed {len(assignments)} category assignments: {'; '.join(results)}" ) @action(is_consequential=True) def remove_category( token: OAuth2Secret[Literal["microsoft"], list[Literal["Mail.ReadWrite"]]], - email_ids: str | list[str], - category_name: str, + removals: EmailCategoryRemoval | list[EmailCategoryRemoval], ) -> Response: """ - Remove a category from one or more emails. + Remove categories from emails. + + Supports batch operations where different categories can be removed from + different emails in a single call. Args: token: The OAuth2 token for authentication. - email_ids: The unique identifier(s) of the email(s) to remove the category from. - Can be a single email ID string or a list of email IDs. - category_name: The name of the category to remove. + removals: One or more email-category removals. Each removal specifies + an email_id and the category_name to remove from it. Different emails + can have different categories removed in one call. Returns: - Response indicating the result of the category removal operation. + Response indicating the result of the category removal operations. """ headers = build_headers(token) - # Normalize email_ids to a list - if isinstance(email_ids, str): - email_ids = [email_ids] + # Normalize to list + if isinstance(removals, EmailCategoryRemoval): + removals = [removals] results = [] - for email_id in email_ids: - # First, get the current categories of the email + for removal in removals: + email_id = removal.email_id + category_name = removal.category_name + current_message = send_request( "get", f"/me/messages/{email_id}", @@ -1372,19 +1392,13 @@ def remove_category( headers=headers, ) - # Get existing categories or initialize empty list - # Categories are returned as an array of strings (category names) existing_categories = current_message.get("categories", []) - # Check if category exists and remove it if category_name not in existing_categories: - results.append(f"{email_id}: not found") + results.append(f"{email_id}: '{category_name}' not found") continue - # Remove the category from the list updated_categories = [cat for cat in existing_categories if cat != category_name] - - # Update the email with the updated categories data = {"categories": updated_categories} send_request( @@ -1394,9 +1408,10 @@ def remove_category( data=data, headers=headers, ) - results.append(f"{email_id}: removed") + results.append(f"{email_id}: removed '{category_name}'") - if len(email_ids) == 1: + if len(removals) == 1: + category_name = removals[0].category_name if "removed" in results[0]: return Response( result=f"Category '{category_name}' removed from email successfully" @@ -1405,7 +1420,7 @@ def remove_category( raise ActionError(f"Category '{category_name}' not found on this email") return Response( - result=f"Category '{category_name}' processed for {len(email_ids)} emails: {', '.join(results)}" + result=f"Processed {len(removals)} category removals: {'; '.join(results)}" ) diff --git a/actions/microsoft-mail/microsoft_mail/models.py b/actions/microsoft-mail/microsoft_mail/models.py index 4f0841ec..6f70de7d 100644 --- a/actions/microsoft-mail/microsoft_mail/models.py +++ b/actions/microsoft-mail/microsoft_mail/models.py @@ -86,3 +86,17 @@ class CategoryList(BaseModel): categories: Annotated[ List[Category], Field(description="A list of categories") ] = [] + + +class EmailCategoryAssignment(BaseModel): + """Assignment of a category to an email.""" + + email_id: str = Field(description="The unique identifier of the email") + category: Category = Field(description="The category to add to the email") + + +class EmailCategoryRemoval(BaseModel): + """Removal of a category from an email.""" + + email_id: str = Field(description="The unique identifier of the email") + category_name: str = Field(description="The name of the category to remove") From fc12f39634c5017eb622f61aeefb146f9e71af30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=20Ha=CC=88nninen?= Date: Tue, 10 Feb 2026 15:31:36 +0200 Subject: [PATCH 3/4] Fix: Use list type instead of union for category operations The action framework's JSON schema resolver doesn't support union types with Pydantic models. Changed to always accept list[...] instead of Model | list[Model]. Co-Authored-By: Claude Opus 4.5 --- .../microsoft_mail/email_action.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/actions/microsoft-mail/microsoft_mail/email_action.py b/actions/microsoft-mail/microsoft_mail/email_action.py index 78493ecc..12ced27c 100644 --- a/actions/microsoft-mail/microsoft_mail/email_action.py +++ b/actions/microsoft-mail/microsoft_mail/email_action.py @@ -1275,7 +1275,7 @@ def flag_email( @action(is_consequential=True) def add_category( token: OAuth2Secret[Literal["microsoft"], list[Literal["Mail.ReadWrite"]]], - assignments: EmailCategoryAssignment | list[EmailCategoryAssignment], + assignments: list[EmailCategoryAssignment], ) -> Response: """ Add categories to emails while preserving existing categories. @@ -1286,7 +1286,7 @@ def add_category( Args: token: The OAuth2 token for authentication. - assignments: One or more email-category assignments. Each assignment specifies + assignments: List of email-category assignments. Each assignment specifies an email_id and the category to add to it. Different emails can have different categories assigned in one call. @@ -1295,10 +1295,6 @@ def add_category( """ headers = build_headers(token) - # Normalize to list - if isinstance(assignments, EmailCategoryAssignment): - assignments = [assignments] - # 1. Collect unique categories and create them first (deduplicated) unique_categories: dict[str, Category] = {} for assignment in assignments: @@ -1357,7 +1353,7 @@ def add_category( @action(is_consequential=True) def remove_category( token: OAuth2Secret[Literal["microsoft"], list[Literal["Mail.ReadWrite"]]], - removals: EmailCategoryRemoval | list[EmailCategoryRemoval], + removals: list[EmailCategoryRemoval], ) -> Response: """ Remove categories from emails. @@ -1367,7 +1363,7 @@ def remove_category( Args: token: The OAuth2 token for authentication. - removals: One or more email-category removals. Each removal specifies + removals: List of email-category removals. Each removal specifies an email_id and the category_name to remove from it. Different emails can have different categories removed in one call. @@ -1376,10 +1372,6 @@ def remove_category( """ headers = build_headers(token) - # Normalize to list - if isinstance(removals, EmailCategoryRemoval): - removals = [removals] - results = [] for removal in removals: email_id = removal.email_id From ea18ecad4970c7dfba6dc9b0335690c417505cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=20Ha=CC=88nninen?= Date: Tue, 10 Feb 2026 15:33:37 +0200 Subject: [PATCH 4/4] Fix: Flatten EmailCategoryAssignment to avoid nested Pydantic models The action framework can't handle nested Pydantic models in JSON schema. Changed EmailCategoryAssignment to use flat fields (category_name, category_color) instead of nested Category model. Co-Authored-By: Claude Opus 4.5 --- .../microsoft-mail/microsoft_mail/email_action.py | 14 +++++++------- actions/microsoft-mail/microsoft_mail/models.py | 6 +++++- actions/microsoft-mail/package.yaml | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/actions/microsoft-mail/microsoft_mail/email_action.py b/actions/microsoft-mail/microsoft_mail/email_action.py index 12ced27c..900f98e0 100644 --- a/actions/microsoft-mail/microsoft_mail/email_action.py +++ b/actions/microsoft-mail/microsoft_mail/email_action.py @@ -1296,19 +1296,19 @@ def add_category( headers = build_headers(token) # 1. Collect unique categories and create them first (deduplicated) - unique_categories: dict[str, Category] = {} + unique_categories: dict[str, str] = {} # name -> color for assignment in assignments: - if assignment.category.display_name not in unique_categories: - unique_categories[assignment.category.display_name] = assignment.category + if assignment.category_name not in unique_categories: + unique_categories[assignment.category_name] = assignment.category_color or "Preset19" - for category in unique_categories.values(): - _ensure_category_exists(token, category.display_name, headers, category.color) + for cat_name, cat_color in unique_categories.items(): + _ensure_category_exists(token, cat_name, headers, cat_color) # 2. Process each assignment results = [] for assignment in assignments: email_id = assignment.email_id - category_name = assignment.category.display_name + category_name = assignment.category_name current_message = send_request( "get", @@ -1335,7 +1335,7 @@ def add_category( results.append(f"{email_id}: '{category_name}' already exists") if len(assignments) == 1: - category_name = assignments[0].category.display_name + category_name = assignments[0].category_name if "added" in results[0]: return Response( result=f"Category '{category_name}' added to email successfully" diff --git a/actions/microsoft-mail/microsoft_mail/models.py b/actions/microsoft-mail/microsoft_mail/models.py index 6f70de7d..326b0039 100644 --- a/actions/microsoft-mail/microsoft_mail/models.py +++ b/actions/microsoft-mail/microsoft_mail/models.py @@ -92,7 +92,11 @@ class EmailCategoryAssignment(BaseModel): """Assignment of a category to an email.""" email_id: str = Field(description="The unique identifier of the email") - category: Category = Field(description="The category to add to the email") + category_name: str = Field(description="Display name of the category to add") + category_color: Optional[str] = Field( + default="Preset19", + description="Color of the category (e.g., Preset19, Preset0, etc.)", + ) class EmailCategoryRemoval(BaseModel): diff --git a/actions/microsoft-mail/package.yaml b/actions/microsoft-mail/package.yaml index 7d8d9d9d..90351356 100644 --- a/actions/microsoft-mail/package.yaml +++ b/actions/microsoft-mail/package.yaml @@ -5,7 +5,7 @@ name: Microsoft Mail description: Actions for Microsoft 365 Outlook emails including category management (add/remove categories) and CSV export. # Package version number, recommend using semver.org -version: 2.3.0 +version: 2.3.5 # The version of the `package.yaml` format. spec-version: v2