From ad65fc27bc8db03b8ca214aaae9b426fb16189f3 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 7 Jan 2023 14:59:14 -0500 Subject: [PATCH] Add Google Mail integration (#82637) * Add Google Mail integration * oops * prettier * Add email service * adjustments * update * move email to notify * break out services * tweaks * Add CC and BCC support * drop scope check, breakout tests * use abstract auth * tweak * bump dependency * dependency bump * remove oauth2client --- CODEOWNERS | 2 + homeassistant/brands/google.json | 1 + .../components/google_mail/__init__.py | 73 +++++++ homeassistant/components/google_mail/api.py | 41 ++++ .../google_mail/application_credentials.py | 20 ++ .../components/google_mail/config_flow.py | 79 ++++++++ homeassistant/components/google_mail/const.py | 24 +++ .../components/google_mail/entity.py | 30 +++ .../components/google_mail/manifest.json | 11 ++ .../components/google_mail/notify.py | 66 +++++++ .../components/google_mail/sensor.py | 52 +++++ .../components/google_mail/services.py | 95 ++++++++++ .../components/google_mail/services.yaml | 53 ++++++ .../components/google_mail/strings.json | 33 ++++ .../google_mail/translations/en.json | 33 ++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/google_mail/__init__.py | 1 + tests/components/google_mail/conftest.py | 119 ++++++++++++ .../google_mail/fixtures/get_profile.json | 6 + .../google_mail/fixtures/get_vacation.json | 8 + .../fixtures/get_vacation_off.json | 8 + .../google_mail/test_config_flow.py | 178 ++++++++++++++++++ tests/components/google_mail/test_init.py | 130 +++++++++++++ tests/components/google_mail/test_notify.py | 76 ++++++++ tests/components/google_mail/test_sensor.py | 60 ++++++ tests/components/google_mail/test_services.py | 90 +++++++++ 30 files changed, 1303 insertions(+) create mode 100644 homeassistant/components/google_mail/__init__.py create mode 100644 homeassistant/components/google_mail/api.py create mode 100644 homeassistant/components/google_mail/application_credentials.py create mode 100644 homeassistant/components/google_mail/config_flow.py create mode 100644 homeassistant/components/google_mail/const.py create mode 100644 homeassistant/components/google_mail/entity.py create mode 100644 homeassistant/components/google_mail/manifest.json create mode 100644 homeassistant/components/google_mail/notify.py create mode 100644 homeassistant/components/google_mail/sensor.py create mode 100644 homeassistant/components/google_mail/services.py create mode 100644 homeassistant/components/google_mail/services.yaml create mode 100644 homeassistant/components/google_mail/strings.json create mode 100644 homeassistant/components/google_mail/translations/en.json create mode 100644 tests/components/google_mail/__init__.py create mode 100644 tests/components/google_mail/conftest.py create mode 100644 tests/components/google_mail/fixtures/get_profile.json create mode 100644 tests/components/google_mail/fixtures/get_vacation.json create mode 100644 tests/components/google_mail/fixtures/get_vacation_off.json create mode 100644 tests/components/google_mail/test_config_flow.py create mode 100644 tests/components/google_mail/test_init.py create mode 100644 tests/components/google_mail/test_notify.py create mode 100644 tests/components/google_mail/test_sensor.py create mode 100644 tests/components/google_mail/test_services.py diff --git a/CODEOWNERS b/CODEOWNERS index 224cc873be6..0934fc47d91 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -435,6 +435,8 @@ build.json @home-assistant/supervisor /homeassistant/components/google_assistant_sdk/ @tronikos /tests/components/google_assistant_sdk/ @tronikos /homeassistant/components/google_cloud/ @lufton +/homeassistant/components/google_mail/ @tkdrob +/tests/components/google_mail/ @tkdrob /homeassistant/components/google_sheets/ @tkdrob /tests/components/google_sheets/ @tkdrob /homeassistant/components/google_travel_time/ @eifinger diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index cceda7505c6..0d396ca05ed 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -6,6 +6,7 @@ "google_assistant_sdk", "google_cloud", "google_domains", + "google_mail", "google_maps", "google_pubsub", "google_sheets", diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py new file mode 100644 index 00000000000..e769bc239f4 --- /dev/null +++ b/homeassistant/components/google_mail/__init__.py @@ -0,0 +1,73 @@ +"""Support for Google Mail.""" +from __future__ import annotations + +from aiohttp.client_exceptions import ClientError, ClientResponseError + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_NAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import AsyncConfigEntryAuth +from .const import DATA_AUTH, DOMAIN +from .services import async_setup_services + +PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Mail from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) + try: + await auth.check_and_refresh_token() + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = auth + + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {DATA_AUTH: auth, CONF_NAME: entry.title}, + {}, + ) + ) + + await hass.config_entries.async_forward_entry_setups( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) + + await async_setup_services(hass) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + for service_name in hass.services.async_services()[DOMAIN]: + hass.services.async_remove(DOMAIN, service_name) + + return unload_ok diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py new file mode 100644 index 00000000000..202fa5b56b6 --- /dev/null +++ b/homeassistant/components/google_mail/api.py @@ -0,0 +1,41 @@ +"""API for Google Mail bound to Home Assistant OAuth.""" +from aiohttp import ClientSession +from google.auth.exceptions import RefreshError +from google.oauth2.credentials import Credentials +from google.oauth2.utils import OAuthClientAuthHandler +from googleapiclient.discovery import Resource, build + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(OAuthClientAuthHandler): + """Provide Google Mail authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth2Session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Google Mail Auth.""" + self.oauth_session = oauth2Session + super().__init__(websession) + + @property + def access_token(self) -> str: + """Return the access token.""" + return self.oauth_session.token[CONF_ACCESS_TOKEN] + + async def check_and_refresh_token(self) -> str: + """Check the token.""" + await self.oauth_session.async_ensure_token_valid() + return self.access_token + + async def get_resource(self) -> Resource: + """Get current resource.""" + try: + credentials = Credentials(await self.check_and_refresh_token()) + except RefreshError as ex: + self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) + raise ex + return build("gmail", "v1", credentials=credentials) diff --git a/homeassistant/components/google_mail/application_credentials.py b/homeassistant/components/google_mail/application_credentials.py new file mode 100644 index 00000000000..0b3b1dfd056 --- /dev/null +++ b/homeassistant/components/google_mail/application_credentials.py @@ -0,0 +1,20 @@ +"""application_credentials platform for Google Mail.""" +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_mail/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_mail/config_flow.py b/homeassistant/components/google_mail/config_flow.py new file mode 100644 index 00000000000..e7631199ddd --- /dev/null +++ b/homeassistant/components/google_mail/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for Google Mail integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DEFAULT_ACCESS, DOMAIN + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Mail OAuth2 authentication.""" + + DOMAIN = DOMAIN + + reauth_entry: ConfigEntry | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(DEFAULT_ACCESS), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + if self.reauth_entry: + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + + def _get_profile() -> dict[str, Any]: + """Get profile from inside the executor.""" + users = build( # pylint: disable=no-member + "gmail", "v1", credentials=credentials + ).users() + return users.getProfile(userId="me").execute() + + email = (await self.hass.async_add_executor_job(_get_profile))["emailAddress"] + + await self.async_set_unique_id(email) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=email, data=data) diff --git a/homeassistant/components/google_mail/const.py b/homeassistant/components/google_mail/const.py new file mode 100644 index 00000000000..b9c2157e031 --- /dev/null +++ b/homeassistant/components/google_mail/const.py @@ -0,0 +1,24 @@ +"""Constants for Google Mail integration.""" +from __future__ import annotations + +ATTR_BCC = "bcc" +ATTR_CC = "cc" +ATTR_ENABLED = "enabled" +ATTR_END = "end" +ATTR_FROM = "from" +ATTR_ME = "me" +ATTR_MESSAGE = "message" +ATTR_PLAIN_TEXT = "plain_text" +ATTR_RESTRICT_CONTACTS = "restrict_contacts" +ATTR_RESTRICT_DOMAIN = "restrict_domain" +ATTR_SEND = "send" +ATTR_START = "start" +ATTR_TITLE = "title" + +DATA_AUTH = "auth" +DEFAULT_ACCESS = [ + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.settings.basic", +] +DOMAIN = "google_mail" +MANUFACTURER = "Google, Inc." diff --git a/homeassistant/components/google_mail/entity.py b/homeassistant/components/google_mail/entity.py new file mode 100644 index 00000000000..bfa93f48107 --- /dev/null +++ b/homeassistant/components/google_mail/entity.py @@ -0,0 +1,30 @@ +"""Entity representing a Google Mail account.""" +from __future__ import annotations + +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN, MANUFACTURER + + +class GoogleMailEntity(Entity): + """An HA implementation for Google Mail entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + auth: AsyncConfigEntryAuth, + description: EntityDescription, + ) -> None: + """Initialize a Google Mail entity.""" + self.auth = auth + self.entity_description = description + self._attr_unique_id = ( + f"{auth.oauth_session.config_entry.entry_id}_{description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, auth.oauth_session.config_entry.entry_id)}, + manufacturer=MANUFACTURER, + name=auth.oauth_session.config_entry.unique_id, + ) diff --git a/homeassistant/components/google_mail/manifest.json b/homeassistant/components/google_mail/manifest.json new file mode 100644 index 00000000000..3693e8ac619 --- /dev/null +++ b/homeassistant/components/google_mail/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "google_mail", + "name": "Google Mail", + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_mail/", + "requirements": ["google-api-python-client==2.71.0"], + "codeowners": ["@tkdrob"], + "iot_class": "cloud_polling", + "integration_type": "device" +} diff --git a/homeassistant/components/google_mail/notify.py b/homeassistant/components/google_mail/notify.py new file mode 100644 index 00000000000..9abf75ea1e9 --- /dev/null +++ b/homeassistant/components/google_mail/notify.py @@ -0,0 +1,66 @@ +"""Notification service for Google Mail integration.""" +from __future__ import annotations + +import base64 +from email.message import EmailMessage +from typing import Any, cast + +from googleapiclient.http import HttpRequest +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + BaseNotificationService, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .api import AsyncConfigEntryAuth +from .const import ATTR_BCC, ATTR_CC, ATTR_FROM, ATTR_ME, ATTR_SEND, DATA_AUTH + + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> GMailNotificationService: + """Get the notification service.""" + return GMailNotificationService(cast(DiscoveryInfoType, discovery_info)) + + +class GMailNotificationService(BaseNotificationService): + """Define the Google Mail notification logic.""" + + def __init__(self, config: dict[str, Any]) -> None: + """Initialize the service.""" + self.auth: AsyncConfigEntryAuth = config[DATA_AUTH] + + async def async_send_message(self, message: str, **kwargs: Any) -> None: + """Send a message.""" + data: dict[str, Any] = kwargs.get(ATTR_DATA) or {} + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + + email = EmailMessage() + email.set_content(message) + if to_addrs := kwargs.get(ATTR_TARGET): + email["To"] = ", ".join(to_addrs) + email["From"] = data.get(ATTR_FROM, ATTR_ME) + email["Subject"] = title + email[ATTR_CC] = ", ".join(data.get(ATTR_CC, [])) + email[ATTR_BCC] = ", ".join(data.get(ATTR_BCC, [])) + + encoded_message = base64.urlsafe_b64encode(email.as_bytes()).decode() + body = {"raw": encoded_message} + msg: HttpRequest + users = (await self.auth.get_resource()).users() + if data.get(ATTR_SEND) is False: + msg = users.drafts().create(userId=email["From"], body={ATTR_MESSAGE: body}) + else: + if not to_addrs: + raise vol.Invalid("recipient address required") + msg = users.messages().send(userId=email["From"], body=body) + await self.hass.async_add_executor_job(msg.execute) diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py new file mode 100644 index 00000000000..c30ea1c0a65 --- /dev/null +++ b/homeassistant/components/google_mail/sensor.py @@ -0,0 +1,52 @@ +"""Support for Google Mail Sensors.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from googleapiclient.http import HttpRequest + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import GoogleMailEntity + +SCAN_INTERVAL = timedelta(minutes=15) + +SENSOR_TYPE = SensorEntityDescription( + key="vacation_end_date", + name="Vacation end date", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Google Mail sensor.""" + async_add_entities( + [GoogleMailSensor(hass.data[DOMAIN][entry.entry_id], SENSOR_TYPE)], True + ) + + +class GoogleMailSensor(GoogleMailEntity, SensorEntity): + """Representation of a Google Mail sensor.""" + + async def async_update(self) -> None: + """Get the vacation data.""" + service = await self.auth.get_resource() + settings: HttpRequest = service.users().settings().getVacation(userId="me") + data = await self.hass.async_add_executor_job(settings.execute) + + if data["enableAutoReply"]: + value = datetime.fromtimestamp(int(data["endTime"]) / 1000, tz=timezone.utc) + else: + value = None + self._attr_native_value = value diff --git a/homeassistant/components/google_mail/services.py b/homeassistant/components/google_mail/services.py new file mode 100644 index 00000000000..1450a5d31b8 --- /dev/null +++ b/homeassistant/components/google_mail/services.py @@ -0,0 +1,95 @@ +"""Services for Google Mail integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta + +from googleapiclient.http import HttpRequest +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_extract_config_entry_ids + +from .api import AsyncConfigEntryAuth +from .const import ( + ATTR_ENABLED, + ATTR_END, + ATTR_ME, + ATTR_MESSAGE, + ATTR_PLAIN_TEXT, + ATTR_RESTRICT_CONTACTS, + ATTR_RESTRICT_DOMAIN, + ATTR_START, + ATTR_TITLE, + DOMAIN, +) + +SERVICE_SET_VACATION = "set_vacation" + +SERVICE_VACATION_SCHEMA = vol.All( + cv.make_entity_service_schema( + { + vol.Required(ATTR_ENABLED, default=True): cv.boolean, + vol.Optional(ATTR_TITLE): cv.string, + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_PLAIN_TEXT, default=True): cv.boolean, + vol.Optional(ATTR_RESTRICT_CONTACTS): cv.boolean, + vol.Optional(ATTR_RESTRICT_DOMAIN): cv.boolean, + vol.Optional(ATTR_START): cv.date, + vol.Optional(ATTR_END): cv.date, + }, + ) +) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Google Mail integration.""" + + async def extract_gmail_config_entries(call: ServiceCall) -> list[ConfigEntry]: + return [ + entry + for entry_id in await async_extract_config_entry_ids(hass, call) + if (entry := hass.config_entries.async_get_entry(entry_id)) + and entry.domain == DOMAIN + ] + + async def gmail_service(call: ServiceCall) -> None: + """Call Google Mail service.""" + auth: AsyncConfigEntryAuth + for entry in await extract_gmail_config_entries(call): + if not (auth := hass.data[DOMAIN].get(entry.entry_id)): + raise ValueError(f"Config entry not loaded: {entry.entry_id}") + service = await auth.get_resource() + + _settings = { + "enableAutoReply": call.data[ATTR_ENABLED], + "responseSubject": call.data.get(ATTR_TITLE), + } + if contacts := call.data.get(ATTR_RESTRICT_CONTACTS): + _settings["restrictToContacts"] = contacts + if domain := call.data.get(ATTR_RESTRICT_DOMAIN): + _settings["restrictToDomain"] = domain + if _date := call.data.get(ATTR_START): + _dt = datetime.combine(_date, datetime.min.time()) + _settings["startTime"] = _dt.timestamp() * 1000 + if _date := call.data.get(ATTR_END): + _dt = datetime.combine(_date, datetime.min.time()) + _settings["endTime"] = (_dt + timedelta(days=1)).timestamp() * 1000 + if call.data[ATTR_PLAIN_TEXT]: + _settings["responseBodyPlainText"] = call.data[ATTR_MESSAGE] + else: + _settings["responseBodyHtml"] = call.data[ATTR_MESSAGE] + settings: HttpRequest = ( + service.users() + .settings() + .updateVacation(userId=ATTR_ME, body=_settings) + ) + await hass.async_add_executor_job(settings.execute) + + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_SET_VACATION, + schema=SERVICE_VACATION_SCHEMA, + service_func=gmail_service, + ) diff --git a/homeassistant/components/google_mail/services.yaml b/homeassistant/components/google_mail/services.yaml new file mode 100644 index 00000000000..76ef40fa3aa --- /dev/null +++ b/homeassistant/components/google_mail/services.yaml @@ -0,0 +1,53 @@ +set_vacation: + name: Set Vacation + description: Set vacation responder settings for Google Mail. + target: + device: + integration: google_mail + entity: + integration: google_mail + fields: + enabled: + name: Enabled + required: true + default: true + description: Turn this off to end vacation responses. + selector: + boolean: + title: + name: Title + description: The subject for the email + selector: + text: + message: + name: Message + description: Body of the email + required: true + selector: + text: + plain_text: + name: Plain text + default: true + description: Choose to send message in plain text or HTML. + selector: + boolean: + restrict_contacts: + name: Restrict to Contacts + description: Restrict automatic reply to contacts. + selector: + boolean: + restrict_domain: + name: Restrict to Domain + description: Restrict automatic reply to domain. This only affects GSuite accounts. + selector: + boolean: + start: + name: start + description: First day of the vacation + selector: + date: + end: + name: end + description: Last day of the vacation + selector: + date: diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json new file mode 100644 index 00000000000..eaebca01e5d --- /dev/null +++ b/homeassistant/components/google_mail/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Mail integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Mail. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + } +} diff --git a/homeassistant/components/google_mail/translations/en.json b/homeassistant/components/google_mail/translations/en.json new file mode 100644 index 00000000000..51d69638ed2 --- /dev/null +++ b/homeassistant/components/google_mail/translations/en.json @@ -0,0 +1,33 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Mail. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "config": { + "abort": { + "already_configured": "Account is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", + "invalid_access_token": "Invalid access token", + "missing_configuration": "The component is not configured. Please follow the documentation.", + "oauth_error": "Received invalid token data.", + "reauth_successful": "Re-authentication was successful", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "create_entry": { + "default": "Successfully authenticated" + }, + "step": { + "auth": { + "title": "Link Google Account" + }, + "pick_implementation": { + "title": "Pick Authentication Method" + }, + "reauth_confirm": { + "description": "The Google Mail integration needs to re-authenticate your account", + "title": "Reauthenticate Integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 87813c20189..b15642d46e1 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -7,6 +7,7 @@ APPLICATION_CREDENTIALS = [ "geocaching", "google", "google_assistant_sdk", + "google_mail", "google_sheets", "home_connect", "lametric", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7e952bf101b..5a90f65580f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -157,6 +157,7 @@ FLOWS = { "goodwe", "google", "google_assistant_sdk", + "google_mail", "google_sheets", "google_travel_time", "govee_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d3d7e49a27e..76eabb12bc3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1990,6 +1990,12 @@ "iot_class": "cloud_polling", "name": "Google Domains" }, + "google_mail": { + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Mail" + }, "google_maps": { "integration_type": "hub", "config_flow": false, diff --git a/requirements_all.txt b/requirements_all.txt index 7c8b2f6ab30..da30561e91d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -794,6 +794,9 @@ goalzero==0.2.1 # homeassistant.components.goodwe goodwe==0.2.18 +# homeassistant.components.google_mail +google-api-python-client==2.71.0 + # homeassistant.components.google_pubsub google-cloud-pubsub==2.13.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b6cb50274e..34734ef9dd0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -604,6 +604,9 @@ goalzero==0.2.1 # homeassistant.components.goodwe goodwe==0.2.18 +# homeassistant.components.google_mail +google-api-python-client==2.71.0 + # homeassistant.components.google_pubsub google-cloud-pubsub==2.13.11 diff --git a/tests/components/google_mail/__init__.py b/tests/components/google_mail/__init__.py new file mode 100644 index 00000000000..9a1a839fc59 --- /dev/null +++ b/tests/components/google_mail/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Mail integration.""" diff --git a/tests/components/google_mail/conftest.py b/tests/components/google_mail/conftest.py new file mode 100644 index 00000000000..3ecaf5b5d09 --- /dev/null +++ b/tests/components/google_mail/conftest.py @@ -0,0 +1,119 @@ +"""Configure tests for the Google Mail integration.""" +from collections.abc import Awaitable, Callable, Generator +import time +from unittest.mock import patch + +from httplib2 import Response +from pytest import fixture + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_mail.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +ComponentSetup = Callable[[], Awaitable[None]] + +BUILD = "homeassistant.components.google_mail.api.build" +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" +SCOPES = [ + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.settings.basic", +] +SENSOR = "sensor.example_gmail_com_vacation_end_date" +TITLE = "example@gmail.com" +TOKEN = "homeassistant.components.google_mail.api.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid" + + +@fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return SCOPES + + +@fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + + +@fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@fixture(name="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Google Mail entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=TITLE, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + ) + + +@fixture(autouse=True) +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Google Mail connection.""" + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Generator[ComponentSetup, None, None]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + + async def func() -> None: + with patch( + "httplib2.Http.request", + return_value=( + Response({}), + bytes(load_fixture("google_mail/get_vacation.json"), encoding="UTF-8"), + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + yield func diff --git a/tests/components/google_mail/fixtures/get_profile.json b/tests/components/google_mail/fixtures/get_profile.json new file mode 100644 index 00000000000..20e58b2a518 --- /dev/null +++ b/tests/components/google_mail/fixtures/get_profile.json @@ -0,0 +1,6 @@ +{ + "emailAddress": "example@gmail.com", + "messagesTotal": 35308, + "threadsTotal": 33901, + "historyId": "4178212" +} diff --git a/tests/components/google_mail/fixtures/get_vacation.json b/tests/components/google_mail/fixtures/get_vacation.json new file mode 100644 index 00000000000..734e108b1ae --- /dev/null +++ b/tests/components/google_mail/fixtures/get_vacation.json @@ -0,0 +1,8 @@ +{ + "enableAutoReply": true, + "responseSubject": "Vacation", + "responseBodyPlainText": "I am on vacation.", + "restrictToContacts": false, + "startTime": "1668402000000", + "endTime": "1668747600000" +} diff --git a/tests/components/google_mail/fixtures/get_vacation_off.json b/tests/components/google_mail/fixtures/get_vacation_off.json new file mode 100644 index 00000000000..cd3e4c9b96c --- /dev/null +++ b/tests/components/google_mail/fixtures/get_vacation_off.json @@ -0,0 +1,8 @@ +{ + "enableAutoReply": false, + "responseSubject": "Vacation", + "responseBodyPlainText": "I am on vacation.", + "restrictToContacts": false, + "startTime": "1668402000000", + "endTime": "1668747600000" +} diff --git a/tests/components/google_mail/test_config_flow.py b/tests/components/google_mail/test_config_flow.py new file mode 100644 index 00000000000..b0814c4b643 --- /dev/null +++ b/tests/components/google_mail/test_config_flow.py @@ -0,0 +1,178 @@ +"""Test the Google Mail config flow.""" +from unittest.mock import patch + +from httplib2 import Response + +from homeassistant import config_entries +from homeassistant.components.google_mail.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import CLIENT_ID, GOOGLE_AUTH_URI, GOOGLE_TOKEN_URI, SCOPES, TITLE + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + current_request_with_host, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "google_mail", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + with patch( + "homeassistant.components.google_mail.async_setup_entry", return_value=True + ) as mock_setup, patch( + "httplib2.Http.request", + return_value=( + Response({}), + bytes(load_fixture("google_mail/get_profile.json"), encoding="UTF-8"), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result.get("type") == "create_entry" + assert result.get("title") == TITLE + assert "result" in result + assert result.get("result").unique_id == TITLE + assert "token" in result.get("result").data + assert result.get("result").data["token"].get("access_token") == "mock-access-token" + assert ( + result.get("result").data["token"].get("refresh_token") == "mock-refresh-token" + ) + + +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock: AiohttpClientMocker, + current_request_with_host, + config_entry: MockConfigEntry, +) -> None: + """Test the reauthentication case updates the existing config entry.""" + config_entry.add_to_hass(hass) + + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_mail.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result.get("type") == "abort" + assert result.get("reason") == "reauth_successful" + + assert config_entry.unique_id == TITLE + assert "token" in config_entry.data + # Verify access token is refreshed + assert config_entry.data["token"].get("access_token") == "updated-access-token" + assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" + + +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth, + current_request_with_host, + config_entry: MockConfigEntry, +) -> None: + """Test case where config flow discovers unique id was already configured.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "google_mail", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + with patch( + "httplib2.Http.request", + return_value=( + Response({}), + bytes(load_fixture("google_mail/get_profile.json"), encoding="UTF-8"), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == "abort" + assert result.get("reason") == "already_configured" diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py new file mode 100644 index 00000000000..b57547cfd70 --- /dev/null +++ b/tests/components/google_mail/test_init.py @@ -0,0 +1,130 @@ +"""Tests for Google Mail.""" +import http +import time +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +import pytest + +from homeassistant.components.google_mail import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import GOOGLE_TOKEN_URI, ComponentSetup + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_success( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test successful setup and unload.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert not len(hass.services.async_services().get(DOMAIN, {})) + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + "expires_at,status,expected_state", + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + GOOGLE_TOKEN_URI, + status=status, + ) + + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_expired_token_refresh_client_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test failure while refreshing token with a client error.""" + + with patch( + "homeassistant.components.google_mail.OAuth2Session.async_ensure_token_valid", + side_effect=ClientError, + ): + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + +async def test_device_info( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test device info.""" + await setup_integration() + device_registry = dr.async_get(hass) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + + assert device.identifiers == {(DOMAIN, entry.entry_id)} + assert device.manufacturer == "Google, Inc." + assert device.name == "example@gmail.com" diff --git a/tests/components/google_mail/test_notify.py b/tests/components/google_mail/test_notify.py new file mode 100644 index 00000000000..c95d0fa8df3 --- /dev/null +++ b/tests/components/google_mail/test_notify.py @@ -0,0 +1,76 @@ +"""Notify tests for the Google Mail integration.""" +from unittest.mock import patch + +import pytest +from voluptuous.error import Invalid + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import BUILD, ComponentSetup + + +async def test_notify( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test service call draft email.""" + await setup_integration() + + with patch(BUILD) as mock_client: + await hass.services.async_call( + NOTIFY_DOMAIN, + "example_gmail_com", + { + "title": "Test", + "message": "test email", + "target": "text@example.com", + }, + blocking=True, + ) + assert len(mock_client.mock_calls) == 5 + + with patch(BUILD) as mock_client: + await hass.services.async_call( + NOTIFY_DOMAIN, + "example_gmail_com", + { + "title": "Test", + "message": "test email", + "target": "text@example.com", + "data": {"send": False}, + }, + blocking=True, + ) + assert len(mock_client.mock_calls) == 5 + + +async def test_notify_voluptuous_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test voluptuous error thrown when drafting email.""" + await setup_integration() + + with pytest.raises(Invalid) as ex: + await hass.services.async_call( + NOTIFY_DOMAIN, + "example_gmail_com", + { + "title": "Test", + "message": "test email", + }, + blocking=True, + ) + assert ex.match("recipient address required") + + with pytest.raises(Invalid) as ex: + await hass.services.async_call( + NOTIFY_DOMAIN, + "example_gmail_com", + { + "title": "Test", + }, + blocking=True, + ) + assert ex.getrepr("required key not provided") diff --git a/tests/components/google_mail/test_sensor.py b/tests/components/google_mail/test_sensor.py new file mode 100644 index 00000000000..369557ad3e9 --- /dev/null +++ b/tests/components/google_mail/test_sensor.py @@ -0,0 +1,60 @@ +"""Sensor tests for the Google Mail integration.""" +from datetime import timedelta +from unittest.mock import patch + +from google.auth.exceptions import RefreshError +from httplib2 import Response + +from homeassistant import config_entries +from homeassistant.components.google_mail.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from .conftest import SENSOR, TOKEN, ComponentSetup + +from tests.common import async_fire_time_changed, load_fixture + + +async def test_sensors(hass: HomeAssistant, setup_integration: ComponentSetup) -> None: + """Test we get sensor data.""" + await setup_integration() + + state = hass.states.get(SENSOR) + assert state.state == "2022-11-18T05:00:00+00:00" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + + with patch( + "httplib2.Http.request", + return_value=( + Response({}), + bytes(load_fixture("google_mail/get_vacation_off.json"), encoding="UTF-8"), + ), + ): + next_update = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(SENSOR) + assert state.state == STATE_UNKNOWN + + +async def test_sensor_reauth_trigger( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test reauth is triggered after a refresh error.""" + await setup_integration() + + with patch(TOKEN, side_effect=RefreshError): + next_update = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH diff --git a/tests/components/google_mail/test_services.py b/tests/components/google_mail/test_services.py new file mode 100644 index 00000000000..2523e7a9591 --- /dev/null +++ b/tests/components/google_mail/test_services.py @@ -0,0 +1,90 @@ +"""Services tests for the Google Mail integration.""" +from unittest.mock import patch + +from google.auth.exceptions import RefreshError +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_mail import DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import BUILD, SENSOR, TOKEN, ComponentSetup + + +async def test_set_vacation( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test service call set vacation.""" + await setup_integration() + + with patch(BUILD) as mock_client: + await hass.services.async_call( + DOMAIN, + "set_vacation", + { + "entity_id": SENSOR, + "enabled": True, + "title": "Vacation", + "message": "Vacation message", + "plain_text": False, + "restrict_contacts": True, + "restrict_domain": True, + "start": "2022-11-20", + "end": "2022-11-26", + }, + blocking=True, + ) + assert len(mock_client.mock_calls) == 5 + + with patch(BUILD) as mock_client: + await hass.services.async_call( + DOMAIN, + "set_vacation", + { + "entity_id": SENSOR, + "enabled": True, + "title": "Vacation", + "message": "Vacation message", + "plain_text": True, + "restrict_contacts": True, + "restrict_domain": True, + "start": "2022-11-20", + "end": "2022-11-26", + }, + blocking=True, + ) + assert len(mock_client.mock_calls) == 5 + + +async def test_reauth_trigger( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test reauth is triggered after a refresh error during service call.""" + await setup_integration() + + with patch(TOKEN, side_effect=RefreshError), pytest.raises(RefreshError): + await hass.services.async_call( + DOMAIN, + "set_vacation", + { + "entity_id": SENSOR, + "enabled": True, + "title": "Vacation", + "message": "Vacation message", + "plain_text": True, + "restrict_contacts": True, + "restrict_domain": True, + "start": "2022-11-20", + "end": "2022-11-26", + }, + blocking=True, + ) + + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH