pyxecm 1.4__py3-none-any.whl → 1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pyxecm might be problematic. Click here for more details.

pyxecm/customizer/m365.py CHANGED
@@ -30,6 +30,7 @@ update_user: Update selected properties of an M365 user
30
30
  get_user_licenses: Get the assigned license SKUs of a user
31
31
  assign_license_to_user: Add an M365 license to a user (e.g. to use Office 365)
32
32
  get_user_photo: Get the photo of a M365 user
33
+ download_user_photo: Download the M365 user photo and save it to the local file system
33
34
  update_user_photo: Update a user with a profile photo (which must be in local file system)
34
35
 
35
36
  get_groups: Get list all all groups in M365 tenant
@@ -63,6 +64,7 @@ upload_teams_app: Upload a new app package to the catalog of MS Teams apps
63
64
  remove_teams_app: Remove MS Teams App for the app catalog
64
65
  assign_teams_app_to_user: Assign (add) a MS Teams app to a M365 user.
65
66
  upgrade_teams_app_of_user: Upgrade a MS teams app for a user.
67
+ remove_teams_app_from_user: Remove a M365 Teams app from a M365 user.
66
68
  assign_teams_app_to_team: Assign (add) a MS Teams app to a M365 team
67
69
  (so that it afterwards can be added as a Tab in a M365 Teams Channel)
68
70
  upgrade_teams_app_of_team: Upgrade a MS teams app for a specific team.
@@ -74,6 +76,17 @@ add_sensitivity_label: Assign a existing sensitivity label to a user.
74
76
  THIS IS CURRENTLY NOT WORKING!
75
77
  assign_sensitivity_label_to_user: Create a new sensitivity label in M365
76
78
  THIS IS CURRENTLY NOT WORKING!
79
+
80
+ upload_outlook_app: Upload the M365 Outlook Add-In as "Integrated" App to M365 Admin Center. (NOT WORKING)
81
+ get_app_registration: Find an Azure App Registration based on its name
82
+ add_app_registration: Add an Azure App Registration
83
+ update_app_registration: Update an Azure App Registration
84
+
85
+ get_mail: Get email from inbox of a given user and a given sender (from)
86
+ get_mail_body: Get full email body for a given email ID
87
+ extract_url_from_message_body: Parse the email body to extract (a potentially multi-line) URL from the body.
88
+ delete_mail: Delete email from inbox of a given user and a given email ID.
89
+ email_verification: Process email verification
77
90
  """
78
91
 
79
92
  __author__ = "Dr. Marc Diefenbruch"
@@ -90,9 +103,13 @@ import time
90
103
  import urllib.parse
91
104
  import zipfile
92
105
  from urllib.parse import quote
106
+ from datetime import datetime
93
107
 
94
108
  import requests
95
109
 
110
+ from pyxecm.helper.web import HTTP
111
+ from pyxecm.customizer.browser_automation import BrowserAutomation
112
+
96
113
  logger = logging.getLogger("pyxecm.customizer.m365")
97
114
 
98
115
  request_login_headers = {
@@ -100,6 +117,7 @@ request_login_headers = {
100
117
  "Accept": "application/json",
101
118
  }
102
119
 
120
+ REQUEST_TIMEOUT = 60
103
121
 
104
122
  class M365(object):
105
123
  """Used to automate stettings in Microsoft 365 via the Graph API."""
@@ -107,6 +125,7 @@ class M365(object):
107
125
  _config: dict
108
126
  _access_token = None
109
127
  _user_access_token = None
128
+ _http_object: HTTP | None = None
110
129
 
111
130
  def __init__(
112
131
  self,
@@ -116,6 +135,7 @@ class M365(object):
116
135
  domain: str,
117
136
  sku_id: str,
118
137
  teams_app_name: str,
138
+ teams_app_external_id: str,
119
139
  ):
120
140
  """Initialize the M365 object
121
141
 
@@ -126,6 +146,7 @@ class M365(object):
126
146
  domain (str): M365 domain
127
147
  sku_id (str): License SKU for M365 users
128
148
  teams_app_name (str): name of the Extended ECM app for MS Teams
149
+ teams_app_external_id (str): external ID of the Extended ECM app for MS Teams
129
150
  """
130
151
 
131
152
  m365_config = {}
@@ -137,6 +158,10 @@ class M365(object):
137
158
  m365_config["domain"] = domain
138
159
  m365_config["skuId"] = sku_id
139
160
  m365_config["teamsAppName"] = teams_app_name
161
+ m365_config["teamsAppExternalId"] = (
162
+ teams_app_external_id # this is the external App ID
163
+ )
164
+ m365_config["teamsAppInternalId"] = None # will be set later...
140
165
  m365_config[
141
166
  "authenticationUrl"
142
167
  ] = "https://login.microsoftonline.com/{}/oauth2/v2.0/token".format(tenant_id)
@@ -162,6 +187,7 @@ class M365(object):
162
187
  m365_config["applicationsUrl"] = m365_config["graphUrl"] + "applications"
163
188
 
164
189
  self._config = m365_config
190
+ self._http_object = HTTP()
165
191
 
166
192
  def config(self) -> dict:
167
193
  """Returns the configuration dictionary
@@ -396,7 +422,7 @@ class M365(object):
396
422
 
397
423
  # Already authenticated and session still valid?
398
424
  if self._access_token and not revalidate:
399
- logger.info(
425
+ logger.debug(
400
426
  "Session still valid - return existing access token -> %s",
401
427
  str(self._access_token),
402
428
  )
@@ -405,7 +431,7 @@ class M365(object):
405
431
  request_url = self.config()["authenticationUrl"]
406
432
  request_header = request_login_headers
407
433
 
408
- logger.info("Requesting M365 Access Token from -> %s", request_url)
434
+ logger.debug("Requesting M365 Access Token from -> %s", request_url)
409
435
 
410
436
  authenticate_post_body = self.credentials()
411
437
  authenticate_response = None
@@ -415,7 +441,7 @@ class M365(object):
415
441
  request_url,
416
442
  data=authenticate_post_body,
417
443
  headers=request_header,
418
- timeout=60,
444
+ timeout=REQUEST_TIMEOUT,
419
445
  )
420
446
  except requests.exceptions.ConnectionError as exception:
421
447
  logger.warning(
@@ -459,7 +485,7 @@ class M365(object):
459
485
  request_url = self.config()["authenticationUrl"]
460
486
  request_header = request_login_headers
461
487
 
462
- logger.info(
488
+ logger.debug(
463
489
  "Requesting M365 Access Token for user -> %s from -> %s",
464
490
  username,
465
491
  request_url,
@@ -473,7 +499,7 @@ class M365(object):
473
499
  request_url,
474
500
  data=authenticate_post_body,
475
501
  headers=request_header,
476
- timeout=60,
502
+ timeout=REQUEST_TIMEOUT,
477
503
  )
478
504
  except requests.exceptions.ConnectionError as exception:
479
505
  logger.warning(
@@ -515,16 +541,18 @@ class M365(object):
515
541
  request_url = self.config()["usersUrl"]
516
542
  request_header = self.request_header()
517
543
 
518
- logger.info("Get list of all users; calling -> %s", request_url)
544
+ logger.debug("Get list of all users; calling -> %s", request_url)
519
545
 
520
546
  retries = 0
521
547
  while True:
522
- response = requests.get(request_url, headers=request_header, timeout=60)
548
+ response = requests.get(
549
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
550
+ )
523
551
  if response.ok:
524
552
  return self.parse_request_response(response)
525
553
  # Check if Session has expired - then re-authenticate and try once more
526
554
  elif response.status_code == 401 and retries == 0:
527
- logger.warning("Session has expired - try to re-authenticate...")
555
+ logger.debug("Session has expired - try to re-authenticate...")
528
556
  self.authenticate(revalidate=True)
529
557
  request_header = self.request_header()
530
558
  retries += 1
@@ -573,19 +601,46 @@ class M365(object):
573
601
  }
574
602
  """
575
603
 
604
+ # Some sanity checks:
605
+ if not "@" in user_email or not "." in user_email:
606
+ logger.error("User email -> %s is not a valid email address", user_email)
607
+ return None
608
+
609
+ # if there's an alias in the E-Mail Adress we remove it as
610
+ # MS Graph seems to not support an alias to lookup a user object.
611
+ if "+" in user_email:
612
+ logger.info(
613
+ "Removing Alias from email address -> %s to determine M365 principal name...",
614
+ user_email,
615
+ )
616
+ # Find the index of the '+' character
617
+ alias_index = user_email.find("+")
618
+
619
+ # Find the index of the '@' character
620
+ domain_index = user_email.find("@")
621
+
622
+ # Construct the email address without the alias
623
+ user_email = user_email[:alias_index] + user_email[domain_index:]
624
+ logger.info(
625
+ "M365 user principal name -> %s",
626
+ user_email,
627
+ )
628
+
576
629
  request_url = self.config()["usersUrl"] + "/" + user_email
577
630
  request_header = self.request_header()
578
631
 
579
- logger.info("Get M365 user -> %s; calling -> %s", user_email, request_url)
632
+ logger.debug("Get M365 user -> %s; calling -> %s", user_email, request_url)
580
633
 
581
634
  retries = 0
582
635
  while True:
583
- response = requests.get(request_url, headers=request_header, timeout=60)
636
+ response = requests.get(
637
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
638
+ )
584
639
  if response.ok:
585
640
  return self.parse_request_response(response)
586
641
  # Check if Session has expired - then re-authenticate and try once more
587
642
  elif response.status_code == 401 and retries == 0:
588
- logger.warning("Session has expired - try to re-authenticate...")
643
+ logger.debug("Session has expired - try to re-authenticate...")
589
644
  self.authenticate(revalidate=True)
590
645
  request_header = self.request_header()
591
646
  retries += 1
@@ -606,7 +661,7 @@ class M365(object):
606
661
  response.text,
607
662
  )
608
663
  else:
609
- logger.info("M365 User -> %s not found.", user_email)
664
+ logger.debug("M365 User -> %s not found.", user_email)
610
665
  return None
611
666
 
612
667
  # end method definition
@@ -657,7 +712,7 @@ class M365(object):
657
712
  request_url = self.config()["usersUrl"]
658
713
  request_header = self.request_header()
659
714
 
660
- logger.info("Adding M365 user -> %s; calling -> %s", email, request_url)
715
+ logger.debug("Adding M365 user -> %s; calling -> %s", email, request_url)
661
716
 
662
717
  retries = 0
663
718
  while True:
@@ -665,13 +720,13 @@ class M365(object):
665
720
  request_url,
666
721
  data=json.dumps(user_post_body),
667
722
  headers=request_header,
668
- timeout=60,
723
+ timeout=REQUEST_TIMEOUT,
669
724
  )
670
725
  if response.ok:
671
726
  return self.parse_request_response(response)
672
727
  # Check if Session has expired - then re-authenticate and try once more
673
728
  elif response.status_code == 401 and retries == 0:
674
- logger.warning("Session has expired - try to re-authenticate...")
729
+ logger.debug("Session has expired - try to re-authenticate...")
675
730
  self.authenticate(revalidate=True)
676
731
  request_header = self.request_header()
677
732
  retries += 1
@@ -698,6 +753,9 @@ class M365(object):
698
753
  """Update selected properties of an M365 user. Documentation
699
754
  on user properties is here: https://learn.microsoft.com/en-us/graph/api/user-update
700
755
 
756
+ Args:
757
+ user_id (str): ID of the user (can also be email). This is also the unique identifier
758
+ updated_settings (dict): new data to update the user with
701
759
  Returns:
702
760
  dict | None: Response of the M365 Graph API or None if the call fails.
703
761
  """
@@ -705,8 +763,8 @@ class M365(object):
705
763
  request_url = self.config()["usersUrl"] + "/" + user_id
706
764
  request_header = self.request_header()
707
765
 
708
- logger.info(
709
- "Updating M365 user -> %s with -> %s; calling -> %s",
766
+ logger.debug(
767
+ "Updating M365 user with ID -> %s with -> %s; calling -> %s",
710
768
  user_id,
711
769
  str(updated_settings),
712
770
  request_url,
@@ -718,13 +776,13 @@ class M365(object):
718
776
  request_url,
719
777
  json=updated_settings,
720
778
  headers=request_header,
721
- timeout=60,
779
+ timeout=REQUEST_TIMEOUT,
722
780
  )
723
781
  if response.ok:
724
782
  return self.parse_request_response(response)
725
783
  # Check if Session has expired - then re-authenticate and try once more
726
784
  elif response.status_code == 401 and retries == 0:
727
- logger.warning("Session has expired - try to re-authenticate...")
785
+ logger.debug("Session has expired - try to re-authenticate...")
728
786
  self.authenticate(revalidate=True)
729
787
  request_header = self.request_header()
730
788
  retries += 1
@@ -775,12 +833,14 @@ class M365(object):
775
833
 
776
834
  retries = 0
777
835
  while True:
778
- response = requests.get(request_url, headers=request_header, timeout=60)
836
+ response = requests.get(
837
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
838
+ )
779
839
  if response.ok:
780
840
  return self.parse_request_response(response)
781
841
  # Check if Session has expired - then re-authenticate and try once more
782
842
  elif response.status_code == 401 and retries == 0:
783
- logger.warning("Session has expired - try to re-authenticate...")
843
+ logger.debug("Session has expired - try to re-authenticate...")
784
844
  self.authenticate(revalidate=True)
785
845
  request_header = self.request_header()
786
846
  retries += 1
@@ -830,7 +890,7 @@ class M365(object):
830
890
  "removeLicenses": [],
831
891
  }
832
892
 
833
- logger.info(
893
+ logger.debug(
834
894
  "Assign M365 license -> %s to M365 user -> %s; calling -> %s",
835
895
  sku_id,
836
896
  user_id,
@@ -840,13 +900,16 @@ class M365(object):
840
900
  retries = 0
841
901
  while True:
842
902
  response = requests.post(
843
- request_url, json=license_post_body, headers=request_header, timeout=60
903
+ request_url,
904
+ json=license_post_body,
905
+ headers=request_header,
906
+ timeout=REQUEST_TIMEOUT,
844
907
  )
845
908
  if response.ok:
846
909
  return self.parse_request_response(response)
847
910
  # Check if Session has expired - then re-authenticate and try once more
848
911
  elif response.status_code == 401 and retries == 0:
849
- logger.warning("Session has expired - try to re-authenticate...")
912
+ logger.debug("Session has expired - try to re-authenticate...")
850
913
  self.authenticate(revalidate=True)
851
914
  request_header = self.request_header()
852
915
  retries += 1
@@ -885,16 +948,18 @@ class M365(object):
885
948
  # Set image as content type:
886
949
  request_header = self.request_header("image/*")
887
950
 
888
- logger.info("Get photo of user -> %s; calling -> %s", user_id, request_url)
951
+ logger.debug("Get photo of user -> %s; calling -> %s", user_id, request_url)
889
952
 
890
953
  retries = 0
891
954
  while True:
892
- response = requests.get(request_url, headers=request_header, timeout=60)
955
+ response = requests.get(
956
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
957
+ )
893
958
  if response.ok:
894
959
  return response.content # this is the actual image - not json!
895
960
  # Check if Session has expired - then re-authenticate and try once more
896
961
  elif response.status_code == 401 and retries == 0:
897
- logger.warning("Session has expired - try to re-authenticate...")
962
+ logger.debug("Session has expired - try to re-authenticate...")
898
963
  self.authenticate(revalidate=True)
899
964
  request_header = self.request_header()
900
965
  retries += 1
@@ -915,7 +980,88 @@ class M365(object):
915
980
  response.text,
916
981
  )
917
982
  else:
918
- logger.info("User -> %s does not yet have a photo.", user_id)
983
+ logger.debug("M365 User -> %s does not yet have a photo.", user_id)
984
+ return None
985
+
986
+ # end method definition
987
+
988
+ def download_user_photo(self, user_id: str, photo_path: str) -> str:
989
+ """Download the M365 user photo and save it to the local file system
990
+
991
+ Args:
992
+ user_id (str): M365 GUID of the user (can also be the M365 email of the user)
993
+ photo_path (str): Directory where the photo should be saved
994
+ Returns:
995
+ str: name of the photo file in the file system (with full path) or None if
996
+ the call of the REST API fails.
997
+ """
998
+
999
+ request_url = self.config()["usersUrl"] + "/" + user_id + "/photo/$value"
1000
+ request_header = self.request_header("application/json")
1001
+
1002
+ logger.debug(
1003
+ "Downloading photo for M365 user with ID -> %s; calling -> %s",
1004
+ user_id,
1005
+ request_url,
1006
+ )
1007
+
1008
+ retries = 0
1009
+ while True:
1010
+ response = requests.get(
1011
+ request_url,
1012
+ headers=request_header,
1013
+ timeout=REQUEST_TIMEOUT,
1014
+ stream=True,
1015
+ )
1016
+ if response.ok:
1017
+ content_type = response.headers.get("Content-Type", "image/png")
1018
+ if content_type == "image/jpeg":
1019
+ file_extension = "jpg"
1020
+ elif content_type == "image/png":
1021
+ file_extension = "png"
1022
+ else:
1023
+ file_extension = "img" # Default extension if type is unknown
1024
+ file_path = os.path.join(
1025
+ photo_path, "{}.{}".format(user_id, file_extension)
1026
+ )
1027
+
1028
+ try:
1029
+ with open(file_path, "wb") as file:
1030
+ for chunk in response.iter_content(chunk_size=8192):
1031
+ file.write(chunk)
1032
+ logger.info(
1033
+ "Photo for M365 user with ID -> %s saved to %s",
1034
+ user_id,
1035
+ file_path,
1036
+ )
1037
+ return file_path
1038
+ except OSError as exception:
1039
+ logger.error(
1040
+ "Error saving photo for user with ID -> %s; error -> %s",
1041
+ user_id,
1042
+ exception,
1043
+ )
1044
+ return None
1045
+ elif response.status_code == 401 and retries == 0:
1046
+ logger.debug("Session has expired - try to re-authenticate...")
1047
+ self.authenticate(revalidate=True)
1048
+ request_header = self.request_header("application/json")
1049
+ retries += 1
1050
+ elif response.status_code in [502, 503, 504] and retries < 3:
1051
+ logger.warning(
1052
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1053
+ response.status_code,
1054
+ (retries + 1) * 60,
1055
+ )
1056
+ time.sleep((retries + 1) * 60)
1057
+ retries += 1
1058
+ else:
1059
+ logger.error(
1060
+ "Failed to download photo for user with ID -> %s; status -> %s; error -> %s",
1061
+ user_id,
1062
+ response.status_code,
1063
+ response.text,
1064
+ )
919
1065
  return None
920
1066
 
921
1067
  # end method definition
@@ -952,8 +1098,8 @@ class M365(object):
952
1098
 
953
1099
  data = photo_data
954
1100
 
955
- logger.info(
956
- "Update M365 user -> %s with photo -> %s; calling -> %s",
1101
+ logger.debug(
1102
+ "Update M365 user with ID -> %s with photo -> %s; calling -> %s",
957
1103
  user_id,
958
1104
  photo_path,
959
1105
  request_url,
@@ -962,13 +1108,13 @@ class M365(object):
962
1108
  retries = 0
963
1109
  while True:
964
1110
  response = requests.put(
965
- request_url, headers=request_header, data=data, timeout=60
1111
+ request_url, headers=request_header, data=data, timeout=REQUEST_TIMEOUT
966
1112
  )
967
1113
  if response.ok:
968
1114
  return self.parse_request_response(response)
969
1115
  # Check if Session has expired - then re-authenticate and try once more
970
1116
  elif response.status_code == 401 and retries == 0:
971
- logger.warning("Session has expired - try to re-authenticate...")
1117
+ logger.debug("Session has expired - try to re-authenticate...")
972
1118
  self.authenticate(revalidate=True)
973
1119
  request_header = self.request_header()
974
1120
  retries += 1
@@ -982,7 +1128,7 @@ class M365(object):
982
1128
  retries += 1
983
1129
  else:
984
1130
  logger.error(
985
- "Failed to update user -> %s with photo -> %s; status -> %s; error -> %s",
1131
+ "Failed to update user with ID -> %s with photo -> %s; status -> %s; error -> %s",
986
1132
  user_id,
987
1133
  photo_path,
988
1134
  response.status_code,
@@ -1004,7 +1150,7 @@ class M365(object):
1004
1150
  request_url = self.config()["groupsUrl"]
1005
1151
  request_header = self.request_header()
1006
1152
 
1007
- logger.info("Get list of all M365 groups; calling -> %s", request_url)
1153
+ logger.debug("Get list of all M365 groups; calling -> %s", request_url)
1008
1154
 
1009
1155
  retries = 0
1010
1156
  while True:
@@ -1012,13 +1158,13 @@ class M365(object):
1012
1158
  request_url,
1013
1159
  headers=request_header,
1014
1160
  params={"$top": str(max_number)},
1015
- timeout=60,
1161
+ timeout=REQUEST_TIMEOUT,
1016
1162
  )
1017
1163
  if response.ok:
1018
1164
  return self.parse_request_response(response)
1019
1165
  # Check if Session has expired - then re-authenticate and try once more
1020
1166
  elif response.status_code == 401 and retries == 0:
1021
- logger.warning("Session has expired - try to re-authenticate...")
1167
+ logger.debug("Session has expired - try to re-authenticate...")
1022
1168
  self.authenticate(revalidate=True)
1023
1169
  request_header = self.request_header()
1024
1170
  retries += 1
@@ -1101,16 +1247,18 @@ class M365(object):
1101
1247
  request_url = self.config()["groupsUrl"] + "?" + encoded_query
1102
1248
  request_header = self.request_header()
1103
1249
 
1104
- logger.info("Get M365 group -> %s; calling -> %s", group_name, request_url)
1250
+ logger.debug("Get M365 group -> %s; calling -> %s", group_name, request_url)
1105
1251
 
1106
1252
  retries = 0
1107
1253
  while True:
1108
- response = requests.get(request_url, headers=request_header, timeout=60)
1254
+ response = requests.get(
1255
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1256
+ )
1109
1257
  if response.ok:
1110
1258
  return self.parse_request_response(response)
1111
1259
  # Check if Session has expired - then re-authenticate and try once more
1112
1260
  elif response.status_code == 401 and retries == 0:
1113
- logger.warning("Session has expired - try to re-authenticate...")
1261
+ logger.debug("Session has expired - try to re-authenticate...")
1114
1262
  self.authenticate(revalidate=True)
1115
1263
  request_header = self.request_header()
1116
1264
  retries += 1
@@ -1131,7 +1279,7 @@ class M365(object):
1131
1279
  response.text,
1132
1280
  )
1133
1281
  else:
1134
- logger.info("M365 Group -> %s not found.", group_name)
1282
+ logger.debug("M365 Group -> %s not found.", group_name)
1135
1283
  return None
1136
1284
 
1137
1285
  # end method definition
@@ -1197,7 +1345,7 @@ class M365(object):
1197
1345
  request_url = self.config()["groupsUrl"]
1198
1346
  request_header = self.request_header()
1199
1347
 
1200
- logger.info("Adding M365 group -> %s; calling -> %s", name, request_url)
1348
+ logger.debug("Adding M365 group -> %s; calling -> %s", name, request_url)
1201
1349
  logger.debug("M365 group attributes -> %s", group_post_body)
1202
1350
 
1203
1351
  retries = 0
@@ -1206,13 +1354,13 @@ class M365(object):
1206
1354
  request_url,
1207
1355
  data=json.dumps(group_post_body),
1208
1356
  headers=request_header,
1209
- timeout=60,
1357
+ timeout=REQUEST_TIMEOUT,
1210
1358
  )
1211
1359
  if response.ok:
1212
1360
  return self.parse_request_response(response)
1213
1361
  # Check if Session has expired - then re-authenticate and try once more
1214
1362
  elif response.status_code == 401 and retries == 0:
1215
- logger.warning("Session has expired - try to re-authenticate...")
1363
+ logger.debug("Session has expired - try to re-authenticate...")
1216
1364
  self.authenticate(revalidate=True)
1217
1365
  request_header = self.request_header()
1218
1366
  retries += 1
@@ -1261,7 +1409,7 @@ class M365(object):
1261
1409
  )
1262
1410
  request_header = self.request_header()
1263
1411
 
1264
- logger.info(
1412
+ logger.debug(
1265
1413
  "Get members of M365 group -> %s (%s); calling -> %s",
1266
1414
  group_name,
1267
1415
  group_id,
@@ -1270,12 +1418,14 @@ class M365(object):
1270
1418
 
1271
1419
  retries = 0
1272
1420
  while True:
1273
- response = requests.get(request_url, headers=request_header, timeout=60)
1421
+ response = requests.get(
1422
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1423
+ )
1274
1424
  if response.ok:
1275
1425
  return self.parse_request_response(response)
1276
1426
  # Check if Session has expired - then re-authenticate and try once more
1277
1427
  elif response.status_code == 401 and retries == 0:
1278
- logger.warning("Session has expired - try to re-authenticate...")
1428
+ logger.debug("Session has expired - try to re-authenticate...")
1279
1429
  self.authenticate(revalidate=True)
1280
1430
  request_header = self.request_header()
1281
1431
  retries += 1
@@ -1316,7 +1466,7 @@ class M365(object):
1316
1466
  "@odata.id": self.config()["directoryObjects"] + "/" + member_id
1317
1467
  }
1318
1468
 
1319
- logger.info(
1469
+ logger.debug(
1320
1470
  "Adding member -> %s to group -> %s; calling -> %s",
1321
1471
  member_id,
1322
1472
  group_id,
@@ -1329,14 +1479,14 @@ class M365(object):
1329
1479
  request_url,
1330
1480
  headers=request_header,
1331
1481
  data=json.dumps(group_member_post_body),
1332
- timeout=60,
1482
+ timeout=REQUEST_TIMEOUT,
1333
1483
  )
1334
1484
  if response.ok:
1335
1485
  return self.parse_request_response(response)
1336
1486
 
1337
1487
  # Check if Session has expired - then re-authenticate and try once more
1338
1488
  if response.status_code == 401 and retries == 0:
1339
- logger.warning("Session has expired - try to re-authenticate...")
1489
+ logger.debug("Session has expired - try to re-authenticate...")
1340
1490
  self.authenticate(revalidate=True)
1341
1491
  request_header = self.request_header()
1342
1492
  retries += 1
@@ -1379,7 +1529,7 @@ class M365(object):
1379
1529
  )
1380
1530
  request_header = self.request_header()
1381
1531
 
1382
- logger.info(
1532
+ logger.debug(
1383
1533
  "Check if user -> %s is in group -> %s; calling -> %s",
1384
1534
  member_id,
1385
1535
  group_id,
@@ -1388,7 +1538,9 @@ class M365(object):
1388
1538
 
1389
1539
  retries = 0
1390
1540
  while True:
1391
- response = requests.get(request_url, headers=request_header, timeout=60)
1541
+ response = requests.get(
1542
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1543
+ )
1392
1544
  if response.ok:
1393
1545
  response = self.parse_request_response(response)
1394
1546
  if not "value" in response or len(response["value"]) == 0:
@@ -1396,7 +1548,7 @@ class M365(object):
1396
1548
  return True
1397
1549
  # Check if Session has expired - then re-authenticate and try once more
1398
1550
  elif response.status_code == 401 and retries == 0:
1399
- logger.warning("Session has expired - try to re-authenticate...")
1551
+ logger.debug("Session has expired - try to re-authenticate...")
1400
1552
  self.authenticate(revalidate=True)
1401
1553
  request_header = self.request_header()
1402
1554
  retries += 1
@@ -1449,7 +1601,7 @@ class M365(object):
1449
1601
  )
1450
1602
  request_header = self.request_header()
1451
1603
 
1452
- logger.info(
1604
+ logger.debug(
1453
1605
  "Get owners of M365 group -> %s (%s); calling -> %s",
1454
1606
  group_name,
1455
1607
  group_id,
@@ -1458,12 +1610,14 @@ class M365(object):
1458
1610
 
1459
1611
  retries = 0
1460
1612
  while True:
1461
- response = requests.get(request_url, headers=request_header, timeout=60)
1613
+ response = requests.get(
1614
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1615
+ )
1462
1616
  if response.ok:
1463
1617
  return self.parse_request_response(response)
1464
1618
  # Check if Session has expired - then re-authenticate and try once more
1465
1619
  elif response.status_code == 401 and retries == 0:
1466
- logger.warning("Session has expired - try to re-authenticate...")
1620
+ logger.debug("Session has expired - try to re-authenticate...")
1467
1621
  self.authenticate(revalidate=True)
1468
1622
  request_header = self.request_header()
1469
1623
  retries += 1
@@ -1504,7 +1658,7 @@ class M365(object):
1504
1658
  "@odata.id": self.config()["directoryObjects"] + "/" + owner_id
1505
1659
  }
1506
1660
 
1507
- logger.info(
1661
+ logger.debug(
1508
1662
  "Adding owner -> %s to M365 group -> %s; calling -> %s",
1509
1663
  owner_id,
1510
1664
  group_id,
@@ -1517,13 +1671,13 @@ class M365(object):
1517
1671
  request_url,
1518
1672
  headers=request_header,
1519
1673
  data=json.dumps(group_member_post_body),
1520
- timeout=60,
1674
+ timeout=REQUEST_TIMEOUT,
1521
1675
  )
1522
1676
  if response.ok:
1523
1677
  return self.parse_request_response(response)
1524
1678
  # Check if Session has expired - then re-authenticate and try once more
1525
1679
  elif response.status_code == 401 and retries == 0:
1526
- logger.warning("Session has expired - try to re-authenticate...")
1680
+ logger.debug("Session has expired - try to re-authenticate...")
1527
1681
  self.authenticate(revalidate=True)
1528
1682
  request_header = self.request_header()
1529
1683
  retries += 1
@@ -1558,7 +1712,9 @@ class M365(object):
1558
1712
  request_url = (
1559
1713
  self.config()["directoryUrl"] + "/deletedItems/microsoft.graph.group"
1560
1714
  )
1561
- response = requests.get(request_url, headers=request_header, timeout=60)
1715
+ response = requests.get(
1716
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1717
+ )
1562
1718
  deleted_groups = self.parse_request_response(response)
1563
1719
 
1564
1720
  for group in deleted_groups["value"]:
@@ -1568,7 +1724,9 @@ class M365(object):
1568
1724
  request_url = (
1569
1725
  self.config()["directoryUrl"] + "/deletedItems/microsoft.graph.user"
1570
1726
  )
1571
- response = requests.get(request_url, headers=request_header, timeout=60)
1727
+ response = requests.get(
1728
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1729
+ )
1572
1730
  deleted_users = self.parse_request_response(response)
1573
1731
 
1574
1732
  for user in deleted_users["value"]:
@@ -1591,16 +1749,18 @@ class M365(object):
1591
1749
  request_url = self.config()["directoryUrl"] + "/deletedItems/" + item_id
1592
1750
  request_header = self.request_header()
1593
1751
 
1594
- logger.info("Purging deleted item -> %s; calling -> %s", item_id, request_url)
1752
+ logger.debug("Purging deleted item -> %s; calling -> %s", item_id, request_url)
1595
1753
 
1596
1754
  retries = 0
1597
1755
  while True:
1598
- response = requests.delete(request_url, headers=request_header, timeout=60)
1756
+ response = requests.delete(
1757
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1758
+ )
1599
1759
  if response.ok:
1600
1760
  return self.parse_request_response(response)
1601
1761
  # Check if Session has expired - then re-authenticate and try once more
1602
1762
  elif response.status_code == 401 and retries == 0:
1603
- logger.warning("Session has expired - try to re-authenticate...")
1763
+ logger.debug("Session has expired - try to re-authenticate...")
1604
1764
  self.authenticate(revalidate=True)
1605
1765
  request_header = self.request_header()
1606
1766
  retries += 1
@@ -1644,7 +1804,7 @@ class M365(object):
1644
1804
  request_url = self.config()["groupsUrl"] + "/" + group_id + "/team"
1645
1805
  request_header = self.request_header()
1646
1806
 
1647
- logger.info(
1807
+ logger.debug(
1648
1808
  "Check if M365 Group -> %s has a M365 Team connected; calling -> %s",
1649
1809
  group_name,
1650
1810
  request_url,
@@ -1652,16 +1812,18 @@ class M365(object):
1652
1812
 
1653
1813
  retries = 0
1654
1814
  while True:
1655
- response = requests.get(request_url, headers=request_header, timeout=60)
1815
+ response = requests.get(
1816
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1817
+ )
1656
1818
 
1657
1819
  if response.status_code == 200: # Group has a Team assigned!
1658
- logger.info("Group -> %s has a M365 Team connected.", group_name)
1820
+ logger.debug("Group -> %s has a M365 Team connected.", group_name)
1659
1821
  return True
1660
1822
  elif response.status_code == 404: # Group does not have a Team assigned!
1661
- logger.info("Group -> %s has no M365 Team connected.", group_name)
1823
+ logger.debug("Group -> %s has no M365 Team connected.", group_name)
1662
1824
  return False
1663
1825
  elif response.status_code == 401 and retries == 0:
1664
- logger.warning("Session has expired - try to re-authenticate...")
1826
+ logger.debug("Session has expired - try to re-authenticate...")
1665
1827
  self.authenticate(revalidate=True)
1666
1828
  request_header = self.request_header()
1667
1829
  retries += 1
@@ -1734,20 +1896,22 @@ class M365(object):
1734
1896
 
1735
1897
  request_header = self.request_header()
1736
1898
 
1737
- logger.info(
1738
- "Lookup Microsoft 365 Teams with name -> %s; calling -> %s",
1899
+ logger.debug(
1900
+ "Lookup Microsoft 365 Teams with name -> '%s'; calling -> %s",
1739
1901
  name,
1740
1902
  request_url,
1741
1903
  )
1742
1904
 
1743
1905
  retries = 0
1744
1906
  while True:
1745
- response = requests.get(request_url, headers=request_header, timeout=60)
1907
+ response = requests.get(
1908
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1909
+ )
1746
1910
  if response.ok:
1747
1911
  return self.parse_request_response(response)
1748
1912
  # Check if Session has expired - then re-authenticate and try once more
1749
1913
  elif response.status_code == 401 and retries == 0:
1750
- logger.warning("Session has expired - try to re-authenticate...")
1914
+ logger.debug("Session has expired - try to re-authenticate...")
1751
1915
  self.authenticate(revalidate=True)
1752
1916
  request_header = self.request_header()
1753
1917
  retries += 1
@@ -1785,7 +1949,7 @@ class M365(object):
1785
1949
  group_id = self.get_result_value(response, "id", 0)
1786
1950
  if not group_id:
1787
1951
  logger.error(
1788
- "M365 Group -> %s not found. It is required for creating a corresponding M365 Team.",
1952
+ "M365 Group -> '%s' not found. It is required for creating a corresponding M365 Team.",
1789
1953
  name,
1790
1954
  )
1791
1955
  return None
@@ -1793,7 +1957,7 @@ class M365(object):
1793
1957
  response = self.get_group_owners(name)
1794
1958
  if response is None or not "value" in response or not response["value"]:
1795
1959
  logger.warning(
1796
- "M365 Group -> %s has no owners. This is required for creating a corresponding M365 Team.",
1960
+ "M365 Group -> '%s' has no owners. This is required for creating a corresponding M365 Team.",
1797
1961
  name,
1798
1962
  )
1799
1963
  return None
@@ -1808,7 +1972,7 @@ class M365(object):
1808
1972
  request_url = self.config()["teamsUrl"]
1809
1973
  request_header = self.request_header()
1810
1974
 
1811
- logger.info("Adding M365 Team -> %s; calling -> %s", name, request_url)
1975
+ logger.debug("Adding M365 Team -> %s; calling -> %s", name, request_url)
1812
1976
  logger.debug("M365 Team attributes -> %s", team_post_body)
1813
1977
 
1814
1978
  retries = 0
@@ -1817,13 +1981,13 @@ class M365(object):
1817
1981
  request_url,
1818
1982
  data=json.dumps(team_post_body),
1819
1983
  headers=request_header,
1820
- timeout=60,
1984
+ timeout=REQUEST_TIMEOUT,
1821
1985
  )
1822
1986
  if response.ok:
1823
1987
  return self.parse_request_response(response)
1824
1988
  # Check if Session has expired - then re-authenticate and try once more
1825
1989
  elif response.status_code == 401 and retries == 0:
1826
- logger.warning("Session has expired - try to re-authenticate...")
1990
+ logger.debug("Session has expired - try to re-authenticate...")
1827
1991
  self.authenticate(revalidate=True)
1828
1992
  request_header = self.request_header()
1829
1993
  retries += 1
@@ -1837,7 +2001,7 @@ class M365(object):
1837
2001
  retries += 1
1838
2002
  else:
1839
2003
  logger.error(
1840
- "Failed to add M365 Team -> %s; status -> %s; error -> %s",
2004
+ "Failed to add M365 Team -> '%s'; status -> %s; error -> %s",
1841
2005
  name,
1842
2006
  response.status_code,
1843
2007
  response.text,
@@ -1859,7 +2023,7 @@ class M365(object):
1859
2023
 
1860
2024
  request_header = self.request_header()
1861
2025
 
1862
- logger.info(
2026
+ logger.debug(
1863
2027
  "Delete Microsoft 365 Teams with ID -> %s; calling -> %s",
1864
2028
  team_id,
1865
2029
  request_url,
@@ -1867,12 +2031,14 @@ class M365(object):
1867
2031
 
1868
2032
  retries = 0
1869
2033
  while True:
1870
- response = requests.delete(request_url, headers=request_header, timeout=60)
2034
+ response = requests.delete(
2035
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2036
+ )
1871
2037
  if response.ok:
1872
2038
  return self.parse_request_response(response)
1873
2039
  # Check if Session has expired - then re-authenticate and try once more
1874
2040
  elif response.status_code == 401 and retries == 0:
1875
- logger.warning("Session has expired - try to re-authenticate...")
2041
+ logger.debug("Session has expired - try to re-authenticate...")
1876
2042
  self.authenticate(revalidate=True)
1877
2043
  request_header = self.request_header()
1878
2044
  retries += 1
@@ -1911,21 +2077,23 @@ class M365(object):
1911
2077
 
1912
2078
  request_header = self.request_header()
1913
2079
 
1914
- logger.info(
1915
- "Delete all Microsoft 365 Teams with name -> %s; calling -> %s",
2080
+ logger.debug(
2081
+ "Delete all Microsoft 365 Teams with name -> '%s'; calling -> %s",
1916
2082
  name,
1917
2083
  request_url,
1918
2084
  )
1919
2085
 
1920
2086
  retries = 0
1921
2087
  while True:
1922
- response = requests.get(request_url, headers=request_header, timeout=60)
2088
+ response = requests.get(
2089
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2090
+ )
1923
2091
  if response.ok:
1924
2092
  existing_teams = self.parse_request_response(response)
1925
2093
  break
1926
2094
  # Check if Session has expired - then re-authenticate and try once more
1927
2095
  elif response.status_code == 401 and retries == 0:
1928
- logger.warning("Session has expired - try to re-authenticate...")
2096
+ logger.debug("Session has expired - try to re-authenticate...")
1929
2097
  self.authenticate(revalidate=True)
1930
2098
  request_header = self.request_header()
1931
2099
  retries += 1
@@ -1962,16 +2130,16 @@ class M365(object):
1962
2130
  counter += 1
1963
2131
 
1964
2132
  logger.info(
1965
- "%s M365 Teams with name -> %s have been deleted.",
2133
+ "%s M365 Teams with name -> '%s' have been deleted.",
1966
2134
  str(counter),
1967
2135
  name,
1968
2136
  )
1969
2137
  return True
1970
2138
  else:
1971
- logger.info("No M365 Teams with name -> %s found.", name)
2139
+ logger.info("No M365 Teams with name -> '%s' found.", name)
1972
2140
  return False
1973
2141
  else:
1974
- logger.error("Failed to retrieve M365 Teams with name -> %s", name)
2142
+ logger.error("Failed to retrieve M365 Teams with name -> '%s'", name)
1975
2143
  return False
1976
2144
 
1977
2145
  # end method definition
@@ -1995,19 +2163,20 @@ class M365(object):
1995
2163
  if not "value" in response or not response["value"]:
1996
2164
  return False
1997
2165
  groups = response["value"]
2166
+
1998
2167
  logger.info(
1999
2168
  "Found -> %s existing M365 groups. Checking which ones should be deleted...",
2000
2169
  len(groups),
2001
2170
  )
2002
2171
 
2003
- # Process all groups and check if the< should be
2172
+ # Process all groups and check if they should be
2004
2173
  # deleted:
2005
2174
  for group in groups:
2006
2175
  group_name = group["displayName"]
2007
2176
  # Check if group is in exception list:
2008
2177
  if group_name in exception_list:
2009
2178
  logger.info(
2010
- "M365 Group name -> %s is on the exception list. Skipping...",
2179
+ "M365 Group name -> '%s' is on the exception list. Skipping...",
2011
2180
  group_name,
2012
2181
  )
2013
2182
  continue
@@ -2016,7 +2185,7 @@ class M365(object):
2016
2185
  result = re.search(pattern, group_name)
2017
2186
  if result:
2018
2187
  logger.info(
2019
- "M365 Group name -> %s is matching pattern -> %s. Delete it now...",
2188
+ "M365 Group name -> '%s' is matching pattern -> %s. Delete it now...",
2020
2189
  group_name,
2021
2190
  pattern,
2022
2191
  )
@@ -2024,7 +2193,7 @@ class M365(object):
2024
2193
  break
2025
2194
  else:
2026
2195
  logger.info(
2027
- "M365 Group name -> %s is not matching any delete pattern. Skipping...",
2196
+ "M365 Group name -> '%s' is not matching any delete pattern. Skipping...",
2028
2197
  group_name,
2029
2198
  )
2030
2199
  return True
@@ -2068,20 +2237,22 @@ class M365(object):
2068
2237
 
2069
2238
  request_header = self.request_header()
2070
2239
 
2071
- logger.info(
2072
- "Retrieve channels of Microsoft 365 Team -> %s; calling -> %s",
2240
+ logger.debug(
2241
+ "Retrieve channels of Microsoft 365 Team -> '%s'; calling -> %s",
2073
2242
  name,
2074
2243
  request_url,
2075
2244
  )
2076
2245
 
2077
2246
  retries = 0
2078
2247
  while True:
2079
- response = requests.get(request_url, headers=request_header, timeout=60)
2248
+ response = requests.get(
2249
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2250
+ )
2080
2251
  if response.ok:
2081
2252
  return self.parse_request_response(response)
2082
2253
  # Check if Session has expired - then re-authenticate and try once more
2083
2254
  elif response.status_code == 401 and retries == 0:
2084
- logger.warning("Session has expired - try to re-authenticate...")
2255
+ logger.debug("Session has expired - try to re-authenticate...")
2085
2256
  self.authenticate(revalidate=True)
2086
2257
  request_header = self.request_header()
2087
2258
  retries += 1
@@ -2151,7 +2322,7 @@ class M365(object):
2151
2322
  None,
2152
2323
  )
2153
2324
  if not channel:
2154
- logger.erro(
2325
+ logger.error(
2155
2326
  "Cannot find Channel -> %s on M365 Team -> %s", channel_name, team_name
2156
2327
  )
2157
2328
  return None
@@ -2168,7 +2339,7 @@ class M365(object):
2168
2339
 
2169
2340
  request_header = self.request_header()
2170
2341
 
2171
- logger.info(
2342
+ logger.debug(
2172
2343
  "Retrieve Tabs of Microsoft 365 Teams -> %s and Channel -> %s; calling -> %s",
2173
2344
  team_name,
2174
2345
  channel_name,
@@ -2177,12 +2348,14 @@ class M365(object):
2177
2348
 
2178
2349
  retries = 0
2179
2350
  while True:
2180
- response = requests.get(request_url, headers=request_header, timeout=60)
2351
+ response = requests.get(
2352
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2353
+ )
2181
2354
  if response.ok:
2182
2355
  return self.parse_request_response(response)
2183
2356
  # Check if Session has expired - then re-authenticate and try once more
2184
2357
  elif response.status_code == 401 and retries == 0:
2185
- logger.warning("Session has expired - try to re-authenticate...")
2358
+ logger.debug("Session has expired - try to re-authenticate...")
2186
2359
  self.authenticate(revalidate=True)
2187
2360
  request_header = self.request_header()
2188
2361
  retries += 1
@@ -2257,24 +2430,26 @@ class M365(object):
2257
2430
  request_url = self.config()["teamsAppsUrl"] + "?" + encoded_query
2258
2431
 
2259
2432
  if filter_expression:
2260
- logger.info(
2433
+ logger.debug(
2261
2434
  "Get list of MS Teams Apps using filter -> %s; calling -> %s",
2262
2435
  filter_expression,
2263
2436
  request_url,
2264
2437
  )
2265
2438
  else:
2266
- logger.info("Get list of all MS Teams Apps; calling -> %s", request_url)
2439
+ logger.debug("Get list of all MS Teams Apps; calling -> %s", request_url)
2267
2440
 
2268
2441
  request_header = self.request_header()
2269
2442
 
2270
2443
  retries = 0
2271
2444
  while True:
2272
- response = requests.get(request_url, headers=request_header, timeout=60)
2445
+ response = requests.get(
2446
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2447
+ )
2273
2448
  if response.ok:
2274
2449
  return self.parse_request_response(response)
2275
2450
  # Check if Session has expired - then re-authenticate and try once more
2276
2451
  elif response.status_code == 401 and retries == 0:
2277
- logger.warning("Session has expired - try to re-authenticate...")
2452
+ logger.debug("Session has expired - try to re-authenticate...")
2278
2453
  self.authenticate(revalidate=True)
2279
2454
  request_header = self.request_header()
2280
2455
  retries += 1
@@ -2297,34 +2472,58 @@ class M365(object):
2297
2472
  # end method definition
2298
2473
 
2299
2474
  def get_teams_app(self, app_id: str) -> dict | None:
2300
- """Get a specific MS Teams app in catalog based on the known app ID
2475
+ """Get a specific MS Teams app in catalog based on the known (internal) app ID
2301
2476
 
2302
2477
  Args:
2303
- app_id (str): ID of the app
2478
+ app_id (str): ID of the app (this is NOT the external ID but the internal ID)
2304
2479
  Returns:
2305
2480
  dict: response of the MS Graph API call or None if the call fails.
2481
+
2482
+ Examle response:
2483
+ {
2484
+ '@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps(appDefinitions())/$entity',
2485
+ 'id': 'ccabe3fb-316f-40e0-a486-1659682cb8cd',
2486
+ 'externalId': 'dd4af790-d8ff-47a0-87ad-486318272c7a',
2487
+ 'displayName': 'Extended ECM',
2488
+ 'distributionMethod': 'organization',
2489
+ 'appDefinitions@odata.context': "https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps('ccabe3fb-316f-40e0-a486-1659682cb8cd')/appDefinitions",
2490
+ 'appDefinitions': [
2491
+ {
2492
+ 'id': 'Y2NhYmUzZmItMzE2Zi00MGUwLWE0ODYtMTY1OTY4MmNiOGNkIyMyNC4yLjAjI1B1Ymxpc2hlZA==',
2493
+ 'teamsAppId': 'ccabe3fb-316f-40e0-a486-1659682cb8cd',
2494
+ 'displayName': 'Extended ECM',
2495
+ 'version': '24.2.0',
2496
+ 'publishingState': 'published',
2497
+ 'shortDescription': 'Add a tab for an Extended ECM business workspace.',
2498
+ 'description': 'View and interact with OpenText Extended ECM business workspaces',
2499
+ 'lastModifiedDateTime': None,
2500
+ 'createdBy': None,
2501
+ 'authorization': {...}
2502
+ }
2503
+ ]
2504
+ }
2306
2505
  """
2307
2506
 
2308
2507
  query = {"$expand": "AppDefinitions"}
2309
2508
  encoded_query = urllib.parse.urlencode(query, doseq=True)
2310
2509
  request_url = self.config()["teamsAppsUrl"] + "/" + app_id + "?" + encoded_query
2311
2510
 
2312
- # request_url = self.config()["teamsAppsUrl"] + "/" + app_id
2313
-
2314
- logger.info(
2315
- "Get MS Teams App with ID -> %s; calling -> %s", app_id, request_url
2511
+ logger.debug(
2512
+ "Get M365 Teams App with ID -> %s; calling -> %s", app_id, request_url
2316
2513
  )
2317
2514
 
2318
2515
  request_header = self.request_header()
2319
2516
 
2320
2517
  retries = 0
2321
2518
  while True:
2322
- response = requests.get(request_url, headers=request_header, timeout=60)
2519
+ response = requests.get(
2520
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2521
+ )
2323
2522
  if response.ok:
2324
2523
  return self.parse_request_response(response)
2325
2524
  # Check if Session has expired - then re-authenticate and try once more
2326
2525
  elif response.status_code == 401 and retries == 0:
2327
- logger.warning("Session has expired - try to re-authenticate...")
2526
+ logger.debug("Session has expired - try to re-authenticate...")
2328
2527
  self.authenticate(revalidate=True)
2329
2528
  request_header = self.request_header()
2330
2529
  retries += 1
@@ -2370,7 +2569,8 @@ class M365(object):
2370
2569
  + "/teamwork/installedApps?"
2371
2570
  + encoded_query
2372
2571
  )
2373
- logger.info(
2572
+
2573
+ logger.debug(
2374
2574
  "Get list of M365 Teams Apps for user -> %s using query -> %s; calling -> %s",
2375
2575
  user_id,
2376
2576
  query,
@@ -2381,12 +2581,14 @@ class M365(object):
2381
2581
 
2382
2582
  retries = 0
2383
2583
  while True:
2384
- response = requests.get(request_url, headers=request_header, timeout=60)
2584
+ response = requests.get(
2585
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2586
+ )
2385
2587
  if response.ok:
2386
2588
  return self.parse_request_response(response)
2387
2589
  # Check if Session has expired - then re-authenticate and try once more
2388
2590
  elif response.status_code == 401 and retries == 0:
2389
- logger.warning("Session has expired - try to re-authenticate...")
2591
+ logger.debug("Session has expired - try to re-authenticate...")
2390
2592
  self.authenticate(revalidate=True)
2391
2593
  request_header = self.request_header()
2392
2594
  retries += 1
@@ -2433,7 +2635,8 @@ class M365(object):
2433
2635
  + "/installedApps?"
2434
2636
  + encoded_query
2435
2637
  )
2436
- logger.info(
2638
+
2639
+ logger.debug(
2437
2640
  "Get list of M365 Teams Apps for M365 Team -> %s using query -> %s; calling -> %s",
2438
2641
  team_id,
2439
2642
  query,
@@ -2444,12 +2647,14 @@ class M365(object):
2444
2647
 
2445
2648
  retries = 0
2446
2649
  while True:
2447
- response = requests.get(request_url, headers=request_header, timeout=60)
2650
+ response = requests.get(
2651
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2652
+ )
2448
2653
  if response.ok:
2449
2654
  return self.parse_request_response(response)
2450
2655
  # Check if Session has expired - then re-authenticate and try once more
2451
2656
  elif response.status_code == 401 and retries == 0:
2452
- logger.warning("Session has expired - try to re-authenticate...")
2657
+ logger.debug("Session has expired - try to re-authenticate...")
2453
2658
  self.authenticate(revalidate=True)
2454
2659
  request_header = self.request_header()
2455
2660
  retries += 1
@@ -2501,6 +2706,7 @@ class M365(object):
2501
2706
  but requires a token of a user authenticated with username + password.
2502
2707
  See https://learn.microsoft.com/en-us/graph/api/teamsapp-publish
2503
2708
  (permissions table on that page)
2709
+ For updates see: https://learn.microsoft.com/en-us/graph/api/teamsapp-update?view=graph-rest-1.0&tabs=http
2504
2710
 
2505
2711
  Args:
2506
2712
  app_path (str): file path (with directory) to the app package to upload
@@ -2511,6 +2717,34 @@ class M365(object):
2511
2717
  after installation (which is tenant specific)
2512
2718
  Returns:
2513
2719
  dict: Response of the MS GRAPH API REST call or None if the request fails
2720
+
2721
+ The responses are different depending if it is an install or upgrade!!
2722
+
2723
+ Example return for upgrades ("teamsAppId" is the "internal" ID of the app):
2724
+ {
2725
+ '@odata.context': "https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps('3f749cca-8cb0-4925-9fa0-ba7aca2014af')/appDefinitions/$entity",
2726
+ 'id': 'M2Y3NDljY2EtOGNiMC00OTI1LTlmYTAtYmE3YWNhMjAxNGFmIyMyNC4yLjAjI1B1Ymxpc2hlZA==',
2727
+ 'teamsAppId': '3f749cca-8cb0-4925-9fa0-ba7aca2014af',
2728
+ 'displayName': 'IDEA-TE - Extended ECM 24.2.0',
2729
+ 'version': '24.2.0',
2730
+ 'publishingState': 'published',
2731
+ 'shortDescription': 'Add a tab for an Extended ECM business workspace.',
2732
+ 'description': 'View and interact with OpenText Extended ECM business workspaces',
2733
+ 'lastModifiedDateTime': None,
2734
+ 'createdBy': None,
2735
+ 'authorization': {
2736
+ 'requiredPermissionSet': {...}
2737
+ }
2738
+ }
2739
+
2740
+ Example return for new installations ("id" is the "internal" ID of the app):
2741
+ {
2742
+ '@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps/$entity',
2743
+ 'id': '6c672afd-37fc-46c6-8365-d499aba3808b',
2744
+ 'externalId': 'dd4af790-d8ff-47a0-87ad-486318272c7a',
2745
+ 'displayName': 'OpenText Extended ECM',
2746
+ 'distributionMethod': 'organization'
2747
+ }
2514
2748
  """
2515
2749
 
2516
2750
  if update_existing_app and not app_catalog_id:
@@ -2520,12 +2754,12 @@ class M365(object):
2520
2754
  return None
2521
2755
 
2522
2756
  if not os.path.exists(app_path):
2523
- logger.error("M365 Teams app file -> {} does not exist!")
2757
+ logger.error("M365 Teams app file -> %s does not exist!", app_path)
2524
2758
  return None
2525
2759
 
2526
2760
  # Ensure that the app file is a zip file
2527
2761
  if not app_path.endswith(".zip"):
2528
- logger.error("M365 Teams app file -> {} must be a zip file!")
2762
+ logger.error("M365 Teams app file -> %s must be a zip file!", app_path)
2529
2763
  return None
2530
2764
 
2531
2765
  request_url = self.config()["teamsAppsUrl"]
@@ -2545,12 +2779,13 @@ class M365(object):
2545
2779
  # Ensure that the app file contains a manifest.json file
2546
2780
  if "manifest.json" not in z.namelist():
2547
2781
  logger.error(
2548
- "M365 Teams app file -> {} does not contain a manifest.json file!"
2782
+ "M365 Teams app file -> '%s' does not contain a manifest.json file!",
2783
+ app_path,
2549
2784
  )
2550
2785
  return None
2551
2786
 
2552
- logger.info(
2553
- "Upload M365 Teams app -> %s to the MS Teams catalog; calling -> %s",
2787
+ logger.debug(
2788
+ "Upload M365 Teams app -> '%s' to the MS Teams catalog; calling -> %s",
2554
2789
  app_path,
2555
2790
  request_url,
2556
2791
  )
@@ -2558,14 +2793,17 @@ class M365(object):
2558
2793
  retries = 0
2559
2794
  while True:
2560
2795
  response = requests.post(
2561
- request_url, headers=request_header, data=app_data, timeout=60
2796
+ request_url,
2797
+ headers=request_header,
2798
+ data=app_data,
2799
+ timeout=REQUEST_TIMEOUT,
2562
2800
  )
2563
2801
  if response.ok:
2564
2802
  return self.parse_request_response(response)
2565
2803
 
2566
2804
  # Check if Session has expired - then re-authenticate and try once more
2567
2805
  if response.status_code == 401 and retries == 0:
2568
- logger.warning("Session has expired - try to re-authenticate...")
2806
+ logger.debug("Session has expired - try to re-authenticate...")
2569
2807
  self.authenticate(revalidate=True)
2570
2808
  request_header = self.request_header()
2571
2809
  retries += 1
@@ -2580,7 +2818,7 @@ class M365(object):
2580
2818
  else:
2581
2819
  if update_existing_app:
2582
2820
  logger.warning(
2583
- "Failed to update existing M365 Teams app -> %s (may be because it is not a new version); status -> %s; error -> %s",
2821
+ "Failed to update existing M365 Teams app -> '%s' (may be because it is not a new version); status -> %s; error -> %s",
2584
2822
  app_path,
2585
2823
  response.status_code,
2586
2824
  response.text,
@@ -2588,7 +2826,7 @@ class M365(object):
2588
2826
 
2589
2827
  else:
2590
2828
  logger.error(
2591
- "Failed to upload new M365 Teams app -> %s; status -> %s; error -> %s",
2829
+ "Failed to upload new M365 Teams app -> '%s'; status -> %s; error -> %s",
2592
2830
  app_path,
2593
2831
  response.status_code,
2594
2832
  response.text,
@@ -2610,11 +2848,13 @@ class M365(object):
2610
2848
  request_header = self.request_header_user()
2611
2849
 
2612
2850
  # Make the DELETE request to remove the app from the app catalog
2613
- response = requests.delete(request_url, headers=request_header, timeout=60)
2851
+ response = requests.delete(
2852
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2853
+ )
2614
2854
 
2615
2855
  # Check the status code of the response
2616
2856
  if response.status_code == 204:
2617
- logger.info(
2857
+ logger.debug(
2618
2858
  "The M365 Teams app with ID -> %s has been successfully removed from the app catalog.",
2619
2859
  app_id,
2620
2860
  )
@@ -2627,35 +2867,160 @@ class M365(object):
2627
2867
 
2628
2868
  # end method definition
2629
2869
 
2630
- def assign_teams_app_to_user(self, user_id: str, app_name: str) -> dict | None:
2870
+ def assign_teams_app_to_user(
2871
+ self,
2872
+ user_id: str,
2873
+ app_name: str = "",
2874
+ app_internal_id: str = "",
2875
+ show_error: bool = False,
2876
+ ) -> dict | None:
2631
2877
  """Assigns (adds) a M365 Teams app to a M365 user.
2632
2878
 
2879
+ See: https://learn.microsoft.com/en-us/graph/api/userteamwork-post-installedapps?view=graph-rest-1.0&tabs=http
2880
+
2633
2881
  Args:
2634
2882
  user_id (str): M365 GUID of the user (can also be the M365 email of the user)
2635
- app_name (str): exact name of the app
2883
+ app_name (str, optional): exact name of the app. Not needed if app_internal_id is provided
2884
+ app_internal_id (str, optional): internal ID of the app. If not provided it will be derived from app_name
2885
+ show_error (bool): whether or not an error should be displayed if the
2886
+ user is not found.
2636
2887
  Returns:
2637
2888
  dict: response of the MS Graph API call or None if the call fails.
2638
2889
  """
2639
2890
 
2640
- response = self.get_teams_apps(f"contains(displayName, '{app_name}')")
2641
- app_id = self.get_result_value(response, "id", 0)
2642
- if not app_id:
2643
- logger.error("M365 Teams App -> %s not found!", app_name)
2891
+ if not app_internal_id and not app_name:
2892
+ logger.error(
2893
+ "Either the internal App ID or the App name need to be provided!"
2894
+ )
2644
2895
  return None
2645
2896
 
2897
+ if not app_internal_id:
2898
+ response = self.get_teams_apps(
2899
+ filter_expression="contains(displayName, '{}')".format(app_name)
2900
+ )
2901
+ app_internal_id = self.get_result_value(
2902
+ response=response, key="id", index=0
2903
+ )
2904
+ if not app_internal_id:
2905
+ logger.error(
2906
+ "M365 Teams App -> '%s' not found! Cannot assign App to user -> %s.",
2907
+ app_name,
2908
+ user_id,
2909
+ )
2910
+ return None
2911
+
2646
2912
  request_url = (
2647
2913
  self.config()["usersUrl"] + "/" + user_id + "/teamwork/installedApps"
2648
2914
  )
2649
2915
  request_header = self.request_header()
2650
2916
 
2651
2917
  post_body = {
2652
- "teamsApp@odata.bind": self.config()["teamsAppsUrl"] + "/" + app_id
2918
+ "teamsApp@odata.bind": self.config()["teamsAppsUrl"] + "/" + app_internal_id
2653
2919
  }
2654
2920
 
2655
- logger.info(
2656
- "Assign M365 Teams app -> %s (%s) to M365 user -> %s; calling -> %s",
2921
+ logger.debug(
2922
+ "Assign M365 Teams app -> '%s' (%s) to M365 user -> %s; calling -> %s",
2657
2923
  app_name,
2658
- app_id,
2924
+ app_internal_id,
2925
+ user_id,
2926
+ request_url,
2927
+ )
2928
+
2929
+ retries = 0
2930
+ while True:
2931
+ response = requests.post(
2932
+ request_url,
2933
+ json=post_body,
2934
+ headers=request_header,
2935
+ timeout=REQUEST_TIMEOUT,
2936
+ )
2937
+ if response.ok:
2938
+ return self.parse_request_response(response)
2939
+ # Check if Session has expired - then re-authenticate and try once more
2940
+ elif response.status_code == 401 and retries == 0:
2941
+ logger.debug("Session has expired - try to re-authenticate...")
2942
+ self.authenticate(revalidate=True)
2943
+ request_header = self.request_header()
2944
+ retries += 1
2945
+ elif response.status_code in [502, 503, 504] and retries < 3:
2946
+ logger.warning(
2947
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2948
+ response.status_code,
2949
+ (retries + 1) * 60,
2950
+ )
2951
+ time.sleep((retries + 1) * 60)
2952
+ retries += 1
2953
+ else:
2954
+ if show_error:
2955
+ logger.error(
2956
+ "Failed to assign M365 Teams app -> '%s' (%s) to M365 user -> %s; status -> %s; error -> %s",
2957
+ app_name,
2958
+ app_internal_id,
2959
+ user_id,
2960
+ response.status_code,
2961
+ response.text,
2962
+ )
2963
+ else:
2964
+ logger.warning(
2965
+ "Failed to assign M365 Teams app -> '%s' (%s) to M365 user -> %s (could be because the app is assigned organization-wide); status -> %s; warning -> %s",
2966
+ app_name,
2967
+ app_internal_id,
2968
+ user_id,
2969
+ response.status_code,
2970
+ response.text,
2971
+ )
2972
+ return None
2973
+
2974
+ # end method definition
2975
+
2976
+ def upgrade_teams_app_of_user(
2977
+ self, user_id: str, app_name: str, app_installation_id: str | None = None
2978
+ ) -> dict | None:
2979
+ """Upgrade a MS teams app for a user. The call will fail if the user does not
2980
+ already have the app assigned. So this needs to be checked before
2981
+ calling this method.
2982
+ See: https://learn.microsoft.com/en-us/graph/api/userteamwork-teamsappinstallation-upgrade?view=graph-rest-1.0&tabs=http
2983
+
2984
+ Args:
2985
+ user_id (str): M365 GUID of the user (can also be the M365 email of the user)
2986
+ app_name (str): exact name of the app
2987
+ app_installation_id (str): ID of the app installation for the user. This is neither the internal nor
2988
+ external app ID. It is specific for each user and app.
2989
+ Returns:
2990
+ dict: response of the MS Graph API call or None if the call fails.
2991
+ """
2992
+
2993
+ if not app_installation_id:
2994
+ response = self.get_teams_apps_of_user(
2995
+ user_id=user_id,
2996
+ filter_expression="contains(teamsAppDefinition/displayName, '{}')".format(
2997
+ app_name
2998
+ ),
2999
+ )
3000
+ # Retrieve the installation specific App ID - this is different from thew App catalalog ID!!
3001
+ app_installation_id = self.get_result_value(response, "id", 0)
3002
+ if not app_installation_id:
3003
+ logger.error(
3004
+ "M365 Teams app -> '%s' not found for user with ID -> %s. Cannot upgrade app for this user!",
3005
+ app_name,
3006
+ user_id,
3007
+ )
3008
+ return None
3009
+
3010
+ request_url = (
3011
+ self.config()["usersUrl"]
3012
+ + "/"
3013
+ + user_id
3014
+ + "/teamwork/installedApps/"
3015
+ + app_installation_id
3016
+ + "/upgrade"
3017
+ )
3018
+ request_header = self.request_header()
3019
+
3020
+ logger.debug(
3021
+ "Upgrade M365 Teams app -> '%s' (%s) of M365 user with ID -> %s; calling -> %s",
3022
+ app_name,
3023
+ app_installation_id,
2659
3024
  user_id,
2660
3025
  request_url,
2661
3026
  )
@@ -2663,13 +3028,13 @@ class M365(object):
2663
3028
  retries = 0
2664
3029
  while True:
2665
3030
  response = requests.post(
2666
- request_url, json=post_body, headers=request_header, timeout=60
3031
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2667
3032
  )
2668
3033
  if response.ok:
2669
3034
  return self.parse_request_response(response)
2670
3035
  # Check if Session has expired - then re-authenticate and try once more
2671
3036
  elif response.status_code == 401 and retries == 0:
2672
- logger.warning("Session has expired - try to re-authenticate...")
3037
+ logger.debug("Session has expired - try to re-authenticate...")
2673
3038
  self.authenticate(revalidate=True)
2674
3039
  request_header = self.request_header()
2675
3040
  retries += 1
@@ -2683,9 +3048,9 @@ class M365(object):
2683
3048
  retries += 1
2684
3049
  else:
2685
3050
  logger.error(
2686
- "Failed to assign M365 Teams app -> %s (%s) to M365 user -> %s; status -> %s; error -> %s",
3051
+ "Failed to upgrade M365 Teams app -> '%s' (%s) of M365 user -> %s; status -> %s; error -> %s",
2687
3052
  app_name,
2688
- app_id,
3053
+ app_installation_id,
2689
3054
  user_id,
2690
3055
  response.status_code,
2691
3056
  response.text,
@@ -2694,10 +3059,12 @@ class M365(object):
2694
3059
 
2695
3060
  # end method definition
2696
3061
 
2697
- def upgrade_teams_app_of_user(self, user_id: str, app_name: str) -> dict | None:
2698
- """Upgrade a MS teams app for a user. The call will fail if the user does not
2699
- already have the app assigned. So this needs to be checked before
2700
- calling this method.
3062
+ def remove_teams_app_from_user(
3063
+ self, user_id: str, app_name: str, app_installation_id: str | None = None
3064
+ ) -> dict | None:
3065
+ """Remove a M365 Teams app from a M365 user.
3066
+
3067
+ See: https://learn.microsoft.com/en-us/graph/api/userteamwork-delete-installedapps?view=graph-rest-1.0&tabs=http
2701
3068
 
2702
3069
  Args:
2703
3070
  user_id (str): M365 GUID of the user (can also be the M365 email of the user)
@@ -2706,14 +3073,18 @@ class M365(object):
2706
3073
  dict: response of the MS Graph API call or None if the call fails.
2707
3074
  """
2708
3075
 
2709
- response = self.get_teams_apps_of_user(
2710
- user_id, "contains(teamsAppDefinition/displayName, '{}')".format(app_name)
2711
- )
2712
- # Retrieve the installation specific App ID - this is different from thew App catalalog ID!!
2713
- app_installation_id = self.get_result_value(response, "id", 0)
3076
+ if not app_installation_id:
3077
+ response = self.get_teams_apps_of_user(
3078
+ user_id=user_id,
3079
+ filter_expression="contains(teamsAppDefinition/displayName, '{}')".format(
3080
+ app_name
3081
+ ),
3082
+ )
3083
+ # Retrieve the installation specific App ID - this is different from thew App catalalog ID!!
3084
+ app_installation_id = self.get_result_value(response, "id", 0)
2714
3085
  if not app_installation_id:
2715
3086
  logger.error(
2716
- "M365 Teams app -> %s not found for user with ID -> %s. Cannot upgrade app for this user!",
3087
+ "M365 Teams app -> '%s' not found for user with ID -> %s. Cannot remove app from this user!",
2717
3088
  app_name,
2718
3089
  user_id,
2719
3090
  )
@@ -2725,12 +3096,11 @@ class M365(object):
2725
3096
  + user_id
2726
3097
  + "/teamwork/installedApps/"
2727
3098
  + app_installation_id
2728
- + "/upgrade"
2729
3099
  )
2730
3100
  request_header = self.request_header()
2731
3101
 
2732
- logger.info(
2733
- "Upgrade M365 Teams app -> %s (%s) of M365 user with ID -> %s; calling -> %s",
3102
+ logger.debug(
3103
+ "Remove M365 Teams app -> '%s' (%s) from M365 user with ID -> %s; calling -> %s",
2734
3104
  app_name,
2735
3105
  app_installation_id,
2736
3106
  user_id,
@@ -2739,12 +3109,14 @@ class M365(object):
2739
3109
 
2740
3110
  retries = 0
2741
3111
  while True:
2742
- response = requests.post(request_url, headers=request_header, timeout=60)
3112
+ response = requests.delete(
3113
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
3114
+ )
2743
3115
  if response.ok:
2744
3116
  return self.parse_request_response(response)
2745
3117
  # Check if Session has expired - then re-authenticate and try once more
2746
3118
  elif response.status_code == 401 and retries == 0:
2747
- logger.warning("Session has expired - try to re-authenticate...")
3119
+ logger.debug("Session has expired - try to re-authenticate...")
2748
3120
  self.authenticate(revalidate=True)
2749
3121
  request_header = self.request_header()
2750
3122
  retries += 1
@@ -2758,7 +3130,7 @@ class M365(object):
2758
3130
  retries += 1
2759
3131
  else:
2760
3132
  logger.error(
2761
- "Failed to upgrade M365 Teams app -> %s (%s) of M365 user -> %s; status -> %s; error -> %s",
3133
+ "Failed to remove M365 Teams app -> '%s' (%s) from M365 user -> %s; status -> %s; error -> %s",
2762
3134
  app_name,
2763
3135
  app_installation_id,
2764
3136
  user_id,
@@ -2788,8 +3160,9 @@ class M365(object):
2788
3160
  "teamsApp@odata.bind": self.config()["teamsAppsUrl"] + "/" + app_id
2789
3161
  }
2790
3162
 
2791
- logger.info(
2792
- "Assign M365 Teams app -> %s to M365 Team -> %s; calling -> %s",
3163
+ logger.debug(
3164
+ "Assign M365 Teams app -> '%s' (%s) to M365 Team -> %s; calling -> %s",
3165
+ self.config()["teamsAppName"],
2793
3166
  app_id,
2794
3167
  team_id,
2795
3168
  request_url,
@@ -2798,13 +3171,16 @@ class M365(object):
2798
3171
  retries = 0
2799
3172
  while True:
2800
3173
  response = requests.post(
2801
- request_url, json=post_body, headers=request_header, timeout=60
3174
+ request_url,
3175
+ json=post_body,
3176
+ headers=request_header,
3177
+ timeout=REQUEST_TIMEOUT,
2802
3178
  )
2803
3179
  if response.ok:
2804
3180
  return self.parse_request_response(response)
2805
3181
  # Check if Session has expired - then re-authenticate and try once more
2806
3182
  elif response.status_code == 401 and retries == 0:
2807
- logger.warning("Session has expired - try to re-authenticate...")
3183
+ logger.debug("Session has expired - try to re-authenticate...")
2808
3184
  self.authenticate(revalidate=True)
2809
3185
  request_header = self.request_header()
2810
3186
  retries += 1
@@ -2818,7 +3194,8 @@ class M365(object):
2818
3194
  retries += 1
2819
3195
  else:
2820
3196
  logger.error(
2821
- "Failed to assign M365 Teams app -> %s to M365 Team -> %s; status -> %s; error -> %s",
3197
+ "Failed to assign M365 Teams app -> '%s' (%s) to M365 Team -> %s; status -> %s; error -> %s",
3198
+ self.config()["teamsAppName"],
2822
3199
  app_id,
2823
3200
  team_id,
2824
3201
  response.status_code,
@@ -2848,7 +3225,7 @@ class M365(object):
2848
3225
  app_installation_id = self.get_result_value(response, "id", 0)
2849
3226
  if not app_installation_id:
2850
3227
  logger.error(
2851
- "M365 Teams app -> %s not found for M365 Team with ID -> %s. Cannot upgrade app for this team!",
3228
+ "M365 Teams app -> '%s' not found for M365 Team with ID -> %s. Cannot upgrade app for this team!",
2852
3229
  app_name,
2853
3230
  team_id,
2854
3231
  )
@@ -2864,8 +3241,8 @@ class M365(object):
2864
3241
  )
2865
3242
  request_header = self.request_header()
2866
3243
 
2867
- logger.info(
2868
- "Upgrade app -> %s (%s) of M365 team with ID -> %s; calling -> %s",
3244
+ logger.debug(
3245
+ "Upgrade app -> '%s' (%s) of M365 team with ID -> %s; calling -> %s",
2869
3246
  app_name,
2870
3247
  app_installation_id,
2871
3248
  team_id,
@@ -2874,12 +3251,14 @@ class M365(object):
2874
3251
 
2875
3252
  retries = 0
2876
3253
  while True:
2877
- response = requests.post(request_url, headers=request_header, timeout=60)
3254
+ response = requests.post(
3255
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
3256
+ )
2878
3257
  if response.ok:
2879
3258
  return self.parse_request_response(response)
2880
3259
  # Check if Session has expired - then re-authenticate and try once more
2881
3260
  elif response.status_code == 401 and retries == 0:
2882
- logger.warning("Session has expired - try to re-authenticate...")
3261
+ logger.debug("Session has expired - try to re-authenticate...")
2883
3262
  self.authenticate(revalidate=True)
2884
3263
  request_header = self.request_header()
2885
3264
  retries += 1
@@ -2893,7 +3272,7 @@ class M365(object):
2893
3272
  retries += 1
2894
3273
  else:
2895
3274
  logger.error(
2896
- "Failed to upgrade app -> %s (%s) of M365 team with ID -> %s; status -> %s; error -> %s",
3275
+ "Failed to upgrade M365 Teams app -> '%s' (%s) of M365 team with ID -> %s; status -> %s; error -> %s",
2897
3276
  app_name,
2898
3277
  app_installation_id,
2899
3278
  team_id,
@@ -2945,8 +3324,10 @@ class M365(object):
2945
3324
  None,
2946
3325
  )
2947
3326
  if not channel:
2948
- logger.erro(
2949
- "Cannot find Channel -> %s on M365 Team -> %s", channel_name, team_name
3327
+ logger.error(
3328
+ "Cannot find Channel -> '%s' on M365 Team -> '%s'",
3329
+ channel_name,
3330
+ team_name,
2950
3331
  )
2951
3332
  return None
2952
3333
  channel_id = channel["id"]
@@ -2974,8 +3355,8 @@ class M365(object):
2974
3355
  },
2975
3356
  }
2976
3357
 
2977
- logger.info(
2978
- "Add Tab -> %s with App ID -> %s to Channel -> %s of Microsoft 365 Team -> %s; calling -> %s",
3358
+ logger.debug(
3359
+ "Add Tab -> '%s' with App ID -> %s to Channel -> '%s' of Microsoft 365 Team -> '%s'; calling -> %s",
2979
3360
  tab_name,
2980
3361
  app_id,
2981
3362
  channel_name,
@@ -2986,13 +3367,16 @@ class M365(object):
2986
3367
  retries = 0
2987
3368
  while True:
2988
3369
  response = requests.post(
2989
- request_url, headers=request_header, json=tab_config, timeout=60
3370
+ request_url,
3371
+ headers=request_header,
3372
+ json=tab_config,
3373
+ timeout=REQUEST_TIMEOUT,
2990
3374
  )
2991
3375
  if response.ok:
2992
3376
  return self.parse_request_response(response)
2993
3377
  # Check if Session has expired - then re-authenticate and try once more
2994
3378
  elif response.status_code == 401 and retries == 0:
2995
- logger.warning("Session has expired - try to re-authenticate...")
3379
+ logger.debug("Session has expired - try to re-authenticate...")
2996
3380
  self.authenticate(revalidate=True)
2997
3381
  request_header = self.request_header()
2998
3382
  retries += 1
@@ -3006,7 +3390,7 @@ class M365(object):
3006
3390
  retries += 1
3007
3391
  else:
3008
3392
  logger.error(
3009
- "Failed to add Tab for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s; tab config -> %s",
3393
+ "Failed to add Tab for M365 Team -> '%s' (%s) and Channel -> '%s' (%s); status -> %s; error -> %s; tab config -> %s",
3010
3394
  team_name,
3011
3395
  team_id,
3012
3396
  channel_name,
@@ -3058,8 +3442,10 @@ class M365(object):
3058
3442
  None,
3059
3443
  )
3060
3444
  if not channel:
3061
- logger.erro(
3062
- "Cannot find Channel -> %s for M365 Team -> %s", channel_name, team_name
3445
+ logger.error(
3446
+ "Cannot find Channel -> '%s' for M365 Team -> '%s'",
3447
+ channel_name,
3448
+ team_name,
3063
3449
  )
3064
3450
  return None
3065
3451
  channel_id = channel["id"]
@@ -3075,8 +3461,8 @@ class M365(object):
3075
3461
  None,
3076
3462
  )
3077
3463
  if not tab:
3078
- logger.erro(
3079
- "Cannot find Tab -> %s on M365 Team -> %s (%s) and Channel -> %s (%s)",
3464
+ logger.error(
3465
+ "Cannot find Tab -> '%s' on M365 Team -> '%s' (%s) and Channel -> '%s' (%s)",
3080
3466
  tab_name,
3081
3467
  team_name,
3082
3468
  team_id,
@@ -3108,8 +3494,8 @@ class M365(object):
3108
3494
  },
3109
3495
  }
3110
3496
 
3111
- logger.info(
3112
- "Update Tab -> %s (%s) of Channel -> %s (%s) for Microsoft 365 Teams -> %s (%s) with configuration -> %s; calling -> %s",
3497
+ logger.debug(
3498
+ "Update Tab -> '%s' (%s) of Channel -> '%s' (%s) for Microsoft 365 Teams -> '%s' (%s) with configuration -> %s; calling -> %s",
3113
3499
  tab_name,
3114
3500
  tab_id,
3115
3501
  channel_name,
@@ -3123,13 +3509,16 @@ class M365(object):
3123
3509
  retries = 0
3124
3510
  while True:
3125
3511
  response = requests.patch(
3126
- request_url, headers=request_header, json=tab_config, timeout=60
3512
+ request_url,
3513
+ headers=request_header,
3514
+ json=tab_config,
3515
+ timeout=REQUEST_TIMEOUT,
3127
3516
  )
3128
3517
  if response.ok:
3129
3518
  return self.parse_request_response(response)
3130
3519
  # Check if Session has expired - then re-authenticate and try once more
3131
3520
  elif response.status_code == 401 and retries == 0:
3132
- logger.warning("Session has expired - try to re-authenticate...")
3521
+ logger.debug("Session has expired - try to re-authenticate...")
3133
3522
  self.authenticate(revalidate=True)
3134
3523
  request_header = self.request_header()
3135
3524
  retries += 1
@@ -3143,7 +3532,7 @@ class M365(object):
3143
3532
  retries += 1
3144
3533
  else:
3145
3534
  logger.error(
3146
- "Failed to update Tab -> %s (%s) for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s",
3535
+ "Failed to update Tab -> '%s' (%s) for M365 Team -> '%s' (%s) and Channel -> '%s' (%s); status -> %s; error -> %s",
3147
3536
  tab_name,
3148
3537
  tab_id,
3149
3538
  team_name,
@@ -3189,8 +3578,10 @@ class M365(object):
3189
3578
  None,
3190
3579
  )
3191
3580
  if not channel:
3192
- logger.erro(
3193
- "Cannot find Channel -> %s for M365 Team -> %s", channel_name, team_name
3581
+ logger.error(
3582
+ "Cannot find Channel -> '%s' for M365 Team -> '%s'",
3583
+ channel_name,
3584
+ team_name,
3194
3585
  )
3195
3586
  return False
3196
3587
  channel_id = channel["id"]
@@ -3206,8 +3597,8 @@ class M365(object):
3206
3597
  item for item in response["value"] if item["displayName"] == tab_name
3207
3598
  ]
3208
3599
  if not tab_list:
3209
- logger.erro(
3210
- "Cannot find Tabs with name -> %s on M365 Team -> %s (%s) and Channel -> %s (%s)",
3600
+ logger.error(
3601
+ "Cannot find Tab -> '%s' on M365 Team -> '%s' (%s) and Channel -> '%s' (%s)",
3211
3602
  tab_name,
3212
3603
  team_name,
3213
3604
  team_id,
@@ -3231,8 +3622,8 @@ class M365(object):
3231
3622
 
3232
3623
  request_header = self.request_header()
3233
3624
 
3234
- logger.info(
3235
- "Delete Tab -> %s (%s) from Channel -> %s (%s) of Microsoft 365 Teams -> %s (%s); calling -> %s",
3625
+ logger.debug(
3626
+ "Delete Tab -> '%s' (%s) from Channel -> '%s' (%s) of Microsoft 365 Teams -> '%s' (%s); calling -> %s",
3236
3627
  tab_name,
3237
3628
  tab_id,
3238
3629
  channel_name,
@@ -3245,11 +3636,11 @@ class M365(object):
3245
3636
  retries = 0
3246
3637
  while True:
3247
3638
  response = requests.delete(
3248
- request_url, headers=request_header, timeout=60
3639
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
3249
3640
  )
3250
3641
  if response.ok:
3251
- logger.info(
3252
- "Tab -> %s (%s) has been deleted from Channel -> %s (%s) of Microsoft 365 Teams -> %s (%s)",
3642
+ logger.debug(
3643
+ "Tab -> '%s' (%s) has been deleted from Channel -> '%s' (%s) of Microsoft 365 Teams -> '%s' (%s)",
3253
3644
  tab_name,
3254
3645
  tab_id,
3255
3646
  channel_name,
@@ -3260,7 +3651,7 @@ class M365(object):
3260
3651
  break
3261
3652
  # Check if Session has expired - then re-authenticate and try once more
3262
3653
  elif response.status_code == 401 and retries == 0:
3263
- logger.warning("Session has expired - try to re-authenticate...")
3654
+ logger.debug("Session has expired - try to re-authenticate...")
3264
3655
  self.authenticate(revalidate=True)
3265
3656
  request_header = self.request_header()
3266
3657
  retries += 1
@@ -3274,7 +3665,7 @@ class M365(object):
3274
3665
  retries += 1
3275
3666
  else:
3276
3667
  logger.error(
3277
- "Failed to delete Tab -> %s (%s) for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s",
3668
+ "Failed to delete Tab -> '%s' (%s) for M365 Team -> '%s' (%s) and Channel -> '%s' (%s); status -> %s; error -> %s",
3278
3669
  tab_name,
3279
3670
  tab_id,
3280
3671
  team_name,
@@ -3334,22 +3725,25 @@ class M365(object):
3334
3725
  request_url = self.config()["securityUrl"] + "/sensitivityLabels"
3335
3726
  request_header = self.request_header()
3336
3727
 
3337
- logger.info(
3338
- "Create M365 sensitivity label -> %s; calling -> %s", name, request_url
3728
+ logger.debug(
3729
+ "Create M365 sensitivity label -> '%s'; calling -> %s", name, request_url
3339
3730
  )
3340
3731
 
3341
3732
  # Send the POST request to create the label
3342
3733
  response = requests.post(
3343
- request_url, headers=request_header, data=json.dumps(payload), timeout=60
3734
+ request_url,
3735
+ headers=request_header,
3736
+ data=json.dumps(payload),
3737
+ timeout=REQUEST_TIMEOUT,
3344
3738
  )
3345
3739
 
3346
3740
  # Check the response status code
3347
3741
  if response.status_code == 201:
3348
- logger.info("Label -> %s has been created successfully!", name)
3742
+ logger.debug("Label -> '%s' has been created successfully!", name)
3349
3743
  return response
3350
3744
  else:
3351
3745
  logger.error(
3352
- "Failed to create the M365 label -> %s! Response status code -> %s",
3746
+ "Failed to create the M365 label -> '%s'! Response status code -> %s",
3353
3747
  name,
3354
3748
  response.status_code,
3355
3749
  )
@@ -3377,8 +3771,8 @@ class M365(object):
3377
3771
  )
3378
3772
  request_header = self.request_header()
3379
3773
 
3380
- logger.info(
3381
- "Assign label -> %s to user -> %s; calling -> %s",
3774
+ logger.debug(
3775
+ "Assign label -> '%s' to user -> '%s'; calling -> %s",
3382
3776
  label_name,
3383
3777
  user_email,
3384
3778
  request_url,
@@ -3387,13 +3781,13 @@ class M365(object):
3387
3781
  retries = 0
3388
3782
  while True:
3389
3783
  response = requests.post(
3390
- request_url, headers=request_header, json=body, timeout=60
3784
+ request_url, headers=request_header, json=body, timeout=REQUEST_TIMEOUT
3391
3785
  )
3392
3786
  if response.ok:
3393
3787
  return self.parse_request_response(response)
3394
3788
  # Check if Session has expired - then re-authenticate and try once more
3395
3789
  elif response.status_code == 401 and retries == 0:
3396
- logger.warning("Session has expired - try to re-authenticate...")
3790
+ logger.debug("Session has expired - try to re-authenticate...")
3397
3791
  self.authenticate(revalidate=True)
3398
3792
  request_header = self.request_header()
3399
3793
  retries += 1
@@ -3407,7 +3801,7 @@ class M365(object):
3407
3801
  retries += 1
3408
3802
  else:
3409
3803
  logger.error(
3410
- "Failed to assign label -> %s to M365 user -> %s; status -> %s; error -> %s",
3804
+ "Failed to assign label -> '%s' to M365 user -> '%s'; status -> %s; error -> %s",
3411
3805
  label_name,
3412
3806
  user_email,
3413
3807
  response.status_code,
@@ -3438,7 +3832,7 @@ class M365(object):
3438
3832
 
3439
3833
  # request_header = self.request_header()
3440
3834
 
3441
- logger.info("Install Outlook Add-in from %s (NOT IMPLEMENTED)", app_path)
3835
+ logger.debug("Install Outlook Add-in from '%s' (NOT IMPLEMENTED)", app_path)
3442
3836
 
3443
3837
  response = None
3444
3838
 
@@ -3464,20 +3858,22 @@ class M365(object):
3464
3858
  ] + "?$filter=displayName eq '{}'".format(app_registration_name)
3465
3859
  request_header = self.request_header()
3466
3860
 
3467
- logger.info(
3468
- "Get Azure App Registration -> %s; calling -> %s",
3861
+ logger.debug(
3862
+ "Get Azure App Registration -> '%s'; calling -> %s",
3469
3863
  app_registration_name,
3470
3864
  request_url,
3471
3865
  )
3472
3866
 
3473
3867
  retries = 0
3474
3868
  while True:
3475
- response = requests.get(request_url, headers=request_header, timeout=60)
3869
+ response = requests.get(
3870
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
3871
+ )
3476
3872
  if response.ok:
3477
3873
  return self.parse_request_response(response)
3478
3874
  # Check if Session has expired - then re-authenticate and try once more
3479
3875
  elif response.status_code == 401 and retries == 0:
3480
- logger.warning("Session has expired - try to re-authenticate...")
3876
+ logger.debug("Session has expired - try to re-authenticate...")
3481
3877
  self.authenticate(revalidate=True)
3482
3878
  request_header = self.request_header()
3483
3879
  retries += 1
@@ -3491,7 +3887,7 @@ class M365(object):
3491
3887
  retries += 1
3492
3888
  else:
3493
3889
  logger.error(
3494
- "Cannot find Azure App Registration -> %s; status -> %s; error -> %s",
3890
+ "Cannot find Azure App Registration -> '%s'; status -> %s; error -> %s",
3495
3891
  app_registration_name,
3496
3892
  response.status_code,
3497
3893
  response.text,
@@ -3573,13 +3969,13 @@ class M365(object):
3573
3969
  request_url,
3574
3970
  headers=request_header,
3575
3971
  json=app_registration_data,
3576
- timeout=60,
3972
+ timeout=REQUEST_TIMEOUT,
3577
3973
  )
3578
3974
  if response.ok:
3579
3975
  return self.parse_request_response(response)
3580
3976
  # Check if Session has expired - then re-authenticate and try once more
3581
3977
  elif response.status_code == 401 and retries == 0:
3582
- logger.warning("Session has expired - try to re-authenticate...")
3978
+ logger.debug("Session has expired - try to re-authenticate...")
3583
3979
  self.authenticate(revalidate=True)
3584
3980
  request_header = self.request_header()
3585
3981
  retries += 1
@@ -3593,7 +3989,7 @@ class M365(object):
3593
3989
  retries += 1
3594
3990
  else:
3595
3991
  logger.error(
3596
- "Cannot add App Registration -> %s; status -> %s; error -> %s",
3992
+ "Cannot add App Registration -> '%s'; status -> %s; error -> %s",
3597
3993
  app_registration_name,
3598
3994
  response.status_code,
3599
3995
  response.text,
@@ -3632,8 +4028,8 @@ class M365(object):
3632
4028
  request_url = self.config()["applicationsUrl"] + "/" + app_registration_id
3633
4029
  request_header = self.request_header()
3634
4030
 
3635
- logger.info(
3636
- "Update App Registration -> %s (%s); calling -> %s",
4031
+ logger.debug(
4032
+ "Update App Registration -> '%s' (%s); calling -> %s",
3637
4033
  app_registration_name,
3638
4034
  app_registration_id,
3639
4035
  request_url,
@@ -3645,13 +4041,13 @@ class M365(object):
3645
4041
  request_url,
3646
4042
  headers=request_header,
3647
4043
  json=app_registration_data,
3648
- timeout=60,
4044
+ timeout=REQUEST_TIMEOUT,
3649
4045
  )
3650
4046
  if response.ok:
3651
4047
  return self.parse_request_response(response)
3652
4048
  # Check if Session has expired - then re-authenticate and try once more
3653
4049
  elif response.status_code == 401 and retries == 0:
3654
- logger.warning("Session has expired - try to re-authenticate...")
4050
+ logger.debug("Session has expired - try to re-authenticate...")
3655
4051
  self.authenticate(revalidate=True)
3656
4052
  request_header = self.request_header()
3657
4053
  retries += 1
@@ -3665,7 +4061,7 @@ class M365(object):
3665
4061
  retries += 1
3666
4062
  else:
3667
4063
  logger.error(
3668
- "Cannot update App Registration -> %s (%s); status -> %s; error -> %s",
4064
+ "Cannot update App Registration -> '%s' (%s); status -> %s; error -> %s",
3669
4065
  app_registration_name,
3670
4066
  app_registration_id,
3671
4067
  response.status_code,
@@ -3674,3 +4070,522 @@ class M365(object):
3674
4070
  return None
3675
4071
 
3676
4072
  # end method definition
4073
+
4074
+ def get_mail(
4075
+ self,
4076
+ user_id: str,
4077
+ sender: str,
4078
+ subject: str,
4079
+ num_emails: int | None = None,
4080
+ show_error: bool = False,
4081
+ ) -> dict | None:
4082
+ """Get email from inbox of a given user and a given sender (from)
4083
+ This requires Mail.Read Application permissions for the Azure App being used.
4084
+
4085
+ Args:
4086
+ user_id (str): M365 ID of the user
4087
+ sender (str): sender email address to filter for
4088
+ num_emails (int, optional): number of matching emails to retrieve
4089
+ show_error (bool): whether or not an error should be displayed if the
4090
+ user is not found.
4091
+ Returns:
4092
+ dict: Email or None of the request fails.
4093
+ """
4094
+
4095
+ # Attention: you can easily run in limitation of the MS Graph API. If selection + filtering
4096
+ # is too complex you can get this error: "The restriction or sort order is too complex for this operation."
4097
+ # that's why we first just do the ordering and then do the filtering on sender and subject
4098
+ # separately
4099
+ request_url = (
4100
+ self.config()["usersUrl"]
4101
+ + "/"
4102
+ + user_id
4103
+ # + "/messages?$filter=from/emailAddress/address eq '{}' and contains(subject, '{}')&$orderby=receivedDateTime desc".format(
4104
+ + "/messages?$orderby=receivedDateTime desc"
4105
+ )
4106
+ if num_emails:
4107
+ request_url += "&$top={}".format(num_emails)
4108
+
4109
+ request_header = self.request_header()
4110
+
4111
+ logger.debug(
4112
+ "Retrieve mails for user -> %s from -> '%s' with subject -> '%s'; calling -> %s",
4113
+ user_id,
4114
+ sender,
4115
+ subject,
4116
+ request_url,
4117
+ )
4118
+
4119
+ retries = 0
4120
+ while True:
4121
+ response = requests.get(
4122
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
4123
+ )
4124
+ if response.ok:
4125
+ response = self.parse_request_response(response)
4126
+ messages = response["value"] if response else []
4127
+
4128
+ # Filter the messages by sender and subject in code
4129
+ filtered_messages = [
4130
+ msg
4131
+ for msg in messages
4132
+ if msg.get("from", {}).get("emailAddress", {}).get("address")
4133
+ == sender
4134
+ and subject in msg.get("subject", "")
4135
+ ]
4136
+ response["value"] = filtered_messages
4137
+ return response
4138
+
4139
+ # Check if Session has expired - then re-authenticate and try once more
4140
+ elif response.status_code == 401 and retries == 0:
4141
+ logger.debug("Session has expired - try to re-authenticate...")
4142
+ self.authenticate(revalidate=True)
4143
+ request_header = self.request_header()
4144
+ retries += 1
4145
+ elif response.status_code in [502, 503, 504] and retries < 3:
4146
+ logger.warning(
4147
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
4148
+ response.status_code,
4149
+ (retries + 1) * 60,
4150
+ )
4151
+ time.sleep((retries + 1) * 60)
4152
+ retries += 1
4153
+ else:
4154
+ if show_error:
4155
+ logger.error(
4156
+ "Cannot retrieve emails for user -> %s; status -> %s; error -> %s",
4157
+ user_id,
4158
+ response.status_code,
4159
+ response.text,
4160
+ )
4161
+ else:
4162
+ logger.warning(
4163
+ "Cannot retrieve emails for user -> %s; status -> %s; warning -> %s",
4164
+ user_id,
4165
+ response.status_code,
4166
+ response.text,
4167
+ )
4168
+ return None
4169
+
4170
+ # end method definition
4171
+
4172
+ def get_mail_body(self, user_id: str, email_id: str) -> str:
4173
+ """Get full email body for a given email ID
4174
+ This requires Mail.Read Application permissions for the Azure App being used.
4175
+
4176
+ Args:
4177
+ user_id (str): M365 ID of the user
4178
+ email_id (str): M365 ID of the email
4179
+ Returns:
4180
+ str: Email body or None of the request fails.
4181
+ """
4182
+
4183
+ request_url = (
4184
+ self.config()["usersUrl"]
4185
+ + "/"
4186
+ + user_id
4187
+ + "/messages/"
4188
+ + email_id
4189
+ + "/$value"
4190
+ )
4191
+
4192
+ request_header = self.request_header()
4193
+
4194
+ retries = 0
4195
+ while True:
4196
+ response = requests.get(
4197
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
4198
+ )
4199
+ if response.ok:
4200
+ return response.content.decode("utf-8")
4201
+ # Check if Session has expired - then re-authenticate and try once more
4202
+ elif response.status_code == 401 and retries == 0:
4203
+ logger.debug("Session has expired - try to re-authenticate...")
4204
+ self.authenticate(revalidate=True)
4205
+ request_header = self.request_header()
4206
+ retries += 1
4207
+ elif response.status_code in [502, 503, 504] and retries < 3:
4208
+ logger.warning(
4209
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
4210
+ response.status_code,
4211
+ (retries + 1) * 60,
4212
+ )
4213
+ time.sleep((retries + 1) * 60)
4214
+ retries += 1
4215
+ else:
4216
+ logger.error(
4217
+ "Cannot retrieve emails body for user -> %s and email -> %s; status -> %s; error -> %s",
4218
+ user_id,
4219
+ email_id,
4220
+ response.status_code,
4221
+ response.text,
4222
+ )
4223
+ return None
4224
+
4225
+ # end method definition
4226
+
4227
+ def extract_url_from_message_body(
4228
+ self,
4229
+ message_body: str,
4230
+ search_pattern: str,
4231
+ multi_line: bool = False,
4232
+ multi_line_end_marker: str = "%3D",
4233
+ line_end_marker: str = "=",
4234
+ replacements: list | None = None,
4235
+ ) -> str | None:
4236
+ """Parse the email body to extract (a potentially multi-line) URL from the body.
4237
+
4238
+ Args:
4239
+ message_body (str): Text of the Email body
4240
+ search_pattern (str): Pattern thatneeds to be in first line of the URL. This
4241
+ makes sure it is the right URL we are looking for.
4242
+ multi_line (bool, optional): Is the URL spread over multiple lines?. Defaults to False.
4243
+ multi_line_end_marker (str, optional): If it is a multi-line URL, what marks the end
4244
+ of the URL in the last line? Defaults to "%3D".
4245
+ line_end_marker (str, optional): What makrs the end of lines 1-(n-1)? Defaults to "=".
4246
+ Returns:
4247
+ str: URL text thathas been extracted.
4248
+ """
4249
+
4250
+ if not message_body:
4251
+ return None
4252
+
4253
+ # Split all the lines after a CRLF:
4254
+ lines = [line.strip() for line in message_body.split("\r\n")]
4255
+
4256
+ # Filter out the complete URL from the extracted URLs
4257
+ found = False
4258
+
4259
+ url = ""
4260
+
4261
+ for line in lines:
4262
+ if found:
4263
+ # Remove line end marker - many times a "="
4264
+ if line.endswith(line_end_marker):
4265
+ line = line[:-1]
4266
+ for replacement in replacements:
4267
+ line = line.replace(replacement["from"], replacement["to"])
4268
+ # We consider an empty line after we found the URL to indicate the end of the URL:
4269
+ if line == "":
4270
+ break
4271
+ url += line
4272
+ if multi_line and line.endswith(multi_line_end_marker):
4273
+ break
4274
+ if not search_pattern in line:
4275
+ continue
4276
+ # Fine https:// in the current line:
4277
+ index = line.find("https://")
4278
+ if index == -1:
4279
+ continue
4280
+ # If there's any text in front of https in that line cut it:
4281
+ line = line[index:]
4282
+ # Remove line end marker - many times a "="
4283
+ if line.endswith(line_end_marker):
4284
+ line = line[:-1]
4285
+ for replacement in replacements:
4286
+ line = line.replace(replacement["from"], replacement["to"])
4287
+ found = True
4288
+ url += line
4289
+ if not multi_line:
4290
+ break
4291
+
4292
+ return url
4293
+
4294
+ # end method definition
4295
+
4296
+ def delete_mail(self, user_id: str, email_id: str) -> dict | None:
4297
+ """Delete email from inbox of a given user and a given email ID.
4298
+ This requires Mail.ReadWrite Application permissions for the Azure App being used.
4299
+
4300
+ Args:
4301
+ user_id (str): M365 ID of the user
4302
+ email_id (str): M365 ID of the email
4303
+ Returns:
4304
+ dict: Email or None of the request fails.
4305
+ """
4306
+
4307
+ request_url = (
4308
+ self.config()["usersUrl"] + "/" + user_id + "/messages/" + email_id
4309
+ )
4310
+
4311
+ request_header = self.request_header()
4312
+
4313
+ retries = 0
4314
+ while True:
4315
+ response = requests.delete(
4316
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
4317
+ )
4318
+ if response.ok:
4319
+ return self.parse_request_response(response)
4320
+ # Check if Session has expired - then re-authenticate and try once more
4321
+ elif response.status_code == 401 and retries == 0:
4322
+ logger.debug("Session has expired - try to re-authenticate...")
4323
+ self.authenticate(revalidate=True)
4324
+ request_header = self.request_header()
4325
+ retries += 1
4326
+ elif response.status_code in [502, 503, 504] and retries < 3:
4327
+ logger.warning(
4328
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
4329
+ response.status_code,
4330
+ (retries + 1) * 60,
4331
+ )
4332
+ time.sleep((retries + 1) * 60)
4333
+ retries += 1
4334
+ else:
4335
+ logger.error(
4336
+ "Cannot delete email -> %s from inbox of user -> %s; status -> %s; error -> %s",
4337
+ email_id,
4338
+ user_id,
4339
+ response.status_code,
4340
+ response.text,
4341
+ )
4342
+ return None
4343
+
4344
+ # end method definition
4345
+
4346
+ def email_verification(
4347
+ self,
4348
+ user_email: str,
4349
+ sender: str,
4350
+ subject: str,
4351
+ url_search_pattern: str,
4352
+ line_end_marker: str = "=",
4353
+ multi_line: bool = True,
4354
+ multi_line_end_marker: str = "%3D",
4355
+ replacements: list | None = None,
4356
+ max_retries: int = 6,
4357
+ use_browser_automation: bool = False,
4358
+ password: str = "",
4359
+ password_field_id: str = "",
4360
+ password_confirmation_field_id: str = "",
4361
+ password_submit_xpath: str = "",
4362
+ terms_of_service_xpath: str = "",
4363
+ ) -> bool:
4364
+ """Process email verification
4365
+
4366
+ Args:
4367
+ user_email (str): Email address of user recieving the verification mail.
4368
+ sender (str): Email sender (address)
4369
+ subject (str): Email subject to look for (can be substring)
4370
+ url_search_pattern (str): String the URL needs to contain to identify it.
4371
+ multi_line_end_marker (str): If the URL spans multiple lines this is the "end" marker for the last line.
4372
+ replacements (list): if the URL needs some treatment these replacements can be applied.
4373
+ Result:
4374
+ bool: True = Success, False = Failure
4375
+ """
4376
+
4377
+ # Determine the M365 user for the current user by
4378
+ # the email address:
4379
+ m365_user = self.get_user(user_email=user_email)
4380
+ m365_user_id = self.get_result_value(m365_user, "id")
4381
+ if not m365_user_id:
4382
+ logger.warning("Cannot find M365 user -> %s", user_email)
4383
+ return False
4384
+
4385
+ if replacements is None:
4386
+ replacements = [{"from": "=3D", "to": "="}]
4387
+
4388
+ retries = 0
4389
+ while retries < max_retries:
4390
+ response = self.get_mail(
4391
+ user_id=m365_user_id,
4392
+ sender=sender,
4393
+ subject=subject,
4394
+ show_error=False,
4395
+ )
4396
+ if response and response["value"]:
4397
+ emails = response["value"]
4398
+ # potentially there may be multiple matching emails,
4399
+ # we want the most recent one (from today):
4400
+ latest_email = max(emails, key=lambda x: x["receivedDateTime"])
4401
+ # Extract just the date:
4402
+ latest_email_date = latest_email["receivedDateTime"].split("T")[0]
4403
+ # Get the current date (today):
4404
+ today_date = datetime.today().strftime("%Y-%m-%d")
4405
+ # We do a sanity check here: the verification mail should be from today,
4406
+ # otherwise we assume it is an old mail and we need to wait for the
4407
+ # new verification mail to yet arrive:
4408
+ if latest_email_date != today_date:
4409
+ logger.info(
4410
+ "Verification email not yet received (latest mail from -> %s). Waiting %s seconds...",
4411
+ latest_email_date,
4412
+ 10 * (retries + 1),
4413
+ )
4414
+ time.sleep(10 * (retries + 1))
4415
+ retries += 1
4416
+ continue
4417
+ email_id = latest_email["id"]
4418
+ # The full email body needs to be loaded with a separate REST call:
4419
+ body_text = self.get_mail_body(user_id=m365_user_id, email_id=email_id)
4420
+ # Extract the verification URL.
4421
+ if body_text:
4422
+ url = self.extract_url_from_message_body(
4423
+ message_body=body_text,
4424
+ search_pattern=url_search_pattern,
4425
+ line_end_marker=line_end_marker,
4426
+ multi_line=multi_line,
4427
+ multi_line_end_marker=multi_line_end_marker,
4428
+ replacements=replacements,
4429
+ )
4430
+ else:
4431
+ url = ""
4432
+ if not url:
4433
+ logger.warning("Cannot find verification link in the email body!")
4434
+ return False
4435
+ # Simulate a "click" on this URL:
4436
+ if use_browser_automation:
4437
+ # Core Share needs a full browser:
4438
+ browser_automation_object = BrowserAutomation(
4439
+ take_screenshots=True,
4440
+ automation_name="email-verification",
4441
+ )
4442
+ logger.info(
4443
+ "Open URL -> %s to verify account or email change (using browser automation)",
4444
+ url,
4445
+ )
4446
+ success = browser_automation_object.get_page(url)
4447
+ if success:
4448
+ user_interaction_required = False
4449
+ logger.info(
4450
+ "Successfully opened URL. Browser title is -> '%s'.",
4451
+ browser_automation_object.get_title(),
4452
+ )
4453
+ if password_field_id:
4454
+ password_field = browser_automation_object.find_elem(
4455
+ find_elem=password_field_id, show_error=False
4456
+ )
4457
+ if password_field:
4458
+ # The subsequent processing is only required if
4459
+ # the returned page requests a password change:
4460
+ user_interaction_required = True
4461
+ logger.info(
4462
+ "Found password field on returned page - it seems email verification requests password entry!"
4463
+ )
4464
+ result = browser_automation_object.find_elem_and_set(
4465
+ find_elem=password_field_id,
4466
+ elem_value=password,
4467
+ is_sensitive=True,
4468
+ )
4469
+ if not result:
4470
+ logger.error(
4471
+ "Failed to enter password in field -> '%s'",
4472
+ password_field_id,
4473
+ )
4474
+ success = False
4475
+ else:
4476
+ logger.info(
4477
+ "No user interaction required (no password change or terms of service acceptance)."
4478
+ )
4479
+ if user_interaction_required and password_confirmation_field_id:
4480
+ password_confirm_field = (
4481
+ browser_automation_object.find_elem(
4482
+ find_elem=password_confirmation_field_id,
4483
+ show_error=False,
4484
+ )
4485
+ )
4486
+ if password_confirm_field:
4487
+ logger.info(
4488
+ "Found password confirmation field on returned page - it seems email verification requests consecutive password entry!"
4489
+ )
4490
+ result = browser_automation_object.find_elem_and_set(
4491
+ find_elem=password_confirmation_field_id,
4492
+ elem_value=password,
4493
+ is_sensitive=True,
4494
+ )
4495
+ if not result:
4496
+ logger.error(
4497
+ "Failed to enter password in field -> '%s'",
4498
+ password_confirmation_field_id,
4499
+ )
4500
+ success = False
4501
+ if user_interaction_required and password_submit_xpath:
4502
+ password_submit_button = (
4503
+ browser_automation_object.find_elem(
4504
+ find_elem=password_submit_xpath,
4505
+ find_method="xpath",
4506
+ show_error=False,
4507
+ )
4508
+ )
4509
+ if password_submit_button:
4510
+ logger.info(
4511
+ "Submit password change dialog with button -> '%s' (found with XPath -> %s)",
4512
+ password_submit_button.text,
4513
+ password_submit_xpath,
4514
+ )
4515
+ result = browser_automation_object.find_elem_and_click(
4516
+ find_elem=password_submit_xpath, find_method="xpath"
4517
+ )
4518
+ if not result:
4519
+ logger.error(
4520
+ "Failed to press submit button -> %s",
4521
+ password_submit_xpath,
4522
+ )
4523
+ success = False
4524
+ # TODO is this sleep required? The Terms of service dialog has some weird animation
4525
+ # which may require this. It seems it is rtequired!
4526
+ time.sleep(1)
4527
+ terms_accept_button = browser_automation_object.find_elem(
4528
+ find_elem=terms_of_service_xpath,
4529
+ find_method="xpath",
4530
+ show_error=False,
4531
+ )
4532
+ if terms_accept_button:
4533
+ logger.info(
4534
+ "Accept terms of service with button -> '%s' (found with XPath -> %s)",
4535
+ terms_accept_button.text,
4536
+ terms_of_service_xpath,
4537
+ )
4538
+ result = browser_automation_object.find_elem_and_click(
4539
+ find_elem=terms_of_service_xpath,
4540
+ find_method="xpath",
4541
+ )
4542
+ if not result:
4543
+ logger.error(
4544
+ "Failed to accept terms of service with button -> '%s'",
4545
+ terms_accept_button.text,
4546
+ )
4547
+ success = False
4548
+ else:
4549
+ logger.info("No Terms of Service acceptance required.")
4550
+ # end if use_browser_automation
4551
+ else:
4552
+ # Salesforce (other than Core Share) is OK with the simple HTTP GET request:
4553
+ logger.info("Open URL -> %s to verify account or email change", url)
4554
+ response = self._http_object.http_request(url=url, method="GET")
4555
+ success = response and response.ok
4556
+
4557
+ if success:
4558
+ logger.info("Remove email from inbox of user -> %s...", user_email)
4559
+ response = self.delete_mail(user_id=m365_user_id, email_id=email_id)
4560
+ if not response:
4561
+ logger.warning(
4562
+ "Couldn't remove the mail from the inbox of user -> %s",
4563
+ user_email,
4564
+ )
4565
+ # We have success now and can break from the while loop
4566
+ return True
4567
+ else:
4568
+ logger.error(
4569
+ "Failed to process e-mail verification for user -> %s",
4570
+ user_email,
4571
+ )
4572
+ return False
4573
+ # end if response and response["value"]
4574
+ else:
4575
+ logger.info(
4576
+ "Verification email not yet received (no mails with sender -> %s and subject -> '%s' found). Waiting %s seconds...",
4577
+ sender,
4578
+ subject,
4579
+ 10 * (retries + 1),
4580
+ )
4581
+ time.sleep(10 * (retries + 1))
4582
+ retries += 1
4583
+ # end while
4584
+
4585
+ logger.warning(
4586
+ "Verification mail for user -> %s has not arrived in time.", user_email
4587
+ )
4588
+
4589
+ return False
4590
+
4591
+ # end method definition