From 87a67cbb74ad8b9c711c4be7c4b1e540a98bf511 Mon Sep 17 00:00:00 2001 From: Ben Zhang Date: Sun, 18 Aug 2024 07:50:06 +0000 Subject: [PATCH] Implement adding emails to groups This project is now feature-complete! --- .env.example | 3 +++ .gitignore | 1 + README.md | 28 +++++++++++++++++++- docker-compose.yml | 2 ++ requirements.txt | 4 ++- src/google_admin_sdk_utils.py | 49 +++++++++++++++++++++++++++++++++++ src/main.py | 24 ++++++++++++----- 7 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 src/google_admin_sdk_utils.py diff --git a/.env.example b/.env.example index 015e88f..51b8cbd 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,6 @@ SMTP_HOST=smtp SMTP_PORT=1025 SMTP_USERNAME=testuser SMTP_PASSWORD=testpass + +# Comma-separated list of Google Group keys (group's email address, group alias, or the unique group ID) +GOOGLE_GROUPS_WHITELIST="watcloud-blog-updates@watonomous.ca" \ No newline at end of file diff --git a/.gitignore b/.gitignore index fe89a3a..4076d4f 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,4 @@ pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python +/secrets/* \ No newline at end of file diff --git a/README.md b/README.md index 2140f32..db119c5 100644 --- a/README.md +++ b/README.md @@ -1 +1,27 @@ -This project is still under development. +# mailing-list-gateway + +A simple service to ask for user confirmation before adding them to a mailing list. +Currently, only Google Groups is supported. + +## Getting started + +1. Populate `.env` with your configuration. An example is provided in `.env.example`. The default configuration is suitable for development. + + ```bash + cp .env.example .env + ``` + +2. Obtain a Google Cloud Service Account key and save it as `./secrets/google-service-account.json`. + 1. [Create](https://console.cloud.google.com/projectcreate) a Google Cloud project. + 2. Create a service account under the project with no roles. + 3. In the [Google Admin console](https://admin.google.com/), give "Groups Editor" role to the service account. + 4. Enable the [Admin SDK API](https://console.cloud.google.com/apis/library/admin.googleapis.com) in the Google Cloud project. + +3. Start the service. + + ```bash + docker compose up --build + ``` + +Now, you can view the API spec at http://localhost:8000/docs. +If you are using the default SMTP configuration, you can view outgoing emails at http://localhost:8025. diff --git a/docker-compose.yml b/docker-compose.yml index aaed7ed..ea95ba1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,10 @@ services: - SMTP_PORT=${SMTP_PORT:?} - SMTP_USERNAME=${SMTP_USERNAME:?} - SMTP_PASSWORD=${SMTP_PASSWORD:?} + - GOOGLE_GROUPS_WHITELIST=${GOOGLE_GROUPS_WHITELIST:?} volumes: - ./src:/app:ro + - ./secrets:/secrets:ro command: - "--reload" depends_on: diff --git a/requirements.txt b/requirements.txt index e3e882e..ccfee8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ typer>=0.12.4,<1 # TODO: fix to a version once we are stable watcloud-utils @ git+https://github.com/WATonomous/watcloud-utils.git apscheduler>=3.10.4,<4 - +google-api-python-client>=2.141.0,<3 +google-auth-httplib2>=0.2.0,<1 +google-auth-oauthlib>=1.2.1,<2 diff --git a/src/google_admin_sdk_utils.py b/src/google_admin_sdk_utils.py new file mode 100644 index 0000000..2b0bc27 --- /dev/null +++ b/src/google_admin_sdk_utils.py @@ -0,0 +1,49 @@ +import logging +import os.path + +from google.oauth2 import service_account +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +SERVICE_ACCOUNT_FILE = "/secrets/google-service-account.json" +SCOPES = [ + # Required to get group: https://developers.google.com/admin-sdk/directory/reference/rest/v1/groups/get + "https://www.googleapis.com/auth/admin.directory.group.readonly", + # Required to insert member: https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/insert + "https://www.googleapis.com/auth/admin.directory.group.member", +] + +GROUPS_WHITELIST = os.environ["GOOGLE_GROUPS_WHITELIST"].split(",") + + +class DirectoryService: + def __init__(self, logger=logging.getLogger(__name__)): + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_FILE, scopes=SCOPES + ) + self.service = build("admin", "directory_v1", credentials=credentials) + + # Ensure we have permissions to access the groups + for group_key in GROUPS_WHITELIST: + self.get_group(group_key) + + self.logger = logger + + logger.info( + f"DirectoryService initialized with groups whitelist: {GROUPS_WHITELIST}" + ) + + def get_group(self, group_key: str): + return self.service.groups().get(groupKey=group_key).execute() + + def insert_member(self, group_key: str, email: str): + try: + self.service.members().insert(groupKey=group_key, body={"email": email}).execute() + except HttpError as e: + if e.resp.status == 409: + self.logger.warning(f"Member {email} already exists in group {group_key}. Ignoring.") + else: + raise + + def is_whitelisted_group(self, group_key: str): + return group_key in GROUPS_WHITELIST diff --git a/src/main.py b/src/main.py index 5e76fe4..7ae2880 100644 --- a/src/main.py +++ b/src/main.py @@ -13,11 +13,9 @@ from watcloud_utils.fastapi import WATcloudFastAPI from watcloud_utils.logging import logger, set_up_logging +from google_admin_sdk_utils import DirectoryService from utils import get_azure_table_client, random_str -scheduler = BackgroundScheduler() -scheduler.start() - @asynccontextmanager async def lifespan(app: FastAPI): @@ -35,6 +33,10 @@ def healthcheck(app: WATcloudFastAPI): set_up_logging() +scheduler = BackgroundScheduler() +scheduler.start() +table_client = get_azure_table_client("signups", create_table_if_not_exists=True) +directory_service = DirectoryService(logger=logger) app = WATcloudFastAPI( logger=logger, lifespan=lifespan, @@ -48,8 +50,6 @@ def healthcheck(app: WATcloudFastAPI): health_fns=[healthcheck], ) -table_client = get_azure_table_client("signups", create_table_if_not_exists=True) - class SignUpRequest(BaseModel): mailing_list: str @@ -65,6 +65,9 @@ def sign_up(req: SignUpRequest, request: Request): if not re.match(r"[^@]+@[^@]+\.[^@]+", req.email): raise HTTPException(status_code=400, detail="Invalid email") + if not directory_service.is_whitelisted_group(req.mailing_list): + raise HTTPException(status_code=400, detail="Invalid mailing list") + # Generate a random code code = random_str(10) @@ -129,7 +132,7 @@ def sign_up(req: SignUpRequest, request: Request):

Confirm Your Email

-

Please confirm your email address by clicking the button or the link below to continue receiving updates from '{req.mailing_list}':

+

Please confirm your email address by clicking the button or the link below to receiving updates from "{req.mailing_list}":

Confirm Email

If the button above does not work, please copy and paste the following URL into your browser:

@@ -159,11 +162,18 @@ def confirm(mailing_list: str, email: str, code: str): app.runtime_info["num_failed_confirms"] += 1 raise HTTPException(status_code=400, detail="Code expired or invalid") - # TODO: Add email to mailing list + if not directory_service.is_whitelisted_group(mailing_list): + raise HTTPException( + status_code=500, detail="Invalid mailing list found in the database" + ) + + directory_service.insert_member(mailing_list, email) # delete the entity table_client.delete_entity(partition_key=mailing_list, row_key=email) + app.runtime_info["num_successful_confirms"] += 1 + return { "status": "ok", "message": f"Subscription confirmed! '{email}' has been added to the '{mailing_list}' mailing list.",