reminder_app.py•12.8 kB
# reminder_app.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import datetime
import os
from dotenv import load_dotenv
import gspread
from oauth2client.service_account import ServiceAccountCredentials
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.executors.pool import ThreadPoolExecutor
import atexit
import sys
import logging
# Configure logging to file instead of stdout (for MCP compatibility)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('reminder_app.log'),
# Remove StreamHandler to avoid stdout pollution in MCP mode
]
)
logger = logging.getLogger(__name__)
class Reminder:
"""
Represents a single customer reminder with its context and actions.
This acts as your 'Model Context Protocol' for reminder data.
"""
def __init__(self, customer_name, customer_email, message_template,
due_date, reminder_type="email", customer_phone=None):
self.customer_name = customer_name
self.customer_email = customer_email
self.message_template = message_template # The base message for the reminder
self.due_date = due_date
self.reminder_type = reminder_type
self.customer_phone = customer_phone
self.message_content = self._generate_message_content()
def _generate_message_content(self):
"""
Generates the specific message content based on the template and customer data.
This is where your 'contextual logic' comes in.
You can customize this further (e.g., add personalization, product details).
"""
# Example: Replace placeholders in the template
content = self.message_template.replace(
"{customer_name}", self.customer_name)
content = content.replace(
"{due_date}", self.due_date.strftime("%Y-%m-%d %H:%M"))
return content
def get_formatted_email(self, sender_email, sender_name="Preshify"):
"""
Formats the reminder into an email message object.
"""
msg = MIMEMultipart()
msg['From'] = f"{sender_name} <{sender_email}>"
msg['To'] = self.customer_email
msg['Subject'] = f"Reminder for {self.customer_name} - {self.due_date.strftime('%b %d')}"
msg.attach(MIMEText(self.message_content, 'plain'))
return msg
class GoogleSheetsReader:
"""
Handles reading reminder data from Google Sheets.
"""
def __init__(self, credentials_file, spreadsheet_name):
self.credentials_file = credentials_file
self.spreadsheet_name = spreadsheet_name
self.client = None
self.sheet = None
self._authenticate()
def _authenticate(self):
"""Authenticate with Google Sheets API."""
try:
scope = ['https://spreadsheets.google.com/feeds',
'https://www.googleapis.com/auth/drive']
creds = ServiceAccountCredentials.from_json_keyfile_name(
self.credentials_file, scope)
self.client = gspread.authorize(creds)
self.sheet = self.client.open(self.spreadsheet_name).sheet1
logger.info(
f"Successfully connected to Google Sheet: {self.spreadsheet_name}")
except Exception as e:
logger.error(f"Error connecting to Google Sheets: {e}")
self.client = None
self.sheet = None
def get_reminders_data(self):
"""
Read reminder data from Google Sheets.
Expected columns: customer_name, customer_email, message_template, due_date, reminder_type
"""
if not self.sheet:
logger.warning(
"Google Sheets not connected. Returning empty list.")
return []
try:
# Get all records as list of dictionaries
records = self.sheet.get_all_records()
reminders_data = []
today = datetime.datetime.now().date()
for record in records:
try:
due_date_str = record.get('due_date', '')
if not due_date_str:
continue
# Try different date formats
for date_format in ['%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y-%m-%d %H:%M', '%m/%d/%Y %H:%M', '%d/%m/%Y %H:%M']:
try:
due_date = datetime.datetime.strptime(
due_date_str, date_format)
break
except ValueError:
continue
else:
logger.warning(f"Could not parse date: {due_date_str}")
continue
# Check if reminder is due (today or overdue)
if due_date.date() <= today:
reminder_data = {
'customer_name': record.get('customer_name', ''),
'customer_email': record.get('customer_email', ''),
'message_template': record.get('message_template',
'Hi {customer_name}! This is a reminder for {due_date}.'),
'due_date': due_date,
'reminder_type': record.get('reminder_type', 'email')
}
# Only add if required fields are present
if reminder_data['customer_name'] and reminder_data['customer_email']:
reminders_data.append(reminder_data)
except Exception as e:
logger.error(
f"Error processing record: {record}, Error: {e}")
continue
logger.info(f"Read {len(records)} records from the sheet")
logger.info(f"Found {len(reminders_data)} reminders due for today")
return reminders_data
except Exception as e:
logger.error(f"Error reading from Google Sheets: {e}")
return []
class ReminderSender:
"""
Handles sending reminders via email.
"""
def __init__(self, smtp_server=None, smtp_port=None, smtp_username=None, smtp_password=None):
self.smtp_server = smtp_server
self.smtp_port = smtp_port
self.smtp_username = smtp_username
self.smtp_password = smtp_password
def send_email_reminder(self, reminder: Reminder):
"""Sends an email reminder."""
if not self.smtp_server or not self.smtp_username or not self.smtp_password:
logger.warning("Email sender not configured. Skipping email.")
return
try:
msg = reminder.get_formatted_email(self.smtp_username)
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
server.starttls() # Secure the connection
server.login(self.smtp_username, self.smtp_password)
server.sendmail(self.smtp_username,
reminder.customer_email, msg.as_string())
logger.info(
f"Email reminder sent to {reminder.customer_email} for {reminder.customer_name}")
except Exception as e:
logger.error(
f"Error sending email to {reminder.customer_email}: {e}")
def send_reminder(self, reminder: Reminder):
"""Dispatches the reminder (only email supported)."""
if reminder.reminder_type == "email":
self.send_email_reminder(reminder)
else:
logger.warning(
f"Unsupported reminder type: {reminder.reminder_type}")
# === STANDALONE FUNCTIONS (NO CLASS DEPENDENCIES) ===
# This prevents APScheduler serialization issues
def load_sent_reminders():
"""Load list of already sent reminders from file"""
sent_file = "sent_reminders.txt" # todo: replace with SQL database
sent_reminders = set()
if os.path.exists(sent_file):
try:
with open(sent_file, 'r') as f:
for line in f:
sent_reminders.add(line.strip())
except Exception as e:
logger.error(f"Error loading sent reminders: {e}")
return sent_reminders
def save_sent_reminder(reminder_id):
"""Save a reminder ID to the sent reminders file"""
sent_file = "sent_reminders.txt"
try:
with open(sent_file, 'a') as f:
f.write(f"{reminder_id}\n")
except Exception as e:
logger.error(f"Error saving sent reminder: {e}")
def generate_reminder_id(customer_email, due_date):
"""Generate a unique ID for a reminder based on email and due date"""
date_str = due_date.strftime("%Y-%m-%d")
return f"{customer_email}_{date_str}"
def check_and_send_reminders():
"""Standalone function to run scheduled reminder checks - NO class dependencies"""
logger.info(
f"=== Reminder Check - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ===")
try:
load_dotenv()
EMAIL_HOST = os.getenv("EMAIL_HOST")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", 587))
EMAIL_USERNAME = os.getenv("EMAIL_USERNAME")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")
GOOGLE_CREDENTIALS_FILE = os.getenv(
"GOOGLE_CREDENTIALS_FILE", "credentials.json")
GOOGLE_SPREADSHEET_NAME = os.getenv(
"GOOGLE_SPREADSHEET_NAME", "Customer_reminder")
# Create fresh instances each time (no shared state)
sender = ReminderSender(
smtp_server=EMAIL_HOST,
smtp_port=EMAIL_PORT,
smtp_username=EMAIL_USERNAME,
smtp_password=EMAIL_PASSWORD
)
sheets_reader = GoogleSheetsReader(
GOOGLE_CREDENTIALS_FILE, GOOGLE_SPREADSHEET_NAME)
reminders_data = sheets_reader.get_reminders_data()
if not reminders_data:
logger.info("No reminders due at this time.")
return
# Load already sent reminders
sent_reminders = load_sent_reminders()
# Filter out already sent reminders
new_reminders = []
for data in reminders_data:
reminder_id = generate_reminder_id(
data["customer_email"], data["due_date"])
if reminder_id not in sent_reminders:
new_reminders.append(data)
else:
logger.info(
f"Reminder already sent to {data['customer_email']} for {data['due_date'].strftime('%Y-%m-%d')} - skipping")
if not new_reminders:
logger.info("All due reminders have already been sent.")
return
# Process and send new reminders
logger.info(f"Processing {len(new_reminders)} new reminders...")
sent_count = 0
for data in new_reminders:
reminder = Reminder(
customer_name=data["customer_name"],
customer_email=data["customer_email"],
message_template=data["message_template"],
due_date=data["due_date"],
reminder_type=data["reminder_type"]
)
sender.send_reminder(reminder)
# Mark as sent
reminder_id = generate_reminder_id(
data["customer_email"], data["due_date"])
save_sent_reminder(reminder_id)
sent_count += 1
logger.info(f"Successfully sent {sent_count} new reminders.")
except Exception as e:
logger.error(f"Error during reminder check: {e}")
import traceback
logger.error(traceback.format_exc())
def start_scheduler_service(interval_minutes=6):
"""Start the APScheduler service - standalone function"""
# Configure job stores, executors and job defaults
jobstores = {
'default': SQLAlchemyJobStore(url='sqlite:///reminder_jobs.sqlite')
}
executors = {
'default': ThreadPoolExecutor(10),
}
job_defaults = {
'coalesce': False,
'max_instances': 1
}
scheduler = BackgroundScheduler(
jobstores=jobstores,
executors=executors,
job_defaults=job_defaults
)
# Schedule interval-based reminders (every X minutes)
scheduler.add_job(
func=check_and_send_reminders, # Use standalone function
trigger="interval",
minutes=interval_minutes,
id='interval_reminders',
replace_existing=True
)
# Run immediately once on