diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5257c8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Environment variables +.env +.venv/ +env/ +venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Credentials and secrets +credentials.json +token.json +*.key +*.pem + +# OS files +.DS_Store +Thumbs.db + +# Project specific +*.log +.vscode diff --git a/config.py b/config.py new file mode 100644 index 0000000..68ad911 --- /dev/null +++ b/config.py @@ -0,0 +1,37 @@ +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +@dataclass +class Config: + """Central configuration loading from .env""" + # We use os.getenv() to read the .env file. + # The second argument is a fallback default if the .env var is missing. + SPREADSHEET_ID: str = os.getenv("SPREADSHEET_ID") + TAB_NAME: str = os.getenv("SHEET_TAB_NAME", "Sheet1") + + # Convert strings to Path objects for better file handling + SIGNATURE_IMAGE_PATH: Path = Path(os.getenv("SIGNATURE_IMAGE_FILE", "signature.png")) + CREDENTIALS_FILE: Path = Path(os.getenv("CREDENTIALS_FILE", "credentials.json")) + TOKEN_FILE: Path = Path(os.getenv("TOKEN_FILE", "token.json")) + TEMPLATE_FILE: Path = Path(os.getenv("TEMPLATE_FILE", "email_template.html")) + + FORM_LINK: str = os.getenv("FORM_LINK", "#") + + SCOPES = [ + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.readonly" + ] + + @classmethod + def validate(cls): + """Checks if critical config is missing.""" + if not cls.SPREADSHEET_ID: + raise ValueError("Missing SPREADSHEET_ID in .env file") + if not cls.TEMPLATE_FILE.exists(): + raise FileNotFoundError(f"Template file not found: {cls.TEMPLATE_FILE}") \ No newline at end of file diff --git a/drive_manager.py b/drive_manager.py new file mode 100644 index 0000000..a97c353 --- /dev/null +++ b/drive_manager.py @@ -0,0 +1,44 @@ +import io +import re +from typing import Tuple + +from googleapiclient.http import MediaIoBaseDownload + + +class DriveManager: + """Handles downloading files from Google Drive.""" + + def __init__(self, service): + self.service = service + + def _extract_id(self, url: str) -> str: + """Extracts file ID from various Google Drive URL formats.""" + patterns = [ + r'/d/([a-zA-Z0-9-_]+)', + r'id=([a-zA-Z0-9-_]+)' + ] + for pattern in patterns: + match = re.search(pattern, url) + if match: + return match.group(1) + raise ValueError(f"Could not parse Drive ID from URL: {url}") + + def download_file(self, file_url: str) -> Tuple[bytes, str]: + """Downloads a file into memory and returns (bytes, filename).""" + file_id = self._extract_id(file_url) + + # Get Metadata (Name) + meta = self.service.files().get(fileId=file_id, fields='name').execute() + filename = meta.get('name', 'attachment.pdf') + + # Download Content + request = self.service.files().get_media(fileId=file_id) + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request) + + done = False + while not done: + _, done = downloader.next_chunk() + + fh.seek(0) + return fh.read(), filename \ No newline at end of file diff --git a/email_composer.py b/email_composer.py new file mode 100644 index 0000000..4a0d0c6 --- /dev/null +++ b/email_composer.py @@ -0,0 +1,69 @@ +import base64 +import logging +from email import encoders +from email.mime.base import MIMEBase +from email.mime.image import MIMEImage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from config import Config + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%H:%M:%S' +) +logger = logging.getLogger(__name__) + +class EmailService: + def __init__(self, service): + self.service = service + self.template_content = self._load_template() + + def _load_template(self) -> str: + """Reads the HTML file into memory.""" + if not Config.TEMPLATE_FILE.exists(): + raise FileNotFoundError(f"Template not found at {Config.TEMPLATE_FILE}") + with open(Config.TEMPLATE_FILE, 'r', encoding='utf-8') as f: + return f.read() + + def send_email(self, to_email: str, name: str, cert_data: bytes, cert_name: str): + msg = MIMEMultipart('mixed') + msg['To'] = to_email + msg['Subject'] = "Your CodeQuest 3.0 Certificate" + + msg_related = MIMEMultipart('related') + msg.attach(msg_related) + + # Inject variables into the HTML template + # We use .format() to replace {name} and {form_link} in the HTML file + try: + filled_html = self.template_content.format( + name=name, + form_link=Config.FORM_LINK + ) + except KeyError as e: + logger.error(f"Template error: Missing placeholder {e} in HTML file.") + filled_html = self.template_content # Fallback to raw template if error + + msg_related.attach(MIMEText(filled_html, 'html')) + + # Inline Signature + if Config.SIGNATURE_IMAGE_PATH.exists(): + with open(Config.SIGNATURE_IMAGE_PATH, 'rb') as f: + img = MIMEImage(f.read()) + img.add_header('Content-ID', '') + img.add_header('Content-Disposition', 'inline') + msg_related.attach(img) + else: + logger.warning(f"Signature {Config.SIGNATURE_IMAGE_PATH} not found.") + + # Attachment + part = MIMEBase('application', 'octet-stream') + part.set_payload(cert_data) + encoders.encode_base64(part) + part.add_header('Content-Disposition', f'attachment; filename="{cert_name}"') + msg.attach(part) + + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode() + self.service.users().messages().send(userId="me", body={"raw": raw}).execute() \ No newline at end of file diff --git a/email_template.html b/email_template.html new file mode 100644 index 0000000..3d22e2b --- /dev/null +++ b/email_template.html @@ -0,0 +1,22 @@ + + +

Dear {name},

+ +

+ Thank you for everything you did to help bring CodeQuest 3.0to life. + Your dedication, reliability, and spirit made a real impact, and the event would not have been the same without you. +

+ +

We noticed your effort, we appreciated it, and we genuinely value the energy you brought to the team.

+ +

+ Please find your certificate attached. You have earned it. +

+ +

+ Best regards, +

+
+Signature + + \ No newline at end of file diff --git a/google_auth.py b/google_auth.py new file mode 100644 index 0000000..1bffb09 --- /dev/null +++ b/google_auth.py @@ -0,0 +1,37 @@ +from typing import Tuple + +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build, Resource + +from config import Config + + +class GoogleAuth: + """Handles Google OAuth2 authentication.""" + + @staticmethod + def get_services() -> Tuple[Resource, Resource, Resource]: + creds = None + if Config.TOKEN_FILE.exists(): + creds = Credentials.from_authorized_user_file(str(Config.TOKEN_FILE), Config.SCOPES) + + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + if not Config.CREDENTIALS_FILE.exists(): + raise FileNotFoundError(f"Missing {Config.CREDENTIALS_FILE}") + flow = InstalledAppFlow.from_client_secrets_file(str(Config.CREDENTIALS_FILE), Config.SCOPES) + creds = flow.run_local_server(port=0) + + # Save the credentials for the next run + with open(Config.TOKEN_FILE, "w") as token: + token.write(creds.to_json()) + + return ( + build("gmail", "v1", credentials=creds), + build("sheets", "v4", credentials=creds), + build("drive", "v3", credentials=creds) + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f4e69f0..0f599c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,8 @@ google-auth-oauthlib==1.2.1 google-auth-httplib2==0.2.0 google-api-python-client==2.147.0 Pillow +dotenv + +requests +python-dotenv +pytest \ No newline at end of file diff --git a/send_emails.py b/send_emails.py index 79d686f..5ed0550 100644 --- a/send_emails.py +++ b/send_emails.py @@ -1,146 +1,78 @@ -from __future__ import print_function -import base64 -import os -import time -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from google.auth.transport.requests import Request -from google.oauth2.credentials import Credentials -from google_auth_oauthlib.flow import InstalledAppFlow -from googleapiclient.discovery import build from datetime import datetime +import logging +import time + +from config import Config +from drive_manager import DriveManager +from email_composer import EmailService +from google_auth import GoogleAuth +from sheet_manager import SheetManager -# ----------------------------- CONFIG ----------------------------- -# Put your Google Sheet ID here -SPREADSHEET_ID = "1I2L48ejaRmL1dDm7JmIjv_CX5lqIwjHmMnIJQlIQtw8" - -# Gmail & Sheets API scopes -SCOPES = [ - "https://www.googleapis.com/auth/gmail.send", - "https://www.googleapis.com/auth/spreadsheets", -] - -# Path to your email signature image -SIGNATURE_IMAGE = "signature_mail_rh.png" - -# Feedback form link -FORM_LINK = "https://docs.google.com/forms/d/e/1FAIpQLSexGv_oQi77lppRIEsV6HENuItai31x3UYQFskV2JTQrYqB7Q/viewform?usp=publish-editor" - -# --------------------------- AUTH FUNCTIONS --------------------------- -def authenticate_google_services(): - creds = None - if os.path.exists("token.json"): - creds = Credentials.from_authorized_user_file("token.json", SCOPES) - if not creds or not creds.valid: - if creds and creds.expired and creds.refresh_token: - creds.refresh(Request()) - else: - flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES) - creds = flow.run_local_server(port=0) - with open("token.json", "w") as token: - token.write(creds.to_json()) - gmail_service = build("gmail", "v1", credentials=creds) - sheets_service = build("sheets", "v4", credentials=creds) - return gmail_service, sheets_service - -# --------------------------- SHEETS HELPERS --------------------------- -def normalize_header(h): - return h.strip().replace(" ", "").lower() - -def load_sheet_data(sheets_service, tab_name): - result = sheets_service.spreadsheets().values().get( - spreadsheetId=SPREADSHEET_ID, - range=f"{tab_name}!A:Z" - ).execute() - values = result.get("values", []) - if not values: - return [], [], [] - headers_raw = values[0] - headers = [normalize_header(h) for h in headers_raw] - data_rows = values[1:] - return headers, data_rows, headers_raw - -def get_col_idx(headers, col_name): - norm = normalize_header(col_name) - if norm not in headers: - raise Exception(f"Missing required column: {col_name}") - return headers.index(norm) - -def update_sheet_cell(sheets_service, tab_name, row, col, value): - range_name = f"{tab_name}!{chr(65 + col)}{row}" - body = {"values": [[value]]} - sheets_service.spreadsheets().values().update( - spreadsheetId=SPREADSHEET_ID, - range=range_name, - valueInputOption="RAW", - body=body - ).execute() - -# --------------------------- EMAIL FUNCTIONS --------------------------- -def build_html_email(name, cert_link): - return f""" - - -

Hi {name},

-

Thank you for being part of CodeQuest 3.0, a beginner-friendly adventure where curiosity, - courage, and a little competitive chaos create magic.

-

You’ve completed this edition, and we’re happy to award you your official participation certificate.

-

Certificate:
- {cert_link}

-

We hope this experience helped you learn, grow, and feel more confident in competitive programming. - Every explorer starts somewhere — today, you took a step forward.

-

Feedback Form:
- {FORM_LINK}

-

Once again, congratulations and thank you for journeying down the rabbit hole with us.
- We look forward to your participation in WinterCup.

-

The CodeQuest Team

- - - -""" - -def send_email(gmail_service, recipient, subject, html_body): - msg = MIMEMultipart("alternative") - msg["To"] = recipient - msg["Subject"] = subject - msg.attach(MIMEText(html_body, "html")) - raw_message = base64.urlsafe_b64encode(msg.as_bytes()).decode() - gmail_service.users().messages().send(userId="me", body={"raw": raw_message}).execute() - -# --------------------------- MAIN --------------------------- -def main(): - gmail, sheets = authenticate_google_services() - tab_name = "Recipients" - headers, rows, _ = load_sheet_data(sheets, tab_name) +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%H:%M:%S' +) +logger = logging.getLogger(__name__) +def main(): + logger.info("Starting Batch Mailer...") - email_col = get_col_idx(headers, "email") - name_col = get_col_idx(headers, "name") - cert_col = get_col_idx(headers, "certiflink") - sent_col = get_col_idx(headers, "sent?") - timestamp_col = get_col_idx(headers, "timestamp") + try: + Config.validate() # Ensure env vars are loaded - for i, row in enumerate(rows, start=2): - email = row[email_col].strip() - name = row[name_col].strip() - cert_link = row[cert_col].strip() - sent_status = row[sent_col].strip().lower() if len(row) > sent_col else "" + gmail_svc, sheets_svc, drive_svc = GoogleAuth.get_services() + sheet_manager = SheetManager(sheets_svc, Config.SPREADSHEET_ID) + drive_manager = DriveManager(drive_svc) + email_service = EmailService(gmail_svc) - if sent_status == "yes": - continue + headers, rows = sheet_manager.load_data(Config.TAB_NAME) - html_body = build_html_email(name, cert_link) try: - send_email(gmail, email, "Your CodeQuest 3.0 Certificate is Here!", html_body) - update_sheet_cell(sheets, tab_name, i, sent_col, "Yes") - update_sheet_cell(sheets, tab_name, i, timestamp_col, datetime.now().strftime("%Y-%m-%d %H:%M")) - print(f"Sent email to {email}") - except Exception as e: - print(f"Error sending to {email}: {e}") + col_map = { + 'email': headers.index('email'), + 'name': headers.index('namecapitalized'), + 'cert': headers.index('certifs'), + 'sent': headers.index('sent?'), + 'timestamp': headers.index('timestamp') + } + except ValueError as e: + logger.error(f"Column missing in spreadsheet: {e}") + return + + logger.info(f"Found {len(rows)} rows.") + + for i, row in enumerate(rows): + try: + def get_val(idx): return row[idx].strip() if len(row) > idx else "" + + email = get_val(col_map['email']) + if not email: continue + + if get_val(col_map['sent']).lower() == "yes": + continue + + name = get_val(col_map['name']) + cert_link = get_val(col_map['cert']) + + logger.info(f"Processing: {email}") + + cert_data, cert_name = drive_manager.download_file(cert_link) + email_service.send_email(email, name, cert_data, cert_name) + + sheet_manager.update_cell(Config.TAB_NAME, i, col_map['sent'], "Yes") + sheet_manager.update_cell(Config.TAB_NAME, i, col_map['timestamp'], datetime.now().strftime("%Y-%m-%d %H:%M")) + + logger.info(f"Success: {email}") + time.sleep(1) + + except Exception as e: + logger.error(f"Row {i+2} failed: {e}") - time.sleep(0.5) + except Exception as e: + logger.critical(f"Fatal Error: {e}") - print("All done.") + logger.info("Job Complete.") if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/sheet_manager.py b/sheet_manager.py new file mode 100644 index 0000000..c55cfa9 --- /dev/null +++ b/sheet_manager.py @@ -0,0 +1,46 @@ +from typing import Tuple, List + + +class SheetManager: + """Handles reading and writing to Google Sheets.""" + + def __init__(self, service, spreadsheet_id: str): + self.service = service + self.spreadsheet_id = spreadsheet_id + + def _col_idx_to_letter(self, n: int) -> str: + """Converts 0 -> A, 25 -> Z, 26 -> AA, etc.""" + string = "" + while n >= 0: + string = chr((n % 26) + 65) + string + n = (n // 26) - 1 + return string + + def load_data(self, tab_name: str) -> Tuple[List[str], List[List[str]]]: + """Returns normalized headers and data rows.""" + result = self.service.spreadsheets().values().get( + spreadsheetId=self.spreadsheet_id, + range=f"{tab_name}!A:Z" + ).execute() + + values = result.get("values", []) + if not values: + return [], [] + + headers = [h.strip().replace(" ", "").lower() for h in values[0]] + return headers, values[1:] + + def update_cell(self, tab_name: str, row_idx: int, col_idx: int, value: str): + """Updates a specific cell. row_idx is 0-based index from python list.""" + # Convert 0-based row index to Sheets 1-based index + sheet_row = row_idx + 2 # +1 for 0-index, +1 for header row + col_letter = self._col_idx_to_letter(col_idx) + + range_name = f"{tab_name}!{col_letter}{sheet_row}" + + self.service.spreadsheets().values().update( + spreadsheetId=self.spreadsheet_id, + range=range_name, + valueInputOption="RAW", + body={"values": [[value]]} + ).execute() \ No newline at end of file diff --git a/signature_mail_rh.png b/signature_mail_rh.png index d6389df..7bbd508 100644 Binary files a/signature_mail_rh.png and b/signature_mail_rh.png differ