Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -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}")
44 changes: 44 additions & 0 deletions drive_manager.py
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions email_composer.py
Original file line number Diff line number Diff line change
@@ -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', '<sig_img>')
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()
22 changes: 22 additions & 0 deletions email_template.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<html>
<body style="font-family: Arial, sans-serif; color: #333; line-height: 1.6;">
<p>Dear {name},</p>

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

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

<p>
<strong>Please find your certificate attached. You have earned it.</strong>
</p>

<p>
Best regards,
</p>
<br>
<img src="cid:sig_img" alt="Signature" style="width:100%; max-width:500px;">
</body>
</html>
37 changes: 37 additions & 0 deletions google_auth.py
Original file line number Diff line number Diff line change
@@ -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)
)
5 changes: 5 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading