diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/blueprints/__init__.py b/api/blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/blueprints/mail/__init__.py b/api/blueprints/mail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/blueprints/test/__init__.py b/api/blueprints/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/blueprints/test/callback.py b/api/blueprints/test/callback.py new file mode 100644 index 0000000..7828581 --- /dev/null +++ b/api/blueprints/test/callback.py @@ -0,0 +1,184 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Thursday, 21st Nov., 2024 + + OBJECTIVE: + + To receive callbacks (webhooks). + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + + NOTES: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For using Quart: +from quart import Blueprint, current_app, request + +# My utils: +from utils_v2.string import json +from utils_v2.api.codes import StatusCodes, HttpCodes +from utils_v2.api.response import ResponseModel +from utils_v2.api.async_quart import ( + set_api_version, + read_input, + get_session_info, + log_request_to_mongo, + log_chain_to_mongo, + should_not_be_under_maintenance, + only_whitelisted_ips, + limit_rate, + validate_input, + handle_cancelled_request +) + +# Common: +from shared import constants + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +test_callback_bp = Blueprint("user_cb", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@test_callback_bp.record_once +def init(blueprint_setup_state): + + # This gets called when the blueprint is registered. + # Consider this to be a one-time setup for the whole blueprint: + pass + + +# --------------------------------------------------------------------------------------------------------------------- + + +@test_callback_bp.route("/callback", methods = ["POST", "GET"]) +@set_api_version(api_version = "1.0.0") +@read_input(sanitize_headers = False, sanitize_data = False) +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "testCllBckApi", + log_input = True, + log_output = True, + sensitive_keys = None +) +@log_chain_to_mongo(attr_name = "logs_mongo") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +@handle_cancelled_request() +async def callback_test( + inbound_headers: dict = None, + inbound_data: dict = None, + inbound_files: dict = None, + **kwargs +): + + """ + This URL does nothing, just captures data on webhooks and logs it for documentation. + :param inbound_headers: auto-extracted by the decorators. + :param inbound_data: auto-extracted by the decorators. + :param inbound_files: auto-extracted by the decorators. + :param kwargs: Any number of extra inputs supplied by the decorators. + :return: A standard response structure. + """ + + # Construct a message: + message = "🪝 *WEBHOOK/CALLBACK ALERT!* 🪝\n\n" + message += f"Method: *{request.method}*\nLog Id.: `{kwargs.get('log_id')}`\n\n" + message += "*Headers:*\n```json\n" + message += json.to_string({k: v for k, v in request.headers.items()}) + message += "\n```\n" + message += "*Query Args:*\n```json\n" + message += json.to_string(request.args.to_dict()) + message += "\n```\n" + message += "*JSON:*\n```json\n" + message += json.to_string(await request.get_json()) + message += "\n```\n" + message += "*Form-Data:*\n```json\n" + message += json.to_string((await request.form).to_dict()) + message += "\n```\n" + message += "*Form-Files:*\n```json\n" + message += json.to_string(inbound_files, default = str) + message += "\n```\n" + + # Send a message on Telegram: + api_response = await current_app.http_client.post( + url = current_app.script_data["alerts"]["url"], + json = { + "message": message, + "type": "info", + "chatId": "1275560043" # ... KPS + } + ) + + # Return a success response: + return ResponseModel( + status_code = StatusCodes.OK, + data = {"accepted": True} + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..baf53eb --- /dev/null +++ b/api/main.py @@ -0,0 +1,409 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Tuesday, 8th Oct., 2024 + + OBJECTIVE: + + This is the central location for the Quart module. + We define the app here, and import and attach all blueprints here. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For system level activities: +import gc +import os +import psutil + +# For using Quart: +from quart import Quart, request, current_app +from quart_cors import cors + +# Common: +from shared import constants + +# My utils: +from utils_v2.string import json +from utils_v2.api import async_quart +from utils_v2.datetime import datetime +from utils_v2.database.async_mongo_v2 import AsyncMongo +from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache +from utils_v2.api.async_quart import ( + set_api_version, + read_input, + log_request_to_mongo, + should_not_be_under_maintenance, + only_whitelisted_ips, + limit_rate, + validate_input, + handle_cancelled_request +) + +# To make REST API calls: +import httpx + +# For debugging: +from icecream import IceCreamDebugger + +# All the blueprints: +from api.blueprints.test.callback import test_callback_bp + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Quart related: +MODULE_BASE = constants.MODULE_NAME +APP_VERSION = constants.APP_VERSION + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# The Quart app: +app = Quart(__name__) +app = cors(app) +app.register_blueprint(test_callback_bp, url_prefix = f"/{MODULE_BASE}/test") + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +def set_cpu_affinity(requested_cpus: list): + + """ + Sets the affinity of the current process to certain CPUs so that performance is boosted. The main factors that + contribute to gains are cache-locality, reduced context switching, and effective resource management. + :param requested_cpus: The array of integers of which CPU cores are preferred. + :return: None. + """ + + # Get the number of available CPUs: + num_cpus = psutil.cpu_count() + + # Wrap around logic for when a core has been request that doesn't exist on this machine. + # This is useful in cases like developing on a local machine with just 4 cores, but your server has dozens of cores. + valid_cpus = [cpu % num_cpus for cpu in requested_cpus] + + # Set the CPU affinity: + psutil.Process().cpu_affinity(valid_cpus) + + +# --------------------------------------------------------------------------------------------------------------------- + + +@app.before_serving +@set_api_version(api_version = APP_VERSION) +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "apiStart", + log_input = True, + log_output = True +) +async def app_startup(**kwargs): + + """ + To initialize the variables that you would like to use in this module. + WARNING: ALL VARIABLES WILL BE INITIALIZED 'n' NUMBER OF TIMES, WHERE 'n' IS THE COUNT OF WORKERS DEPLOYED. + SO, IF YOU WANT TO CONNECT TO A DATABASE AND YOU ALLOW A POOL-SIZE OF 10 AND IF YOU DEPLOY 4 WORKERS, YOU WILL END + UP WITH 40 CONNECTIONS TO THE DATABASE. + :return: None. + """ + + # Safe-halt mechanism for upgrades (for a single-worker run): + current_app.is_under_maintenance = False + + # Debugging: + enable_debugging = True if os.environ["DEBUG"].strip().lower() == "true" else False + current_app.printer = IceCreamDebugger(prefix = f"{MODULE_BASE} (Q) | ", includeContext = True) + if not enable_debugging: current_app.printer.disable() + current_app.printer("initializing worker...") + + # Make an instance of an HTTP client to use to make API calls: + current_app.http_client = httpx.AsyncClient( + timeout = httpx.Timeout( + 10.0, + read = 5.0 + ) + ) + + # Get the script credentials and data: + script_id = os.environ["SCRIPT_ID"] + response = await current_app.http_client.get( + url = r"https://nexcom.ditscentre.in/internal/cred/get", + headers = {"X-Script-Id": script_id} + ) + script_cred = response.json().get("data") + response = await current_app.http_client.get( + url = r"https://nexcom.ditscentre.in/internal/data/get", + headers = {"X-Script-Id": script_id} + ) + current_app.script_data = response.json().get("data") + + # We set the CPU affinity: + try: set_cpu_affinity(script_cred["cpuAffinity"]) + except Exception as exception: current_app.printer(exception) + + # Caching connections: + current_app.rate_limit_cache = AsyncRedisCache( + connection_string = script_cred["redisCache"]["rateLimit"]["connectionString"], + debug = enable_debugging, + debug_prefix = "RL Cache | " + ) + current_app.module_cache = AsyncRedisCache( + connection_string = script_cred["redisCache"]["funcReturn"]["connectionString"], + debug = enable_debugging, + debug_prefix = "User Cache | " + ) + + # MongoDB connections: + current_app.logs_mongo = AsyncMongo( + connection_string = script_cred["mongoDb"]["logs"]["connectionString"], + database_name = script_cred["mongoDb"]["logs"]["dbName"], + max_connections = script_cred["mongoDb"]["logs"]["poolSize"], + debug = enable_debugging + ) + await current_app.logs_mongo.connect() + + # Pick the important stuff: + current_app.whitelisted_ips = current_app.script_data["whitelistedIps"] + + # Remove unwanted/sensitive variables from RAM: + del script_cred + gc.collect() + + # Done here! + current_app.printer("Worker ready!") + + +# --------------------------------------------------------------------------------------------------------------------- + + +@app.after_serving +@set_api_version(api_version = APP_VERSION) +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "apiStop", + log_input = True, + log_output = True +) +async def app_shutdown(**kwargs): + + """ + This is called when "app.shutdown()" is called. + :return: None. + """ + + message = "Shutting down..." + current_app.printer(message) + + +# --------------------------------------------------------------------------------------------------------------------- + + +@app.route(f"/", methods = ["GET", "POST"]) +@app.route(f"/{MODULE_BASE}", methods = ["GET", "POST"]) +async def root(): + + """ + To check if the service is running or not. + Use this to monitor the service from your "watchman" script. + :return: only "ok" + """ + + return "ok" + + +# --------------------------------------------------------------------------------------------------------------------- + + +@app.route(f"/metrics/memory", methods = ["GET", "POST"]) +@app.route(f"/{MODULE_BASE}/metrics/memory", methods = ["GET", "POST"]) +async def memory_metrics(): + + """ + TO measure the metrics of the app. + :return: A JSON of the metrics. + """ + + # Figure pout the parent process. + # This is important for multi-worker environments: + parent_pid = os.getppid() + parent_process = psutil.Process(parent_pid) + parent_mem_info = parent_process.memory_info() + + # Figure out all the children of the parent process: + child_pids = [child.pid for child in parent_process.children()] + child_processes = [psutil.Process(child_pid) for child_pid in child_pids] + child_stats = [] + for pid, process in zip(child_pids, child_processes): + mem_info = process.memory_info() + child_stats.append({ + "pid": pid, + "mem": round(mem_info.rss / (1024 ** 2), 2) + }) + + # Construct the response: + metrics_json = { + "parent": { + "pid": parent_pid, + "mem": round(parent_mem_info.rss / (1024 ** 2), 2) + }, + "children": child_stats, + "unit": { + "mem": "MB" + }, + "ts": datetime.get_current_ist_date_time(as_string = True) + } + + # Done here: + return metrics_json + + +# --------------------------------------------------------------------------------------------------------------------- + + +@app.route(f"/{MODULE_BASE}/debug/", methods = ["POST", "GET"]) +async def change_debug(action): + + """ + Enable or disable debugging for the entire microservice. + WARNING: NOT RECOMMENDED FOR MULTI-WORKER DEPLOYMENTS. + :param action: "enable" to allow debugging on the terminal, or "disable". + :return: "enabled"/"disabled" if successful, else "ok" + """ + + # Enable or disable debugging only if the password matches: + action = action.lower() + if action == "enable": current_app.printer.enable() + elif action == "disable": current_app.printer.disable() + return "ok" + + +# --------------------------------------------------------------------------------------------------------------------- + + +@app.route(f"/{MODULE_BASE}/maintenance/", methods = ["POST", "GET"]) +async def change_maintenance(action): + + """ + Enable or disable debugging for the entire microservice. + WARNING: NOT RECOMMENDED FOR MULTI-WORKER DEPLOYMENTS. + :param action: "enable" to stop taking new requests on the API, or "disable". + :return: "enabled"/"disabled" if successful, else "ok" + """ + + # Enable or disable debugging only if the password matches: + action = action.lower() + if action == "enable": + current_app.is_under_maintenance = True + os.environ["IS_UNDER_MAINTENANCE"] = "True" + elif action == "disable": + current_app.is_under_maintenance = False + os.environ["IS_UNDER_MAINTENANCE"] = "False" + return "ok" + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + # To get args from the terminal: + import argparse + + # To run the ASGI: + import uvicorn + from multiprocessing import freeze_support + + # Get the config from the command-line: + parser = argparse.ArgumentParser(description = f"Microservice for '{MODULE_BASE}' API.") + parser.add_argument( + "--workers", + type = int, + help = "The no. of threads to spin up for this instance!", + default = 2 + ) + parser.add_argument( + "--host", + type = str, + help = "The host for the app. e.g.: '0.0.0.0' or '127.0.0.1'.", + default = "127.0.0.1" + ) + parser.add_argument( + "--port", + type = int, + help = "The port no. to bind the app to.", + default = 8080 + ) + parser.add_argument( + "--script-id", + type = str, + help = "The id of this script (will affect the loaded config)." + ) + parser.add_argument( + "--debug", + action = "store_true", + help = "Whether, or not, you want to see debugging messages in the terminal.", + default = False + ) + args = parser.parse_args() + + # Note down the config; + os.environ["SCRIPT_ID"] = args.script_id + os.environ["DEBUG"] = str(args.debug) + + # Run the gateway: + freeze_support() + uvicorn.run( + app = "main:app", + workers = args.workers, + host = args.host, + port = args.port + ) diff --git a/creds/google_converse_test_oauth.json b/creds/google_converse_test_oauth.json new file mode 100644 index 0000000..ee3568b --- /dev/null +++ b/creds/google_converse_test_oauth.json @@ -0,0 +1 @@ +{"web":{"client_id":"559989535634-b9pe4t06dj3b4crcarrrqn13rdsmq0be.apps.googleusercontent.com","project_id":"converse-20241122","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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"}} \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/data/__init__.py b/models/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/data/shared/__init__.py b/models/data/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/data/shared/message.py b/models/data/shared/message.py new file mode 100644 index 0000000..30405d3 --- /dev/null +++ b/models/data/shared/message.py @@ -0,0 +1,107 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 22nd Nov., 2024. + + OBJECTIVE: + + To provide the data model for the structure of each message. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For making data behaviour_models: +from pydantic import BaseModel, Field, field_validator +from typing import Optional, Literal + +# My utils: +from utils_v2.string import regex + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +class LoginRequest(BaseModel): + + username: str = Field(description = "the username of the user") + + password: str = Field(description = "the password of the user") + + mode: Optional[str] = Field( + description = "the mode through which this request came in", + default = "N/A" + ) + + remoteIp: Optional[str] = Field( + description = "the ip addr of the client", + default = "N/A" + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/run.sh b/run.sh index 0ba1f74..1a44bf7 100644 --- a/run.sh +++ b/run.sh @@ -2,7 +2,7 @@ # Use this to run the microservice without any docker setup. source .venv/bin/activate -python3 "$(pwd)/api/main.py" --host "127.0.0.1" --port 5205 --workers 4 --script-id "kps_tcaoff_mUPO2QB8" & +python3 "$(pwd)/api/main.py" --host "127.0.0.1" --port 5108 --workers 4 --script-id "kps_cnv_KBC3MoaU" & deactivate # All done: diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/constants.py b/shared/constants.py new file mode 100644 index 0000000..0f6c718 --- /dev/null +++ b/shared/constants.py @@ -0,0 +1,55 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 22nd Nov., 2024 + + OBJECTIVE: + + To keep variables and values that will be shared among various parts of the project. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +# --- +import sys +sys.path.append(".") +sys.path.append("..") + +# System-level activities: +import os + +# For parsing URLs: +import urllib + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +APP_VERSION = "1.0.0" +PROJECT_NAME = "utils" +MODULE_NAME = "converse" diff --git a/utils_v2/mail_v2/__init__.py b/utils_v2/mail_v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils_v2/mail_v2/async_smtp.py b/utils_v2/mail_v2/async_smtp.py new file mode 100644 index 0000000..d825622 --- /dev/null +++ b/utils_v2/mail_v2/async_smtp.py @@ -0,0 +1,433 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 22nd Nov., 2024 + + OBJECTIVE: + + To be able to send out mails from code. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For working with mails: +import aiosmtplib +from email.mime.multipart import MIMEMultipart +from email.mime.image import MIMEImage +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders + +# For random strings: +import string +import random + +# For system-level activities: +import os + +# For working with files in RAM: +import io + +# For debugging: +from icecream import IceCreamDebugger + +# To work with datatypes: +from typing import List + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class MailMessage: + + def __init__( + self, + to_email: str, + subject: str, + cc_emails: List[str] = None, + bcc_emails: List[str] = None + ): + + """ + Create an instance of the message that you would like to send. + :param to_email: The EMail ID of th recipient. + :param subject: The subject of the mail. + :param cc_emails: A list of recipients to add to the CC section. + :param bcc_emails: A list of recipients to add to the BCC section. + """ + + self.message = MIMEMultipart() + self.message["To"] = to_email + self.message["Subject"] = subject + if cc_emails: self.message["CC"] = ",".join(cc_emails) + if bcc_emails: self.message["BCC"] = ",".join(bcc_emails) + + def add_text(self, text): + + """ + Add plain-text to the mail body. + :param text: The text to add to the mail body. + :return: None. + """ + + self.message.attach(MIMEText(text, "plain")) + + def add_html(self, html_text): + + """ + Add HTML text to the mail body. + :param html_text: The HTML text to add to the mail body. + :return: None. + """ + + self.message.attach(MIMEText(html_text, "html")) + + def add_inline_image(self, image_file, content_id = None): + + """ + Add an inline image to the body of the mail. + NOTE: This is NOT the same as sending an image as an attachment. + :param image_file: The image data to attach to the mail body. + :param content_id: Inline images are inserted via HTML bocks. This field identifies the image resource. If not + specified, I will generate a random string. You may write a custom value here if you know what you are + doing. For most use cases, please ignore this field. + :return: None. + """ + + # Read the image as bytes: + image_bytes = None + if type(image_file) is str: + with open(image_file, "rb") as opened_image_file: + image_bytes = opened_image_file.read() + if type(image_file) is io.BytesIO: + image_file.seek(0) + image_bytes = image_file.getvalue() + + # Declare the part to be attached to the multipart message: + if image_bytes is not None: + + # Create the HTML block if the image pointer is blank: + if content_id is None: + content_id = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) + self.add_html(f""" + + +

+ + + """) + + # Then add the image: + image_part = MIMEImage(image_bytes) + image_part.add_header("Content-ID", f"<{content_id}>") + self.message.attach(image_part) + + def add_attachment(self, attachment_file, file_name = None): + + # Declare the part to be attached to the multipart message: + part = MIMEBase("application", "octet-stream") + + # If the attachment is a file stored in the local disk: + if isinstance(attachment_file, str): + file_name = file_name or os.path.split(attachment_file)[-1] + with open(attachment_file, "rb") as attachment: + part.set_payload(attachment.read()) + + # If the file is held in RAM: + elif isinstance(attachment_file, io.BytesIO): + attachment_file.seek(0) + part.set_payload(attachment_file.read()) + + # Encode and attach the file: + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", + f"attachment; filename= {file_name}", + ) + self.message.attach(part) + + def get_message(self): + return self.message + + +# --------------------------------------------------------------------------------------------------------------------- + + +class AsyncSMTPClient: + + # LOGIN MODES: + LOGIN_SIMPLE = 0 + LOGIN_OAUTH = 1 + + # variables: + __client = None + __login_mode = None + + def __init__( + self, + server, + rate_limiters = None, + wait_for_turn = True, + debug = True, + debug_prefix = "Mail (C) | " + ): + + """ + Set up the mail client. + :param server: The EMail server (host). + :param rate_limiters: The rate limiters to use. Must have "get_turn" and "has_turn" methods. "get_turn" method + must wait for the turn, and "has_turn" method must only check if a turn is available. + :param wait_for_turn: To wait for turn if the rate limit has been exceeded, or to return with failure. + :param debug: Whether, or not, you want to print debugging messages. + :param debug_prefix: The prefix to identify the debugging messages. + """ + + # Initialize the debugger: + self.__printer = IceCreamDebugger(prefix = debug_prefix, includeContext = True) + if not debug: self.__printer.disable() + + # Note down the credentials and other details: + self.__server = server + self.__rate_limiters = rate_limiters if type(rate_limiters) is list else ([rate_limiters] if rate_limiters is not None else []) + self.__wait_for_turn = wait_for_turn + + def enable_debug(self): + self.__printer.enable() + + def disable_debug(self): + self.__printer.disable() + + async def login(self, email, password): + + """ + To connect to the mail server and authenticate the user. + :param email: The e-mail id of the user. + :param password: The password of the user. + :return: True if authenticated, else False. + """ + + # Initialize the SMTP connection, + # and return with success if all goes well: + try: + + self.__client = aiosmtplib.SMTP( + hostname = self.__server, + port = 587, + use_tls = False, + start_tls = False + ) + await self.__client.connect() + await self.__client.starttls() + await self.__client.login(email, password) + return True + + # Return with failure if something goes wrong: + except Exception as exception: + self.__printer(exception) + try: await self.__client.quit() + except Exception as exception: self.__printer(exception) + self.__client = None + return False + + async def login_oauth2(self, access_token): + + # Initialize the SMTP connection, + # and return with success if all goes well: + try: + + auth_string = f"" + + self.__client = aiosmtplib.SMTP( + hostname = self.__server, + port = 465, + use_tls = True + ) + await self.__client.connect() + print("AUTH METHODS:", self.__client.supported_auth_methods) + # await self.__client.authenticate("XOAUTH2", auth_string) + + # Return with failure if something goes wrong: + except Exception as exception: + self.__printer(exception) + try: await self.__client.quit() + except Exception as exception: self.__printer(exception) + self.__client = None + return False + + async def logout(self): + + """ + Closes the connection to the SMTP client. + :return: True by default. + """ + + if self.__client is not None: + try: await self.__client.quit() + except Exception as exception: self.__printer(exception) + self.__client = None + return True + + async def send(self, mail: MailMessage): + + """ + Send out the mail. + :param mail: The instance of 'MailMessage' with all the content populated. + :return: A dict with 'success' and 'message'. + """ + + # Return with failure if we aren't connected, + # and our attempt to (re)connect fails: + if not await self.ensure_connection(): + return { + "success": False, + "message": "login failed" + } + + # Comply with the rate-limit: + for rate_limiter in self.__rate_limiters: + if not self.__wait_for_turn: + if not await rate_limiter.has_turn(): return False + got_turn = await rate_limiter.get_turn() + if not got_turn: + return { + "success": False, + "message": "rate-limit wait timeout" + } + + # Try to send the message: + try: + mail.message["From"] = self.__email + response = await self.__client.send_message(mail.message) + return { + "success": True, + "message": f"mail accepted - {response[-1]}" + } + + # If something goes wrong: + except Exception as exception: + self.__printer(exception) + return { + "success": False, + "message": str(exception) + } + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + import asyncio + from utils_v2.string import json + + async def test(): + + mail_client = AsyncSMTPClient( + # email = "sender@gmail.com", + # password = "zcaf nmqy ncfz fave", + server = "smtp.gmail.com", + rate_limiters = None + ) + + my_mail = MailMessage( + to_email = "orangebhopli@gmail.coms", + subject = "Bhopli is the best!", + cc_emails = None, + bcc_emails = None + ) + my_mail.add_html( + """ + + + + + Sample HTML String + + + +

Hello, Bhopli!

+

Bhopli is the best, most well-behaved cat in the known universe.

+ + + """ + ) + my_mail.add_text("This is how you should pet her 👇") + # my_mail.add_inline_image(r"/path/to/image/cat_petting.png") + # my_mail.add_attachment(r"/path/to/file/sample_label.pdf") + + await mail_client.login_oauth2("1234") + # result = await mail_client.send(my_mail) + # print("MAIL RESULT:", json.to_string(result)) + await mail_client.logout() + + asyncio.run(test()) diff --git a/utils_v2/oauth/__init__.py b/utils_v2/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils_v2/oauth/base.py b/utils_v2/oauth/base.py new file mode 100644 index 0000000..588d4f2 --- /dev/null +++ b/utils_v2/oauth/base.py @@ -0,0 +1,128 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 22nd Nov., 2024 + + OBJECTIVE: + + To provide a structured way to handle OAuth2.0 behaviour for various services. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# System-level activities: +import io + +# For defining the class's structure: +from abc import ABC, abstractmethod + +# For debugging: +from icecream import IceCreamDebugger + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class OAuthBase(ABC): + + def __init__( + self, + config: dict, + redirect_url: str, + debug = True, + debug_prefix = "OAuth | ", + 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 config: + self._config = config + self._redirect_url = redirect_url + + 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 + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/utils_v2/oauth/services/__init__.py b/utils_v2/oauth/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils_v2/oauth/services/goog.py b/utils_v2/oauth/services/goog.py new file mode 100644 index 0000000..9db12aa --- /dev/null +++ b/utils_v2/oauth/services/goog.py @@ -0,0 +1,152 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 22nd Nov., 2024 + + OBJECTIVE: + + To handle OAuth2.0 activities for Google's services. + + REFERENCES: + + 1. https://developers.google.com/calendar/api/quickstart/python + + 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 + +# The base model: +from utils_v2.oauth.base import OAuthBase + +# Related to Google: +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Google Scopes: +SCOPES_GMAIL_MAIL_MANAGEMENT = [ + r"https://www.googleapis.com/auth/gmail.modify", + r"https://www.googleapis.com/auth/gmail.labels" +] +SCOPES_GMAIL_FULL = [r"https://mail.google.com/"] + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class GoogleOAuth(OAuthBase): + + # Class variables: + __flow = None + + async def init( + self, + scopes = None + ): + + # Initialize your Google App: + self._printer("Initializing flow.") + self.__flow = InstalledAppFlow.from_client_config( + self._config, + scopes = scopes or SCOPES_GMAIL_MAIL_MANAGEMENT, + redirect_uri = self._redirect_url + ) + + async def get_authorization_url(self): + + authorization_url, state = self.__flow.authorization_url( + access_type = "offline", + include_granted_scopes = "true" + ) + + return authorization_url + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + secrets_file = r"../../../creds/google_converse_test_oauth.json" + secrets_dict = json.from_file(secrets_file) + + my_goog = GoogleOAuth( + config = secrets_dict, + # redirect_url = r"https://v2.api.bicree.com/user/callback/test", + redirect_url = r"127.0.0.1", + debug = True, + debug_prefix = "OAuth (Goog) | " + ) + + async def main(): + + await my_goog.init() + print("AUTH URL:", await my_goog.get_authorization_url()) + + + asyncio.run(main()) diff --git a/utils_v2/string/__pycache__/__init__.cpython-310.pyc b/utils_v2/string/__pycache__/__init__.cpython-310.pyc index ea914a4..5a1a53f 100644 Binary files a/utils_v2/string/__pycache__/__init__.cpython-310.pyc and b/utils_v2/string/__pycache__/__init__.cpython-310.pyc differ diff --git a/utils_v2/string/__pycache__/json.cpython-310.pyc b/utils_v2/string/__pycache__/json.cpython-310.pyc index 01aa078..19d6fc5 100644 Binary files a/utils_v2/string/__pycache__/json.cpython-310.pyc and b/utils_v2/string/__pycache__/json.cpython-310.pyc differ diff --git a/utils_v2/system/__pycache__/__init__.cpython-310.pyc b/utils_v2/system/__pycache__/__init__.cpython-310.pyc index 20acb5a..a49d9d2 100644 Binary files a/utils_v2/system/__pycache__/__init__.cpython-310.pyc and b/utils_v2/system/__pycache__/__init__.cpython-310.pyc differ diff --git a/utils_v2/system/__pycache__/files.cpython-310.pyc b/utils_v2/system/__pycache__/files.cpython-310.pyc index 2f59793..f900196 100644 Binary files a/utils_v2/system/__pycache__/files.cpython-310.pyc and b/utils_v2/system/__pycache__/files.cpython-310.pyc differ