Skip to content
This repository was archived by the owner on Aug 27, 2021. It is now read-only.
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
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,27 @@ Write down the Application (client) ID. You will need this value.
Under "Certificates & secrets", generate a new client secret. Set the expiration preferably to never. Write down the value of the client secret created now. It will be hidden later on.

Under "Api Permissions" add the following delegated permission from the Microsoft Graph API collection
* Calendars.ReadWrite - *Read and write user calendars*
* Calendars.ReadWrite.Shared - *Read and write user and shared calendars*
* offline_access - *Maintain access to data you have given it access to*
* Users.Read - *Sign in and read user profile*
* email - *View users' email address*

If `calendar_access` is equal to `ReadWrite`
* Calendars.ReadWrite - *Read and write user calendars*
* Calendars.ReadWrite.Shared - *Read and write user and shared calendars*

If `calendar_access` is equal to `Read`
* Calendars.Read - *Read user calendars*
* Calendars.Read.Shared - *Read user and shared calendars*

If `email_access` is equal to `ReadWrite`
* Mail.ReadWrite - *Read and write access to user mail*
* Mail.ReadWrite.Shared - *Read and write user and shared mail*
* Mail.Send - *Send mail as a user*
* Mail.Send.Shared - *Send mail on behalf of others*

If `email_access` is equal to `Read`
* Mail.Read - *Read access to user mail*
* Mail.Read.Shared - *Read user and shared mail*

## Adding to Home Assistant

### Manual installation
Expand Down Expand Up @@ -107,6 +118,8 @@ Key | Type | Required | Description
`calendars` | `list<calendars>` | `False` | List of calendar config entries
`email_sensors` | `list<email_sensors>` | `False` | List of email_sensor config entries
`query_sensors` | `list<query_sensors>` | `False` | List of query_sensor config entries
`calendar_access` | `Disabled`, `Read` or `ReadWrite` | `False` | Determines the access level for calendars. Defaults to `ReadWrite`.
`email_access` | `Disabled`, `Read` or `ReadWrite` | `False` | Determines the access level for email. Defaults to `ReadWrite`.

### email_sensors
Key | Type | Required | Description
Expand Down
12 changes: 7 additions & 5 deletions custom_components/o365/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
AUTH_CALLBACK_PATH,
AUTH_CALLBACK_PATH_ALT,
TOKEN_BACKEND,
SCOPE,
CONF_CALENDARS,
DEFAULT_NAME,
CONFIGURATOR_LINK_NAME,
Expand All @@ -29,14 +28,16 @@
CONF_TRACK_NEW,
)

from .utils import validate_permissions
from .utils import (
validate_permissions,
get_scopes
)

_LOGGER = logging.getLogger(__name__)


def setup(hass, config):
"""Set up the O365 platform."""
validate_permissions()
conf = config.get(DOMAIN, {})
CONFIG_SCHEMA(conf)
credentials = (conf.get(CONF_CLIENT_ID), conf.get(CONF_CLIENT_SECRET))
Expand All @@ -51,10 +52,11 @@ def setup(hass, config):

account = Account(credentials, token_backend=TOKEN_BACKEND)
is_authenticated = account.is_authenticated
permissions = validate_permissions()
scopes = get_scopes(conf)
permissions = validate_permissions(scopes)
if not is_authenticated or not permissions:
url, state = account.con.get_authorization_url(
requested_scopes=SCOPE, redirect_uri=callback_url
requested_scopes=scopes, redirect_uri=callback_url
)
_LOGGER.info("no token; requesting authorization")
callback_view = O365AuthCallbackView(
Expand Down
40 changes: 23 additions & 17 deletions custom_components/o365/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
CONF_MAX_RESULTS,
CALENDAR_ENTITY_ID_FORMAT,
CONF_TRACK_NEW,
CONF_CALENDAR_ACCESS,
FeatureAccess
)
from .utils import (
clean_html,
Expand All @@ -51,6 +53,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
if not is_authenticated:
return False

conf = config.get(DOMAIN, {})
if conf.get(CONF_CALENDAR_ACCESS) is FeatureAccess.Disabled:
return False

calendar_services = CalendarServices(account, track_new, hass)
calendar_services.scan_for_calendars(None)

Expand All @@ -68,22 +74,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
devices.append(cal)
add_devices(devices, True)

hass.services.register(
DOMAIN, "modify_calendar_event", calendar_services.modify_calendar_event
)
hass.services.register(
DOMAIN, "create_calendar_event", calendar_services.create_calendar_event
)
hass.services.register(
DOMAIN, "remove_calendar_event", calendar_services.remove_calendar_event
)
hass.services.register(
DOMAIN, "respond_calendar_event", calendar_services.respond_calendar_event
)
hass.services.register(
DOMAIN, "scan_for_calendars", calendar_services.scan_for_calendars
)

if conf.get(CONF_CALENDAR_ACCESS) is FeatureAccess.ReadWrite:
hass.services.register(
DOMAIN, "modify_calendar_event", calendar_services.modify_calendar_event
)
hass.services.register(
DOMAIN, "create_calendar_event", calendar_services.create_calendar_event
)
hass.services.register(
DOMAIN, "remove_calendar_event", calendar_services.remove_calendar_event
)
hass.services.register(
DOMAIN, "respond_calendar_event", calendar_services.respond_calendar_event
)

return True


Expand Down Expand Up @@ -254,15 +262,13 @@ def to_datetime(obj):
@staticmethod
def get_end_date(obj):
if hasattr(obj, "end"):
enddate = obj.end
return obj.end

elif hasattr(obj, "duration"):
enddate = obj.start + obj.duration.value
return obj.start + obj.duration.value

else:
enddate = obj.start + timedelta(days=1)

return enddate
return obj.start + timedelta(days=1)


class CalendarServices:
Expand Down
39 changes: 33 additions & 6 deletions custom_components/o365/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class EventResponse(Enum):
Tentative = "tentative"
Decline = "decline"

class FeatureAccess(Enum):
Disabled = "disabled"
Read = "read"
ReadWrite = "readwrite"


ATTR_ATTACHMENTS = "attachments"
ATTR_ATTENDEES = "attendees"
Expand Down Expand Up @@ -76,6 +81,10 @@ class EventResponse(Enum):
CONF_SUBJECT_CONTAINS = "subject_contains"
CONF_SUBJECT_IS = "subject_is"
CONF_TRACK_NEW = "track_new_calendar"

CONF_CALENDAR_ACCESS = "calendar_access"
CONF_EMAIL_ACCESS = "email_access"

CONFIG_BASE_DIR = get_default_config_dir()
CONFIGURATOR_DESCRIPTION = (
"To link your O365 account, click the link, login, and authorize:"
Expand All @@ -91,21 +100,36 @@ class EventResponse(Enum):
ICON = "mdi:office"
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
DEFAULT_OFFSET = "!!"
SCOPE = [
BASE_SCOPES = [
"offline_access",
"User.Read",
]
CALENDAR_READ_SCOPES = [
"Calendars.Read",
"Calendars.Read.Shared",
]
CALENDAR_READ_WRITE_SCOPES = [
"Calendars.ReadWrite",
"Calendars.ReadWrite.Shared",
]
EMAIL_READ_SCOPES = [
"Mail.Read",
"Mail.Read.Shared",
]
EMAIL_READ_WRITE_SCOPES = [
"Mail.ReadWrite",
"Mail.ReadWrite.Shared",
"Mail.Send",
"Mail.Send.Shared",
]
MINIMUM_REQUIRED_SCOPES = [
"User.Read",
"Calendars.ReadWrite",
"Mail.ReadWrite",
"Mail.Send",
# Scopes that might not exist in the retrieved token
IGNORABLE_SCOPES = [
"offline_access",
"Calendars.Read.Shared",
"Calendars.ReadWrite.Shared",
"Mail.Read.Shared",
"Mail.ReadWrite.Shared",
"Mail.Send.Shared",
]
TOKEN_BACKEND = FileSystemTokenBackend(
token_path=DEFAULT_CACHE_PATH, token_filename="o365.token"
Expand Down Expand Up @@ -151,6 +175,9 @@ class EventResponse(Enum):
vol.Optional(CONF_CALENDARS, default=[]): [CALENDAR_SCHEMA],
vol.Optional(CONF_EMAIL_SENSORS): [EMAIL_SENSOR],
vol.Optional(CONF_QUERY_SENSORS): [QUERY_SENSOR],

vol.Optional(CONF_CALENDAR_ACCESS, default='ReadWrite'): cv.enum(FeatureAccess),
vol.Optional(CONF_EMAIL_ACCESS, default='ReadWrite'): cv.enum(FeatureAccess)
},
)
},
Expand Down
8 changes: 6 additions & 2 deletions custom_components/o365/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
ATTR_ZIP_ATTACHMENTS,
ATTR_ZIP_NAME,
NOTIFY_BASE_SCHEMA,
CONF_EMAIL_ACCESS,
FeatureAccess,
)

_LOGGER = logging.getLogger(__name__)
Expand All @@ -25,8 +27,10 @@ async def async_get_service(hass, config, discovery_info=None):
is_authenticated = account.is_authenticated
if not is_authenticated:
return
email_service = O365EmailService(account)
return email_service
conf = config.get(DOMAIN, {})
if conf.get(CONF_EMAIL_ACCESS) is not FeatureAccess.ReadWrite:
return
return O365EmailService(account)


class O365EmailService(BaseNotificationService):
Expand Down
6 changes: 6 additions & 0 deletions custom_components/o365/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
CONF_IS_UNREAD,
CONF_EMAIL_SENSORS,
CONF_QUERY_SENSORS,
CONF_EMAIL_ACCESS,
FeatureAccess,
)
from .utils import get_email_attributes

Expand All @@ -29,6 +31,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
if not is_authenticated:
return False

conf = config.get(DOMAIN, {})
if conf.get(CONF_EMAIL_ACCESS) is FeatureAccess.Disabled:
return False

unread_sensors = hass.data[DOMAIN].get(CONF_EMAIL_SENSORS, [])
for conf in unread_sensors:
sensor = O365InboxSensor(account, conf)
Expand Down
38 changes: 31 additions & 7 deletions custom_components/o365/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
from bs4 import BeautifulSoup
from .const import (
DEFAULT_CACHE_PATH,
MINIMUM_REQUIRED_SCOPES,
BASE_SCOPES,
CALENDAR_READ_SCOPES,
CALENDAR_READ_WRITE_SCOPES,
EMAIL_READ_SCOPES,
EMAIL_READ_WRITE_SCOPES,
IGNORABLE_SCOPES,
CONFIG_BASE_DIR,
DATETIME_FORMAT,
CALENDAR_DEVICE_SCHEMA,
Expand All @@ -15,6 +20,9 @@
CONF_TRACK,
CONF_NAME,
CONF_DEVICE_ID,
CONF_CALENDAR_ACCESS,
CONF_EMAIL_ACCESS,
FeatureAccess,
)
from O365.calendar import Attendee
from homeassistant.util import dt
Expand All @@ -34,17 +42,34 @@ def clean_html(html):
else:
return html

def get_scopes(conf):
scopes = [x for x in BASE_SCOPES]

def validate_permissions(token_path=DEFAULT_CACHE_PATH, token_filename="o365.token"):
if conf.get(CONF_CALENDAR_ACCESS) is FeatureAccess.ReadWrite:
scopes += CALENDAR_READ_WRITE_SCOPES
elif conf.get(CONF_CALENDAR_ACCESS) is FeatureAccess.Read:
scopes += CALENDAR_READ_SCOPES

if conf.get(CONF_EMAIL_ACCESS) is FeatureAccess.ReadWrite:
scopes += EMAIL_READ_WRITE_SCOPES
elif conf.get(CONF_EMAIL_ACCESS) is FeatureAccess.Read:
scopes += EMAIL_READ_SCOPES

_LOGGER.warning(f"Required scopes: {scopes}")

return scopes


def validate_permissions(scopes, token_path=DEFAULT_CACHE_PATH, token_filename="o365.token"):
full_token_path = os.path.join(token_path, token_filename)
if not os.path.exists(full_token_path) or not os.path.isfile(full_token_path):
_LOGGER.warning(f"Could not loacte token at {full_token_path}")
_LOGGER.warning(f"Could not locate token at {full_token_path}")
return False
with open(full_token_path, "r", encoding="UTF-8") as fh:
raw = fh.read()
permissions = json.loads(raw)["scope"]
scope = [x for x in MINIMUM_REQUIRED_SCOPES]
all_permissions_granted = all([x in permissions for x in scope])
mandatory_scopes = [x for x in scopes if x not in IGNORABLE_SCOPES]
all_permissions_granted = all(x in permissions for x in mandatory_scopes)
if not all_permissions_granted:
_LOGGER.warning(f"All permissions granted: {all_permissions_granted}")
return all_permissions_granted
Expand Down Expand Up @@ -191,7 +216,7 @@ def load_calendars(path):

def get_calendar_info(hass, calendar, track_new_devices):
"""Convert data from O365 into DEVICE_SCHEMA."""
calendar_info = CALENDAR_DEVICE_SCHEMA(
return CALENDAR_DEVICE_SCHEMA(
{
CONF_CAL_ID: calendar.calendar_id,
CONF_ENTITIES: [
Expand All @@ -203,7 +228,6 @@ def get_calendar_info(hass, calendar, track_new_devices):
],
}
)
return calendar_info


def update_calendar_file(path, calendar, hass, track_new_devices):
Expand Down