Skip to content

Commit

Permalink
Implement adding emails to groups
Browse files Browse the repository at this point in the history
This project is now feature-complete!
  • Loading branch information
ben-z committed Aug 18, 2024
1 parent 4939574 commit 87a67cb
Show file tree
Hide file tree
Showing 7 changed files with 102 additions and 9 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,4 @@ pyrightconfig.json

# End of https://www.toptal.com/developers/gitignore/api/python

/secrets/*
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 49 additions & 0 deletions src/google_admin_sdk_utils.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 17 additions & 7 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -129,7 +132,7 @@ def sign_up(req: SignUpRequest, request: Request):
</head>
<body>
<h1>Confirm Your Email</h1>
<p>Please confirm your email address by clicking the button or the link below to continue receiving updates from '{req.mailing_list}':</p>
<p>Please confirm your email address by clicking the button or the link below to receiving updates from "{req.mailing_list}":</p>
<a href="{confirmation_url}">Confirm Email</a>
<p>If the button above does not work, please copy and paste the following URL into your browser:</p>
<p class="link-text">{confirmation_url}</p>
Expand Down Expand Up @@ -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.",
Expand Down

0 comments on commit 87a67cb

Please sign in to comment.