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
This commit is contained in:
Robert Hillis 2023-01-07 14:59:14 -05:00 committed by GitHub
parent d2537dacc6
commit ad65fc27bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1303 additions and 0 deletions

View File

@ -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

View File

@ -6,6 +6,7 @@
"google_assistant_sdk",
"google_cloud",
"google_domains",
"google_mail",
"google_maps",
"google_pubsub",
"google_sheets",

View File

@ -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

View File

@ -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)

View File

@ -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",
}

View File

@ -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)

View File

@ -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."

View File

@ -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,
)

View File

@ -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"
}

View File

@ -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)

View File

@ -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

View File

@ -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,
)

View File

@ -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:

View File

@ -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"
}
}

View File

@ -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"
}
}
}
}

View File

@ -7,6 +7,7 @@ APPLICATION_CREDENTIALS = [
"geocaching",
"google",
"google_assistant_sdk",
"google_mail",
"google_sheets",
"home_connect",
"lametric",

View File

@ -157,6 +157,7 @@ FLOWS = {
"goodwe",
"google",
"google_assistant_sdk",
"google_mail",
"google_sheets",
"google_travel_time",
"govee_ble",

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Google Mail integration."""

View File

@ -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

View File

@ -0,0 +1,6 @@
{
"emailAddress": "example@gmail.com",
"messagesTotal": 35308,
"threadsTotal": 33901,
"historyId": "4178212"
}

View File

@ -0,0 +1,8 @@
{
"enableAutoReply": true,
"responseSubject": "Vacation",
"responseBodyPlainText": "I am on vacation.",
"restrictToContacts": false,
"startTime": "1668402000000",
"endTime": "1668747600000"
}

View File

@ -0,0 +1,8 @@
{
"enableAutoReply": false,
"responseSubject": "Vacation",
"responseBodyPlainText": "I am on vacation.",
"restrictToContacts": false,
"startTime": "1668402000000",
"endTime": "1668747600000"
}

View File

@ -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"

View File

@ -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"

View File

@ -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")

View File

@ -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

View File

@ -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