Skip to content

Commit

Permalink
Merge branch 'main' into saved-courses
Browse files Browse the repository at this point in the history
  • Loading branch information
plumshum authored Oct 29, 2024
2 parents 2d1970e + 07cf500 commit e81d8b0
Show file tree
Hide file tree
Showing 72 changed files with 86,904 additions and 21,380 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,11 @@ scripts/out
# Secret API key
python/secret_api_keys.py

# Email API key stuff.
.env.private

# Python compilation files.
*/**/*.pyc

# Pytest
python/__pycache__
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11.0
6 changes: 6 additions & 0 deletions cypress/integration/accessibility-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ it('Check navbar accessibility', () => {
cy.checkA11y('[data-cyId=navbar]', null, null, true); // only check accessibility within the navbar
});

// Test to confirm that the new user walkthrough works as expected
// Click through the initial explanation, then the 4 following steps, and finally the finishing page
it('Click through schedule generator tour', () => {
cy.get('.introjs-nextbutton').click();
});

// Check the accessibility of the requirements sidebar with all toggles fully open
// Note that the selector in checkA11y ensures only the sidebar is inspected
it('Check accessibility of the requirements sidebar', () => {
Expand Down
6 changes: 1 addition & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"esbuild": "^0.15.5",
"firebase": "^9.9.3",
"firebase-admin": "^11.5.0",
"html2canvas": "^1.4.1",
"intro.js": "^3.3.1",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.5.25",
Expand Down
99 changes: 99 additions & 0 deletions scripts/email/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# CoursePlan Email System

Welcome to the CoursePlan email-sending system. This folder is the home for all logic and templates related to the emails that we send to users, such as onboarding, reminders, notifications about pre-enrollment, etc.

## Infrastructure

This system is built on top of [Resend](https://resend.com)'s email API. We chose Resend over a Google-Cloud based OAuth solution like Nodemailer (used by IDOL) for the following reasons:

1. (**Main reason**): capable of handling high email volumes; Gmail automatically limits the number of emails sent per day and we would go way over that.
2. Improved deliverability rates.
3. Simplified API for sending.

Then in comparison to other email services like Sendgrid, Resend stood out for its quality developer documentation and YC backing.

## Getting Started

1. Install dependencies in the `scripts/email` directory:
```bash
python3 -m pip install -r requirements.txt
```

2. Set up environment variables in `.env.private` **in the root directory of the project (`courseplan`)**:
```
RESEND_API_KEY=your_resend_api_key # contact Simon or your TPM for access
GLOBAL_FROM_NAME=CoursePlan # what the name of the sender will be
[email protected] # what the email of the sender will be (once DNS records for courseplan.io are configured)
[email protected] # a dummy email address to ensure bcc works
```
**Never commit this file!** (Should already be in `.gitignore`.)

3. Create a new template in `scripts/email/templates/` or use the existing `dryrun.py` as a test example with your own email.

4. Update the import in `execute_template.py` to import the template you created in the previous step. As an example, if you created a file called `new_course_reminder.py` in the templates folder, you would update the import in `execute_template.py` to `from .templates.new_course_reminder import *`. For details on how to create a template, see two sections from now.

5. `cd` upwards into the root directory and run the script:
```bash
python3 scripts/email/execute_template.py
```

**Important**: Please revert your import change to `execute_template.py` before pushing any changes. By always sticking with `dryrun.py` as the base template, we can avoid accidentally sending emails to thousands of users (surefire way to worsen your chances at getting an A/A+ for the semester 😅).

## How It Works

1. The script loads environment variables and the specified email template.
2. It chunks the BCC list into groups of 49 recipients to make best use of our 100-email-per-day free tier. (50 is the max number of recipients allowed in a single Resend API call, and this is shared across `to` and `bcc` recipients.)
3. Emails are sent in batches, with progress updates printed to the console.

## Creating Templates

1. Create a new Python file in `scripts/email/templates/`.
2. Define `BCC`, `SUBJECT`, and `HTML` variables.
- `BCC` should be a list of emails to send to.
- `SUBJECT` is the subject of the email.
- `HTML` is the body of the email.
3. **Test your template** by running `python3 scripts/email/execute_template.py` with a simplified BCC list before sending to a large audience.

A couple notes:
- You can refer to existing templates for best practices and to see how to e.g. have the `BCC` list be dynamically generated from our Firebase users.
- **Important**: Ensure all HTML styling is inline as we unfortunately cannot use external CSS directives.

## Fetching Users from Firebase

We use Firebase to store user data and retrieve it for our email templates. The process is handled by the `firebase_users_loader.py` helper script under `scripts/email/helpers/`.

1. The script connects to Firebase using a service account key, stored in the root directory of the project as `serviceAccountKey.json`.
3. It retrieves user data from the `user-onboarding-data` collection.
2. Then, it fetches all user names from the `user-name` collection.
4. The data is processed and organized into a dictionary, with keys being tuples of (graduation_semester, graduation_year).
5. Each user's data includes email, name, colleges, grad programs, majors, minors, and graduation information.

The `USERS` variable in `firebase_users_loader.py` contains this processed data and is imported by individual email templates.

### Using Firebase Data in Templates

Email templates, such as `current_freshman.py`, import the `USERS` data and filter it based on specific criteria. For example:

```python
from scripts.email.templates.helpers.firebase_users_loader import USERS

BCC = [
user["email"]
for users in USERS.values()
for user in users
if (
(user["graduation_year"] == "2028" and user["graduation_semester"] == "Spring")
or (user["graduation_year"] == "2027" and user["graduation_semester"] == "Fall")
)
]
```

This code filters the `USERS` data to create a `BCC` list of email addresses for current freshmen (in FA24) based on their expected graduation year and semester.

By using this approach, we can easily create targeted email lists for different groups of students without manually maintaining separate lists.

## Further Notes

- You **must** run the script from the root directory of the project.
- If you want to have emails land in more than 4,900 inboxes per day, you will need to stagger the emails over several days. Note that these 4,900 inboxes are already "batched" into 100 emails each with 49 bcc recipients.
- A dummy "to" recipient is required when using BCC for technical reasons. (This does not count towards the 49 bcc recipients.)
74 changes: 74 additions & 0 deletions scripts/email/execute_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import os
from dotenv import load_dotenv
import resend
from typing import List

# Load relevant template to run this script on.
from templates.versioned_preenroll.sp25.current_freshman import *

# Load environment variables from .env
load_dotenv(".env.private")

resend.api_key = os.environ["RESEND_API_KEY"]

# Global constants across all sessions — please do not change these.
FROM = os.environ["GLOBAL_FROM_NAME"] + " <" + os.environ["GLOBAL_FROM_EMAIL"] + ">"
TO = os.environ["GLOBAL_TO_EMAIL"]


def chunk_list(lst: List[str], chunk_size: int) -> List[List[str]]:
return [lst[i : i + chunk_size] for i in range(0, len(lst), chunk_size)]


def send_emails(bcc_list: List[str]):
bcc_chunks = chunk_list(bcc_list, 49)
total_chunks = len(bcc_chunks)

# Add option to list all emails
list_emails = (
input("Do you want to list all email addresses? (y/N): ").lower().strip()
)
if list_emails == "y":
print("\nList of email addresses:")
for email in bcc_list:
print(email)
print()

# Add confirmation prompt
confirm = (
input(
f"Do you want to proceed with sending {len(bcc_chunks)} {'email' if len(bcc_chunks) == 1 else 'emails'} to {len(bcc_list)} {'recipient' if len(bcc_list) == 1 else 'recipients'}? (y/N): "
)
.lower()
.strip()
)

if confirm != "y":
print("Email sending cancelled. Goodbye!")
return
else:
print(
f"Starting email job with {len(bcc_list)} {'recipient' if len(bcc_list) == 1 else 'recipients'} in {total_chunks} batch(es).\n"
)

for i, bcc_chunk in enumerate(bcc_chunks, 1):
params: resend.Emails.SendParams = {
"from": FROM,
"to": TO, # Must always have a to recipient for bcc to work.
"bcc": bcc_chunk,
"subject": SUBJECT,
"html": HTML,
}

try:
resend.Emails.send(params)
print(f"Batch {i}/{total_chunks} sent successfully!")
except Exception as e:
print(f"Batch {i}/{total_chunks} failed with error: {e}")

print("\nAll email batches sent successfully!")
print("Email job completed!")


if __name__ == "__main__":
send_emails(BCC)
77 changes: 77 additions & 0 deletions scripts/email/helpers/firebase_users_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import firebase_admin
from firebase_admin import credentials, firestore
from collections import defaultdict
from typing import Dict, List, Tuple

cred = credentials.Certificate("./serviceAccountKey.json")
firebase_admin.initialize_app(cred)

db = firestore.client()


# NOTE: currently not being used for personalization due to BCC logic.
# If we do end up personalizing sometime, then uncomment this and associated
# calls later in the file + update the email script.
# def get_all_user_names() -> Dict[str, Tuple[str, str, str]]:
# print("Fetching all user names from Firebase...", end="\n\n")
# user_names = {}
# docs = db.collection("user-name").get()
# for doc in docs:
# data = doc.to_dict()
# user_names[doc.id] = (
# data.get("firstName", None),
# data.get("middleName", None),
# data.get("lastName", None),
# )
# return user_names


def get_users() -> Dict[Tuple[str, str], List[Dict]]:
print("Fetching all user data from Firebase...")
# user_names = get_all_user_names()
users = db.collection("user-onboarding-data").get()
user_map = defaultdict(list)

for user in users:
user_data = user.to_dict()
email = user.id
# first_name, middle_name, last_name = user_names.get(
# email, (None, None, None)
# ) # NOTE: may cause type errors if we don't have a name for this user and try
# # to send them an email regardless. (Deliberate decision.)

# # Deleted accounts or something? Not sure to do with these people.
# if first_name is None and middle_name is None and last_name is None:
# print(f"User {email} not found in user-name collection!")

colleges = [
college.get("acronym", "") for college in user_data.get("colleges", [])
]
grad_programs = [
program.get("acronym", "") for program in user_data.get("gradPrograms", [])
]
entrance_sem = user_data.get("entranceSem", None)
entrance_year = user_data.get("entranceYear", None)
majors = [major.get("acronym", "") for major in user_data.get("majors", [])]
minors = [minor.get("acronym", "") for minor in user_data.get("minors", [])]

key = (entrance_sem, entrance_year)
user_map[key].append(
{
"email": email,
# "first_name": first_name,
# "middle_name": middle_name,
# "last_name": last_name,
"colleges": colleges,
"grad_programs": grad_programs,
"entrance_semester": entrance_sem,
"entrance_year": entrance_year,
"majors": majors,
"minors": minors,
}
)

return dict(user_map)


USERS = get_users()
Binary file added scripts/email/playground/images/cp_square.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/email/playground/images/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/email/playground/images/screen1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/email/playground/images/screen2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit e81d8b0

Please sign in to comment.