diff --git a/actions/microsoft-mail/CHANGELOG.md b/actions/microsoft-mail/CHANGELOG.md index 97fd498f..8668e512 100644 --- a/actions/microsoft-mail/CHANGELOG.md +++ b/actions/microsoft-mail/CHANGELOG.md @@ -5,6 +5,23 @@ 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 +- 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 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 ### Added diff --git a/actions/microsoft-mail/microsoft_mail/email_action.py b/actions/microsoft-mail/microsoft_mail/email_action.py index d3639684..900f98e0 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, @@ -286,6 +294,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 +344,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 +355,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 +835,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 +849,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,110 +1275,144 @@ def flag_email( @action(is_consequential=True) def add_category( token: OAuth2Secret[Literal["microsoft"], list[Literal["Mail.ReadWrite"]]], - email_id: str, - category: Category, + assignments: list[EmailCategoryAssignment], ) -> Response: """ - Add a category to an email 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_id: The unique identifier of the email to add the category to. - category: The category to add to the email (includes display_name and color). + 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. Returns: - Response indicating the result of the category addition operation. + Response indicating the result of the category addition operations. """ headers = build_headers(token) - # 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) + # 1. Collect unique categories and create them first (deduplicated) + unique_categories: dict[str, str] = {} # name -> color + for assignment in assignments: + if assignment.category_name not in unique_categories: + unique_categories[assignment.category_name] = assignment.category_color or "Preset19" - # 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, - ) + for cat_name, cat_color in unique_categories.items(): + _ensure_category_exists(token, cat_name, headers, cat_color) - # Get existing categories or initialize empty list - existing_categories = current_message.get("categories", []) + # 2. Process each assignment + results = [] + for assignment in assignments: + email_id = assignment.email_id + category_name = assignment.category_name - # 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" + current_message = send_request( + "get", + f"/me/messages/{email_id}", + "get email for categories", + headers=headers, ) - # Update the email with the new categories - data = {"categories": existing_categories} + existing_categories = current_message.get("categories", []) + + if category_name not in existing_categories: + existing_categories.append(category_name) + 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 '{category_name}'") + else: + results.append(f"{email_id}: '{category_name}' already exists") + + if len(assignments) == 1: + category_name = assignments[0].category_name + if "added" in results[0]: + return Response( + result=f"Category '{category_name}' added to email successfully" + ) + else: + return Response( + result=f"Category '{category_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"Processed {len(assignments)} category assignments: {'; '.join(results)}" ) @action(is_consequential=True) def remove_category( token: OAuth2Secret[Literal["microsoft"], list[Literal["Mail.ReadWrite"]]], - email_id: str, - category_name: str, + removals: list[EmailCategoryRemoval], ) -> Response: """ - Remove a category from an email. + 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_id: The unique identifier of the email to remove the category from. - category_name: The name of the category to remove. + 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. Returns: - Response indicating the result of the category removal operation. + Response indicating the result of the category removal operations. """ 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, - ) + results = [] + for removal in removals: + email_id = removal.email_id + category_name = removal.category_name - # Get existing categories or initialize empty list - # Categories are returned as an array of strings (category names) - existing_categories = current_message.get("categories", []) + 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") + existing_categories = current_message.get("categories", []) - # Remove the category from the list - updated_categories = [cat for cat in existing_categories if cat != category_name] + if category_name not in existing_categories: + results.append(f"{email_id}: '{category_name}' not found") + continue - # Update the email with the updated categories - data = {"categories": updated_categories} + updated_categories = [cat for cat in existing_categories if cat != category_name] + 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 '{category_name}'") + + 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" + ) + 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"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..326b0039 100644 --- a/actions/microsoft-mail/microsoft_mail/models.py +++ b/actions/microsoft-mail/microsoft_mail/models.py @@ -86,3 +86,21 @@ 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_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): + """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") diff --git a/actions/microsoft-mail/package.yaml b/actions/microsoft-mail/package.yaml index a1bb5b82..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.2.0 +version: 2.3.5 # The version of the `package.yaml` format. spec-version: v2