Skip to content

Automated emails functionality #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions .env → .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
DATABASE_URL=../attendance-s25.db
SMTP_PASSWORD=your-smtp-password
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
target/
Cargo.lock
.env
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ edition = "2024"
[dependencies]
chrono = "0.4.40"
clap = { version = "4.5.32", features = ["derive"] }
config = "0.15.11"
csv = "1.3.1"
diesel = { version = "2.2.0", features = ["sqlite", "chrono", "returning_clauses_for_sqlite_3_35"] }
dotenvy = "0.15"
lettre = "0.11.15"
serde = { version = "1.0.219", features = ["derive"] }
tabled = "0.18.0"
8 changes: 8 additions & 0 deletions config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[smtp]
sender = "[email protected]"
cc = "[email protected],[email protected],[email protected],[email protected]"
email_subject = "98008 Unexcused Absence"
email_body_path = "./examples/email_body.html"

[setup]
roster_path = "./examples/roster.csv"
10 changes: 10 additions & 0 deletions examples/email_body.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<style>
</style>
<p>Hello!</p>
<p>If you're reading this, our records show that you have an unexcused absence for our most recent class! If you think this is a mistake, no worries, please reply to this email and we'll sort it out together.</p>
<p>If you did miss class, please reply so we can work together to get you back on track. Whether you're seeking help with course material or navigating personal challenges, we're here to support your success!<p>
<p>Best,</p>
<p>98008 Staff 🦀</p>
</html>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to use the email builder instead to build these emails? We should aim to be telling people how many times they have been absent, for example if they are at 2 absences then that should be a different message.

https://docs.rs/lettre/latest/lettre/transport/index.html

Copy link
Member Author

@jespiron jespiron Mar 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I previously used lettre, but the emails weren't sending, so I switched back to raw SMTP.
I can integrate lettre with enough plumbing, but I don't have bandwidth

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I introduced an email mode. Now we can send different templates with custom filtering functions based on the desired mode
See PR description for usage

3 changes: 3 additions & 0 deletions examples/roster.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"Semester","Course","Section","Lecture","Mini","Last Name","Preferred/First Name","MI","Andrew ID","Email","College","Department","Major","Class","Graduation Semester","Units","Grade Option","QPA Scale","Mid-Semester Grade","Primary Advisor","Final Grade","Default Grade","Time Zone Code","Time Zone Description","Added By","Added On","Confirmed","Waitlist Position","Units Carried/Max Units","Waitlisted By","Waitlisted On","Dropped By","Dropped On","Roster As Of Date"
"S25","98008","A","Y","N","The Parallel Cow","Clarabelle","I","idigestinparallel","[email protected]","CIT","ECE","ECE","3","S26","3.0","P","4","P","Clarabelle"," ","","","","idigestinparallel","20 Nov 2024","Y","","","","","","","21 Mar 2025 10:47 PM"
"S25","98008","A","Y","N","The Polymorphic Parrot","Polly","M","polymorphicparrot","[email protected]","SCS","CS","","3","","3.0","P","4","P","Polly"," ","","","","polymorphicparrot","22 Nov 2024","Y","","","","","","","21 Mar 2025 10:47 PM"
43 changes: 37 additions & 6 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,34 @@ use diesel::QueryResult;
use std::path::Path;

pub mod display;
pub mod mailer;
pub mod manager;
pub mod models;
pub mod schema;

use manager::AttendanceManager;
use models::Student;

/// The path to the roster of students.
///
/// We hardcode this since this should only change once per semester.
const ROSTER_PATH: &str = "../roster-s25.csv";
use config::Config;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct AppConfig {
setup: SetupDetails,
}

#[derive(Debug, Deserialize)]
struct SetupDetails {
roster_path: String,
}

fn load_config() -> Result<SetupDetails, config::ConfigError> {
let settings = Config::builder()
.add_source(config::File::with_name("config"))
.build()?;
let app_config: AppConfig = settings.try_deserialize()?;
Ok(app_config.setup)
}

/// The date of the first day of attendance.
///
Expand Down Expand Up @@ -68,7 +85,14 @@ pub fn setup() -> QueryResult<()> {
let _ = manager.delete_roster();

// Insert the students from the given roster.
let new_roster = download_roster(ROSTER_PATH);
let config = match load_config() {
Ok(config) => config,
Err(e) => {
eprintln!("Failed to load configuration: {}", e);
return Err(diesel::result::Error::RollbackTransaction);
}
};
let new_roster = download_roster(config.roster_path);
manager.insert_students(&new_roster)?;

let roster = manager.get_roster()?;
Expand All @@ -90,7 +114,14 @@ pub fn update_roster() -> QueryResult<()> {
let mut manager = AttendanceManager::connect();

// Insert the students from the given roster.
let new_roster = download_roster(ROSTER_PATH);
let config = match load_config() {
Ok(config) => config,
Err(e) => {
eprintln!("Failed to load configuration: {}", e);
return Err(diesel::result::Error::RollbackTransaction);
}
};
let new_roster = download_roster(config.roster_path);

let curr_roster = manager.get_roster()?;

Expand Down
223 changes: 223 additions & 0 deletions src/mailer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
use crate::manager::AttendanceManager;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use config::Config;
use diesel::QueryResult;
use dotenv::dotenv;
use native_tls::TlsConnector;
use serde::Deserialize;
use std::fs;
use std::net::TcpStream;
use std::time::Duration;
use std::{
env,
io::{self, Read, Write},
};

#[derive(Debug, Deserialize)]
struct SmtpConfig {
smtp: SmtpDetails,
}

#[derive(Debug, Deserialize)]
struct SmtpDetails {
sender: String,
cc: String,
email_subject: String,
email_body_path: String,
}

fn load_config() -> Result<SmtpDetails, Box<dyn std::error::Error>> {
// Load from config.toml
let settings = Config::builder()
.add_source(config::File::with_name("config"))
.build()?;
let smtp_config: SmtpConfig = settings.try_deserialize()?;

Ok(smtp_config.smtp)
}

fn parse_recipients(recipients: &str) -> Vec<String> {
recipients
.split(',')
.map(|s| s.trim().to_string())
.collect()
}

fn read_email_body(file_path: &str) -> Result<String, Box<dyn std::error::Error>> {
let body = fs::read_to_string(file_path)?;
Ok(body)
}

pub fn send_mail(recipients: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let config = load_config()?;
dotenv().ok();

// Get password from environment variable
let password = env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD must be set");
// Create all_recipients vector including CC
let mut all_recipients = recipients.to_vec();
all_recipients.extend(parse_recipients(&config.cc));
println!("{:?}", all_recipients);

// Connect to the SMTP server (e.g., Gmail's SMTP server)
println!("here");
let mut stream = TcpStream::connect("smtp.gmail.com:587")?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;

// Read the server's welcome message
let mut response = [0; 512];
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Send EHLO command
stream.write_all(b"EHLO example.com\r\n")?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Send STARTTLS command
stream.write_all(b"STARTTLS\r\n")?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Upgrade the connection to TLS
let connector = TlsConnector::new()?;
let mut stream = connector.connect("smtp.gmail.com", stream)?;

// Re-send EHLO after STARTTLS
stream.write_all(b"EHLO example.com\r\n")?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Authenticate using AUTH LOGIN
stream.write_all(b"AUTH LOGIN\r\n")?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Send base64-encoded username
let username = BASE64.encode(&config.sender);
stream.write_all(format!("{}\r\n", username).as_bytes())?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Send base64-encoded password
let password_encoded = BASE64.encode(&password);
stream.write_all(format!("{}\r\n", password_encoded).as_bytes())?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Send MAIL FROM command
stream.write_all(format!("MAIL FROM:<{}>\r\n", config.sender).as_bytes())?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Send RCPT TO commands for all recipients
for recipient in all_recipients {
stream.write_all(format!("RCPT TO:<{}>\r\n", recipient).as_bytes())?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));
}

// Send DATA command
stream.write_all(b"DATA\r\n")?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Send email headers and body
let email_body = read_email_body(&config.email_body_path)?;
let email_headers = format!(
"From: {}\r\n\
To: undisclosed-recipients\r\n\
CC: {}\r\n\
Subject: {}\r\n\
Content-Type: text/html; charset=UTF-8\r\n\
\r\n",
config.sender, config.cc, config.email_subject
);

stream.write_all(email_headers.as_bytes())?;
stream.write_all(email_body.as_bytes())?;
stream.write_all(b"\r\n.\r\n")?; // End of email
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

// Send QUIT command
stream.write_all(b"QUIT\r\n")?;
stream.read(&mut response)?;
println!("Server: {}", String::from_utf8_lossy(&response));

Ok(())
}

/// Emails students who have more than the specified number of absences after a given week.
pub fn email_absentees(after_week: i32, min_absences: i32) -> QueryResult<()> {
let mut manager = AttendanceManager::connect();
let roster = manager.get_roster()?;

// Get all attendance records in one batch using transaction
let mut absentees = Vec::new();
let mut recipient_emails = Vec::new();
for student in roster {
let attendance = manager.get_student_attendance(&student.id)?;
let absences = attendance
.absent
.iter()
.filter(|(week, _)| *week >= after_week)
.count();

if absences >= min_absences as usize {
recipient_emails.push(student.email.clone());
absentees.push((student, absences));
}
}

// Display results
println!(
"\nStudents with {} or more absences after week {}:",
min_absences, after_week
);
println!("{:<30} {:<30} {:<10}", "Name", "Email", "Absences");
println!("{}", "-".repeat(70));

for (student, absences) in &absentees {
println!(
"{:<30} {:<30} {:<10}",
format!("{} {}", student.first_name, student.last_name),
student.email,
absences
);
}

if absentees.is_empty() {
println!(
"\nNo students found with {} or more absences after week {}.",
min_absences, after_week
);
return Ok(());
}

// Ask for confirmation
print!("\nWould you like to email these students? [y/N] ");
if let Err(e) = io::stdout().flush() {
eprintln!("Error flushing stdout: {}", e);
return Ok(());
}

let mut input = String::new();
if let Err(e) = io::stdin().read_line(&mut input) {
eprintln!("Error reading input: {}", e);
return Ok(());
}

if input.trim().to_lowercase() != "y" {
println!("Operation cancelled.");
return Ok(());
}

// Send emails
println!("\nSending emails...");
send_mail(&recipient_emails);
println!("Done");

Ok(())
}
11 changes: 11 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ enum Command {
StudentInfo { id: String },
/// Actions to perform specific to a given week.
Week(WeekArgs),
/// Email students with excessive absences after a given week.
EmailAbsentees {
/// The week to start checking from (inclusive)
after_week: i32,
/// The minimum number of absences to trigger an email
min_absences: i32,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should want to email absentees for a specific week (not every person who has been absent after week 4), so this should be under the week args

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I introduced an email mode. Now we can send different templates with custom filtering functions based on the desired mode
See PR description for usage

},
}

/// The command-line arguments for doing actions given a specific week.
Expand Down Expand Up @@ -70,6 +77,10 @@ fn main() -> QueryResult<()> {
Command::Absences { after_week } => attendance::display::show_absences(after_week),
Command::StudentInfo { id } => attendance::display::show_student_info(&id),
Command::Week(week_args) => run_week_command(week_args),
Command::EmailAbsentees {
after_week,
min_absences,
} => attendance::mailer::email_absentees(after_week, min_absences),
}
}

Expand Down