Files
api_utils_converse_v2/utils_v2/goog/gmail/gmail.py
T

867 lines
33 KiB
Python

"""
AUTHOR:
Khushal P Soonderji
DATE:
Monday, 25th Nov., 2024
OBJECTIVE:
To manage e-mails in a GMail account.
REFERENCES:
1. Quickstart: https://developers.google.com/gmail/api/quickstart/python
2. Labels: https://developers.google.com/gmail/api/guides/labels
3. Messages: https://developers.google.com/gmail/api/reference/rest/v1/users.messages
DOWNLOADS:
N/A
"""
# *****************************************************************************************************************
# ***** ****
# *** IMPORT ***
# ***** ****
# *****************************************************************************************************************
# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")
# System-level activities:
import io
# My utils:
from utils_v2.string import json
from utils_v2.string import regex
from utils_v2.date_time import date_time
from utils_v2.oauth.services.goog import GoogleOAuth
# Related to Google:
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
# To make API calls:
import httpx
# For asynchronous activities:
import asyncio
# To work with date and time:
import datetime
# For working with datatypes:
from typing import Dict, Literal, List, Any
# For debugging:
from icecream import IceCreamDebugger
# For computational help:
import math
# *****************************************************************************************************************
# ***** ****
# *** MACROS / ONE-TIME INIT ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** VARIABLES ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** FUNCTIONS ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** CLASSES ***
# ***** ****
# *****************************************************************************************************************
class AsyncGMailClient:
def __init__(
self,
credentials: Credentials,
http_client: httpx.AsyncClient,
debug = True,
debug_prefix = "GMail | ",
debug_only_errors = True
):
# Prepare the debugging utility:
self._debug_prefix = debug_prefix
self._printer = IceCreamDebugger(prefix = debug_prefix, includeContext = True)
if not debug: self._printer.disable()
self._debug_only_errors = debug_only_errors
# Accept the input configuration:
self.__http_client = http_client
self.__service = None
self.__user_email = None
self.__credentials = credentials
def enable_debug(self):
self._printer.enable()
def disable_debug(self):
self._printer.disable()
def debug_only_errors(self):
self._debug_only_errors = True
def debug_everything(self):
self._debug_only_errors = False
# ┏┓ ┏┓ ┏┓ ┏•
# ┗┓┏┓╋┓┏┏┓ ┣╋ ┃ ┏┓┏┓╋┓┏┓
# ┗┛┗ ┗┗┻┣┛ ┗┻ ┗┛┗┛┛┗┛┗┗┫
# ┛ ┛
async def initialize(self):
"""
Call this once when the instance is created.
:return: True if successful, else False.
"""
return await self.__build_service()
async def __build_service(self) -> bool:
"""
Build a service object that can be used to perform various activities.
:return: True if successful, else False.
"""
try:
# Build the service object:
self.__service = build(
serviceName = "gmail",
version = "v1",
credentials = self.__credentials,
cache_discovery = False
)
# Note down the e-mail address of the user:
profile = self.__service.users().getProfile(userId = "me").execute()
self.__user_email = profile.get("emailAddress")
# Done here:
return True
# In case something goes wrong:
except Exception as exception:
self._printer(exception)
return False
async def set_credentials(
self,
credentials: Credentials
) -> bool:
"""
To update the credentials. Needed for the times when the access token gets refreshed.
:param credentials: Google's custom 'Credentials' object that describes the OAuth-based access details.
:return: True if successful, else False.
"""
self.__credentials = credentials
return await self.__build_service()
async def __ensure_token(self) -> None:
"""
Checks if the access token has expired and refreshes if needed.
:return: None.
"""
if await GoogleOAuth.credentials_have_expired(self.__credentials):
if not self._debug_only_errors: self._printer("Refreshing Token.", self.__user_email)
self.__credentials.refresh(Request())
# ┏┓ •
# ┃┃┏┓┏┓┏┓┏┓┏┓╋┓┏┓┏
# ┣┛┛ ┗┛┣┛┗ ┛ ┗┗┗ ┛
# ┛
@property
async def user_email(self):
return self.__user_email
@property
async def user_profile(self):
return self.__service.users().getProfile(userId = "me").execute()
# ┓ ┓ ┓
# ┃ ┏┓┣┓┏┓┃┏
# ┗┛┗┻┗┛┗ ┗┛
async def list_labels(
self,
raise_exception: bool = False
) -> Dict[str, dict] | None:
"""
To get the list of labels of this user.
DOCUMENTATION:
1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/list
2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label
:param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be
suppressed.
:return: The list of labels.
"""
# Start by assuming failure:
labels = None
try:
# Standard token-refresh check:
await self.__ensure_token()
# Make the API call:
if not self._debug_only_errors: self._printer("Listing All Labels.", self.__user_email)
api_response = await self.__http_client.get(
url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels",
headers = {"Authorization": f"Bearer {self.__credentials.token}"}
)
# If the API call failed:
if api_response.status_code not in [200]: return labels
# Else we format the response:
labels = {label.pop("name"): label for label in api_response.json().get("labels", [])}
# In case something goes wrong along the way:
except Exception as exception:
if raise_exception: raise
self._printer(exception)
labels = None
# Done here:
return labels
async def get_label(
self,
label_id: str,
raise_exception: bool = False
) -> dict | None:
"""
To get one label of this user. the label will be identified by its id.
DOCUMENTATION:
1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/get
2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label
:param label_id: The id that Google assigned to the label.
:param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be
suppressed.
:return: The list of labels.
"""
# Start by assuming failure:
label = None
try:
# Standard token-refresh check:
await self.__ensure_token()
# Make the API call:
if not self._debug_only_errors: self._printer("Getting One Label.", self.__user_email)
api_response = await self.__http_client.get(
url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels/{label_id}",
headers = {"Authorization": f"Bearer {self.__credentials.token}"}
)
# If the API call failed:
if api_response.status_code not in [200]: return label
# Else we extract the response:
label = api_response.json()
# In case something goes wrong along the way:
except Exception as exception:
if raise_exception: raise
self._printer(exception)
label = None
# Done here:
return label
async def create_label(
self,
label_name: str,
label_visibility: Literal["labelShow", "labelShowIfUnread", "labelHide"] = "labelShow",
message_visibility: Literal["show", "hide"] = "show",
label_text_color: str = "#434343",
label_background_color: str = "#000000",
raise_exception: bool = False
) -> bool:
"""
Create one label for the user.
DOCUMENTATION:
1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/create
2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label
:param label_name: The display name of the label.
:param label_visibility: Whether, or not, you would like to show the label in the web UI.
:param message_visibility: Whether, or not, you would like to show messages with this label in the web UI.
:param label_text_color: The colour of the text of the label.
:param label_background_color: The colour of the background/tag of the label.
:param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be
suppressed.
:return: True if the label was created, else False.
"""
# Start by assuming failure:
success = False
try:
# Standard token-refresh check:
await self.__ensure_token()
# Make the API call:
if not self._debug_only_errors: self._printer("Creating One Label.", self.__user_email)
api_response = await self.__http_client.post(
url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels",
headers = {
"Authorization": f"Bearer {self.__credentials.token}"
},
json = {
"name": label_name,
"messageListVisibility": "show" if message_visibility else "hide",
"labelListVisibility": "labelShow" if label_visibility else "labelHide",
"color": {
"textColor": label_text_color.lower(),
"backgroundColor": label_background_color.lower()
}
}
)
# If the API call failed:
if api_response.status_code not in [200]: return success
# Else we note down the success:
if api_response.json().get("id") is not None: success = True
# In case something goes wrong along the way:
except Exception as exception:
if raise_exception: raise
self._printer(exception)
success = False
# Done here:
return success
async def update_label(
self,
label_id: str,
label_name: str = None,
label_visibility: Literal["labelShow", "labelShowIfUnread", "labelHide"] = None,
message_visibility: Literal["show", "hide"] = None,
label_text_color: str = None,
label_background_color: str = None,
raise_exception: bool = False
) -> bool:
"""
Updates one label for the user.
DOCUMENTATION:
1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/create
2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label
NOTE: Both or none of the colours must be updated. For this reason, a simple default will be chosen for the
other if only one is provided.
:param label_id: The id that Google assigned to the label.
:param label_name: The display name of the label.
:param label_visibility: Whether, or not, you would like to show the label in the web UI.
:param message_visibility: Whether, or not, you would like to show messages with this label in the web UI.
:param label_text_color: The colour of the text of the label.
:param label_background_color: The colour of the background/tag of the label.
:param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be
suppressed.
:return: True if the label was created, else False.
"""
# Start by assuming failure:
success = False
try:
# Standard token-refresh check:
await self.__ensure_token()
# Format the JSON body:
json_body = {}
if label_name: json_body["name"] = label_name
if label_visibility: json_body["labelListVisibility"] = label_visibility
if message_visibility: json_body["messageListVisibility"] = message_visibility
if label_text_color or label_background_color:
json_body["color"] = {
"textColor": (label_text_color or "#434343").lower(),
"backgroundColor": (label_background_color or "#000000").lower()
}
# If no value was given to update:
if not json_body: return success
# Make the API call:
if not self._debug_only_errors: self._printer("Updating One Label.", self.__user_email)
api_response = await self.__http_client.put(
url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels/{label_id}",
headers = {"Authorization": f"Bearer {self.__credentials.token}"},
json = json_body
)
# If the API call failed:
if api_response.status_code not in [200]: return success
# Else we note down the success:
if api_response.json().get("id") is not None: success = True
# In case something goes wrong along the way:
except Exception as exception:
if raise_exception: raise
self._printer(exception)
success = False
# Done here:
return success
async def delete_label(
self,
label_id: str,
raise_exception: bool = False
) -> bool:
"""
To delete one label of this user. the label will be identified by its id.
DOCUMENTATION:
1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/get
2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label
:param label_id: The id that Google assigned to the label.
:param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be
suppressed.
:return: The list of labels.
"""
# Start by assuming failure:
success = False
try:
# Standard token-refresh check:
await self.__ensure_token()
# Make the API call:
if not self._debug_only_errors: self._printer("Deleting One Label.", self.__user_email)
api_response = await self.__http_client.delete(
url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels/{label_id}",
headers = {"Authorization": f"Bearer {self.__credentials.token}"}
)
# If the API call failed:
if api_response.status_code not in [200, 204]: return success
# Else we extract the response:
else: success = True
# In case something goes wrong along the way:
except Exception as exception:
if raise_exception: raise
self._printer(exception)
success = False
# Done here:
return success
# ┳┳┓
# ┃┃┃┏┓┏┏┏┓┏┓┏┓┏
# ┛ ┗┗ ┛┛┗┻┗┫┗ ┛
# ┛
async def __list_messages_on_page(
self,
count: int = 100,
query: str = None,
label_ids: List[str] | str = None,
include_spam_and_trash: bool = False,
next_page_token: str = None,
raise_exception: bool = False
) -> Dict[str, Any] | None:
"""
To enlist messages on one page. Google allows at most 500 results on one page. This method respects that
pagination limit and returns only what Google gives. This method should be used internally by the class and the
class should expose another method that calls this one in loop to get any arbitrary no. of messages as the user
desires.
DOCUMENTATION:
1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list
2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message
:param count: The no. of messages to fetch.
:param query: Any query filter that is supported by GMail.
:param label_ids: The list of labels' ids that the mails must have on them.
:param include_spam_and_trash: Whether, or not, you would like to include mails categorized as spam and trash.
:param next_page_token: The token to fetch the next set of results.
:param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be
suppressed.
:return: The messages that matched the given conditions if the call was successful, else None.
"""
# Start by assuming failure:
page_messages = None
try:
# Standard token-refresh check:
await self.__ensure_token()
# Build the needed params:
params_json = {
"maxResults": count,
"includeSpamTrash": include_spam_and_trash
}
if query: params_json["q"] = query
if next_page_token: params_json["pageToken"] = next_page_token
if label_ids: params_json["labelIds"] = label_ids if isinstance(label_ids, list) else [label_ids]
# Make the API call:
if not self._debug_only_errors: self._printer(
"Listing Messages for Page.",
self.__user_email,
count,
next_page_token
)
api_response = await self.__http_client.get(
url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/messages",
headers = {"Authorization": f"Bearer {self.__credentials.token}"},
params = params_json
)
# If the API call failed:
if api_response.status_code not in [200]: return page_messages
# Else we format the response:
api_json = api_response.json()
page_messages = {
"messages": {m.pop("id"): m for m in api_json.get("messages", [])},
"nextPageToken": api_json.get("nextPageToken"),
"resultSizeEstimate": api_json["resultSizeEstimate"],
}
# In case something goes wrong along the way:
except Exception as exception:
if raise_exception: raise
self._printer(exception)
page_messages = None
# Done here:
return page_messages
async def list_messages(
self,
count: int = 100,
query: str = None,
label_ids: List[str] | str = None,
include_spam_and_trash: bool = False,
raise_exception: bool = False
) -> Dict[str, Any]:
"""
To enlist mail messages from a user's account.
DOCUMENTATION:
1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list
2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message
:param count: The no. of messages to fetch.
:param query: Any query filter that is supported by GMail.
:param label_ids: The list of labels' ids that the mails must have on them.
:param include_spam_and_trash: Whether, or not, you would like to include mails categorized as spam and trash.
:param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be
suppressed.
:return: The messages that matched the given conditions if the call was successful, else None.
"""
# Start by assuming failure:
messages = None
try:
# Standard token-refresh check:
await self.__ensure_token()
# We convert the messages to a dict:
messages = {
"messages": {},
"nextPageToken": None,
"resultSizeEstimate": 0
}
# Let's figure out how many times we'll have to loop through the process to retrieve the target no. of
# messages. Google allows you to fetch info about at most 500 messages in one go.
max_per_call = 500 # ... because Google allows at most 500 entries in one call.
iterations_needed = int(math.ceil(count / max_per_call))
last_iteration_count = count - int((max_per_call * (iterations_needed - 1)))
# Run the loop those many times:
next_page_token = None
results_size_estimate = 0
for iteration_no in range(iterations_needed):
# Figure out the count for this page:
if iterations_needed > 1:
if iteration_no < (iterations_needed - 1): iteration_count = max_per_call
else: iteration_count = last_iteration_count
else: iteration_count = count
# Retrieve the messages for this page:
iteration_messages = await self.__list_messages_on_page(
count = iteration_count,
query = query,
label_ids = label_ids,
include_spam_and_trash = include_spam_and_trash,
next_page_token = next_page_token,
raise_exception = True
)
# Check if no data was received:
if iteration_messages is None: break
if not iteration_messages.get("messages"): break
# Now that we know that messages were received:
for k, v in iteration_messages["messages"].items(): messages["messages"][k] = v
results_size_estimate += iteration_messages["resultSizeEstimate"]
# If there is no next page after this, we break out of the loop:
next_page_token = iteration_messages["nextPageToken"]
if next_page_token is None: break
# Format the final response:
messages["nextPageToken"] = next_page_token
messages["resultSizeEstimate"] = results_size_estimate
# In case something goes wrong along the way:
except Exception as exception:
if raise_exception: raise
self._printer(exception)
messages = None
# Done here:
return messages
@staticmethod
def format_message(
raw_message: Dict[str, Any],
consider_timezone = date_time.TIMEZONE_UTC,
) -> Dict[str, Any] | None:
message_ts = date_time.parse_date_time(raw_message["internalDate"], timezone = consider_timezone)
message_ts = date_time.to_timezone(message_ts, date_time.TIMEZONE_UTC)
message_headers = {h["name"]: h["value"] for h in raw_message["payload"]["headers"]}
message = {
"ts": message_ts,
"messageId": raw_message["id"],
"threadId": raw_message["threadId"],
"labels": raw_message["labelIds"],
"summary": raw_message["snippet"],
"headers": message_headers,
"from": regex.find_first(text = message_headers["From"], pattern = regex.REGEX_EMAIL_ID),
"to": regex.find_first(text = message_headers["To"], pattern = regex.REGEX_EMAIL_ID),
"cc": [
regex.find_first(
text = cc_mail.strip(),
pattern = regex.REGEX_EMAIL_ID
) for cc_mail in cc_mails.split(",")
] if (cc_mails := message_headers.get("Cc")) else None,
"bcc": [
regex.find_first(
text = bcc_mail.strip(),
pattern = regex.REGEX_EMAIL_ID
) for bcc_mail in bcc_mails.split(",")
] if (bcc_mails := message_headers.get("Bcc")) else None,
"subject": message_headers["Subject"]
}
# Done here:
return message
async def get_message(
self,
message_id: str,
return_raw = False,
consider_timezone = date_time.TIMEZONE_UTC,
raise_exception: bool = False
) -> Dict[str, Any] | None:
"""
To get one message of this user. The message will be identified by its id.
DOCUMENTATION:
1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list
2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message
2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message.MessagePart
:param message_id: The id that Google assigned to the message.
:param return_raw: Whether you want the raw message or the formatted message.
:param consider_timezone: The timestamp given by Google doesn't have timezone information. Use this parameter to
control what timezone the timestamp is interpreted as.
:param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be
suppressed.
:return: The list of labels.
"""
# Start by assuming failure:
message = None
try:
# Standard token-refresh check:
await self.__ensure_token()
# Make the API call:
if not self._debug_only_errors: self._printer("Getting One Message.", self.__user_email)
api_response = await self.__http_client.get(
url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/messages/{message_id}",
headers = {"Authorization": f"Bearer {self.__credentials.token}"}
)
# If the API call failed:
if api_response.status_code not in [200]: return message
# Return the raw message if asked:
api_json = api_response.json()
if return_raw: return api_json
# Else we extract and format the response:
message = self.format_message(
raw_message = api_json,
consider_timezone = consider_timezone
)
# In case something goes wrong along the way:
except Exception as exception:
if raise_exception: raise
self._printer(exception)
message = None
# Done here:
return message
# *****************************************************************************************************************
# ***** ****
# *** MAIN PROGRAM ***
# ***** ****
# *****************************************************************************************************************
if __name__ == "__main__":
import dateparser
# Create an HTTP client:
test_client = httpx.AsyncClient(
limits = httpx.Limits(
max_connections = 100, # ............ Maximum number of connections allowed in the pool.
max_keepalive_connections = 50, # ... Maximum number of connections that can be kept alive.
),
timeout = httpx.Timeout(
connect = 2.5, # ... Shorter connection timeout.
read = 2.5, # ...... Like what EasyEcom gives.
write = 10.0, # .... Time to wait for sending data.
pool = 120.0 # ..... Time to wait for a free connection from the pool.
)
)
secrets_file = r"../../../creds/google_tcaoff_test_oauth_20241125.json"
secrets_dict = json.from_file(secrets_file)
my_oauth = GoogleOAuth(
config = secrets_dict,
redirect_url = r"https://api.thecaoffice.com/converse/mail/callback/gmail",
debug = True,
debug_prefix = "OAuth (Goog) | ",
)
tokens = {
"access_token": "ya29.a0AeDClZAYoo85BXRId_n-hwo_amKshzi46c33GaJcsZZvGB7A7OGU2RFYcWBM_BleNBfAFUSJP2NHAvmd7Nsp_U5Kg68hXSy0iO99PNTm3pvKrJSzbkA-rXsVLsCnBIfPUMyNt2nOOVJmGwm17DNN0jAELkm1fPNTju7SZzmuaCgYKAZwSARMSFQHGX2Mis8TZui2rZT1gKySVds-N0w0175",
"refresh_token": "1//0gnqzjMf9YT19CgYIARAAGBASNgF-L9Ir3rcY37nGrV45XyOUBRllEH7Txui7T1JbwevlmDoNw7PuMu149cCWQSwsScuKaZusUQ",
"expires_in": 3539,
"expires_at": dateparser.parse("2024-11-25 10:40:40.833699+00:00"),
"scopes": [
"https://www.googleapis.com/auth/gmail.labels",
"https://www.googleapis.com/auth/gmail.modify"
]
}
async def main():
my_gmail = AsyncGMailClient(
http_client = test_client,
credentials = await my_oauth.credentials_from_tokens(tokens = tokens),
debug_only_errors = False
)
await my_gmail.initialize()
print(await my_gmail.user_email)
print(await my_gmail.user_profile)
# print(json.to_string(await my_gmail.list_labels()))
# print(json.to_string(await my_gmail.get_label(label_id = "Label_3")))
# print(json.to_string(await my_gmail.update_label(
# label_id = "Label_3",
# label_name = "Updated Label 123",
# label_background_color = "#cc3a21"
# )))
#
# print(json.to_string(await my_gmail.get_label(label_id = "Label_3")))
# print(json.to_string(await my_gmail.create_label(
# label_name = "Bye - Bye !",
# label_text_color = "#fbc8d9",
# label_background_color = "#7a2e0b"
# )))
# print(json.to_string(await my_gmail.delete_label(label_id = "Label_6")))
# print(json.to_string(await my_gmail.list_messages(count = 10)))
print(json.to_string(
await my_gmail.get_message(
message_id = "1935e92672e67f22",
return_raw = True
),
default = str)
)
asyncio.run(main())