-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from 6 commits
cc17d10
88ba705
390c886
ad1dc96
19cd88c
deb0c00
25bbb52
967f31b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
DATABASE_URL=../attendance-s25.db | ||
SMTP_PASSWORD=your-smtp-password |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
target/ | ||
Cargo.lock | ||
.env |
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" |
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> | ||
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" |
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(()) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
}, | ||
} | ||
|
||
/// The command-line arguments for doing actions given a specific week. | ||
|
@@ -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), | ||
} | ||
} | ||
|
||
|
There was a problem hiding this comment.
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
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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