Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion auth/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
GMAIL_COMPOSE_SCOPE = 'https://www.googleapis.com/auth/gmail.compose'
GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify'
GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels'
GMAIL_SETTINGS_BASIC_SCOPE = "https://www.googleapis.com/auth/gmail.settings.basic"

# Google Chat API scopes
CHAT_READONLY_SCOPE = 'https://www.googleapis.com/auth/chat.messages.readonly'
Expand Down Expand Up @@ -90,7 +91,8 @@
GMAIL_SEND_SCOPE,
GMAIL_COMPOSE_SCOPE,
GMAIL_MODIFY_SCOPE,
GMAIL_LABELS_SCOPE
GMAIL_LABELS_SCOPE,
GMAIL_SETTINGS_BASIC_SCOPE
]

CHAT_SCOPES = [
Expand Down
52 changes: 52 additions & 0 deletions gcalendar/calendar_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -782,3 +782,55 @@ async def delete_event(service, user_google_email: str, event_id: str, calendar_
return confirmation_message


@server.tool()
@handle_http_errors("create_out_of_office_event", service_type="calendar")
@require_google_service("calendar", "calendar_events")
async def create_out_of_office_event(
service,
user_google_email: str,
start_time: str,
end_time: str,
decline_message: Optional[str] = "Declined because I am out of office.",
calendar_id: str = "primary",
) -> str:
"""
Creates a new 'out of office' event.

Args:
user_google_email (str): The user's Google email address. Required.
start_time (str): Start time (RFC3339, e.g., "2023-10-27T10:00:00-07:00").
end_time (str): End time (RFC3339, e.g., "2023-10-27T11:00:00-07:00").
decline_message (Optional[str]): The message to send when declining invitations.
calendar_id (str): Calendar ID (default: 'primary').

Returns:
str: Confirmation message of the successful event creation with event link.
"""
logger.info(
f"[create_out_of_office_event] Invoked. Email: '{user_google_email}'"
)

event_body = {
"start": {"dateTime": start_time},
"end": {"dateTime": end_time},
"eventType": "outOfOffice",
"outOfOfficeProperties": {
"autoDeclineMode": "declineOnlyNewConflictingInvitations",
"declineMessage": decline_message,
},
"transparency": "opaque",
}

created_event = await asyncio.to_thread(
lambda: service.events()
.insert(calendarId=calendar_id, body=event_body)
.execute()
)

link = created_event.get("htmlLink", "No link available")
confirmation_message = f"Successfully created out of office event for {user_google_email}. Link: {link}"

logger.info(
f"Out of office event created successfully for {user_google_email}. ID: {created_event.get('id')}, Link: {link}"
)
return confirmation_message
36 changes: 36 additions & 0 deletions gcalendar/test_calendar_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import asyncio
import unittest
from unittest.mock import MagicMock, patch

from gcalendar.calendar_tools import create_out_of_office_event


class TestCalendarTools(unittest.TestCase):
@patch('core.server.server.tool', new=lambda *args, **kwargs: (lambda func: func))
@patch('gcalendar.calendar_tools.require_google_service', new=lambda *args, **kwargs: (lambda func: func))
@patch('gcalendar.calendar_tools.handle_http_errors', new=lambda *args, **kwargs: (lambda func: func))
def test_create_out_of_office_event(self):
# Mock the service object
service = MagicMock()
service.events.return_value.insert.return_value.execute.return_value = {
"id": "test-event-id",
"htmlLink": "https://calendar.google.com/event?id=test-event-id",
}

# Call the function
result = asyncio.run(
create_out_of_office_event(
service,
"[email protected]",
start_time="2025-01-01T10:00:00Z",
end_time="2025-01-01T11:00:00Z",
)
)

# Check the result
self.assertIn("Successfully created out of office event", result)
self.assertIn("https://calendar.google.com/event?id=test-event-id", result)


if __name__ == "__main__":
unittest.main()
100 changes: 100 additions & 0 deletions gmail/gmail_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
GMAIL_COMPOSE_SCOPE,
GMAIL_MODIFY_SCOPE,
GMAIL_LABELS_SCOPE,
GMAIL_SETTINGS_BASIC_SCOPE,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1174,3 +1175,102 @@ async def batch_modify_gmail_message_labels(

return f"Labels updated for {len(message_ids)} messages: {'; '.join(actions)}"


@server.tool()
@handle_http_errors("get_vacation_settings", is_read_only=True, service_type="gmail")
@require_google_service("gmail", GMAIL_SETTINGS_BASIC_SCOPE)
async def get_vacation_settings(service, user_google_email: str) -> str:
"""
Retrieves the current vacation responder settings for the user's Gmail account.

Args:
user_google_email (str): The user's Google email address. Required.

Returns:
str: A formatted string containing the vacation responder settings.
"""
logger.info(f"[get_vacation_settings] Invoked for email: '{user_google_email}'")

vacation_settings = await asyncio.to_thread(
service.users().settings().getVacation(userId="me").execute
)

enabled = vacation_settings.get("enableAutoReply", False)
subject = vacation_settings.get("responseSubject", "")
body = vacation_settings.get("responseBodyHtml", "")
restrict_to_contacts = vacation_settings.get("restrictToContacts", False)
restrict_to_domain = vacation_settings.get("restrictToDomain", False)
start_time = vacation_settings.get("startTime")
end_time = vacation_settings.get("endTime")

lines = [
"Vacation Responder Settings:",
f" Enabled: {enabled}",
f" Subject: {subject}",
f" Body: {body}",
f" Restrict to Contacts: {restrict_to_contacts}",
f" Restrict to Domain: {restrict_to_domain}",
]

if start_time:
lines.append(f" Start Time: {start_time}")
if end_time:
lines.append(f" End Time: {end_time}")

return "\n".join(lines)


@server.tool()
@handle_http_errors("set_vacation_settings", service_type="gmail")
@require_google_service("gmail", GMAIL_SETTINGS_BASIC_SCOPE)
async def set_vacation_settings(
service,
user_google_email: str,
enable: bool,
subject: Optional[str] = None,
body: Optional[str] = None,
restrict_to_contacts: Optional[bool] = None,
restrict_to_domain: Optional[bool] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> str:
"""
Updates the vacation responder settings for the user's Gmail account.

Args:
user_google_email (str): The user's Google email address. Required.
enable (bool): Whether to enable or disable the vacation responder.
subject (Optional[str]): The subject of the vacation response.
body (Optional[str]): The body of the vacation response, in HTML format.
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states the body is in HTML format, but the parameter name and implementation suggest it accepts any string. Consider clarifying whether HTML formatting is required or if plain text is also acceptable.

Copilot uses AI. Check for mistakes.
restrict_to_contacts (Optional[bool]): Whether to restrict the response to contacts.
restrict_to_domain (Optional[bool]): Whether to restrict the response to the user's domain.
start_time (Optional[str]): The start time for the vacation responder, in RFC 3339 format.
end_time (Optional[str]): The end time for the vacation responder, in RFC 3339 format.

Returns:
str: A confirmation message.
"""
logger.info(f"[set_vacation_settings] Invoked for email: '{user_google_email}'")

vacation_settings = {"enableAutoReply": enable}

if subject is not None:
vacation_settings["responseSubject"] = subject
if body is not None:
vacation_settings["responseBodyHtml"] = body
if restrict_to_contacts is not None:
vacation_settings["restrictToContacts"] = restrict_to_contacts
if restrict_to_domain is not None:
vacation_settings["restrictToDomain"] = restrict_to_domain
if start_time is not None:
vacation_settings["startTime"] = start_time
if end_time is not None:
vacation_settings["endTime"] = end_time

await asyncio.to_thread(
service.users().settings().updateVacation(
userId="me", body=vacation_settings
).execute
)

return "Vacation settings updated successfully."
64 changes: 64 additions & 0 deletions gmail/test_gmail_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import asyncio
import unittest
from unittest.mock import MagicMock, patch

from gmail.gmail_tools import get_vacation_settings, set_vacation_settings


class TestGmailTools(unittest.TestCase):
@patch('core.server.server.tool', new=lambda *args, **kwargs: (lambda func: func))
@patch('gmail.gmail_tools.require_google_service', new=lambda *args, **kwargs: (lambda func: func))
@patch('gmail.gmail_tools.handle_http_errors', new=lambda *args, **kwargs: (lambda func: func))
def test_vacation_settings(self):
# Mock the service object
service = MagicMock()
service.users.return_value.settings.return_value.getVacation.return_value.execute.return_value = {
"enableAutoReply": False,
}
service.users.return_value.settings.return_value.updateVacation.return_value.execute.return_value = {}

# 1. Get original settings
original_settings = asyncio.run(
get_vacation_settings(service, "[email protected]")
)
self.assertIn("Enabled: False", original_settings)

# 2. Set new settings
asyncio.run(
set_vacation_settings(
service,
"[email protected]",
enable=True,
subject="On vacation",
body="I am on vacation.",
)
)

# 3. Get new settings to confirm
service.users.return_value.settings.return_value.getVacation.return_value.execute.return_value = {
"enableAutoReply": True,
"responseSubject": "On vacation",
"responseBodyHtml": "I am on vacation.",
}
Comment on lines +37 to +42
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The mock return value is being reassigned multiple times within the same test. Consider creating a helper method to set mock responses or use side_effect to return different values on subsequent calls for better test clarity.

Copilot uses AI. Check for mistakes.
new_settings = asyncio.run(get_vacation_settings(service, "[email protected]"))
self.assertIn("Enabled: True", new_settings)
self.assertIn("Subject: On vacation", new_settings)
self.assertIn("Body: I am on vacation.", new_settings)

# 4. Restore original settings
asyncio.run(
set_vacation_settings(
service, "[email protected]", enable=False
)
)
service.users.return_value.settings.return_value.getVacation.return_value.execute.return_value = {
"enableAutoReply": False,
}
restored_settings = asyncio.run(
get_vacation_settings(service, "[email protected]")
)
self.assertIn("Enabled: False", restored_settings)


if __name__ == "__main__":
unittest.main()
Loading