From 86577b547d04f006fea28414bdc9516a8f748bdd Mon Sep 17 00:00:00 2001 From: Sadeep Madurange Date: Sat, 22 Aug 2026 19:04:42 +0800 Subject: Spaced-repetition mailer script. --- mailer.pl | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 mailer.pl diff --git a/mailer.pl b/mailer.pl new file mode 100644 index 0000000..c6c1af2 --- /dev/null +++ b/mailer.pl @@ -0,0 +1,155 @@ +#!/usr/bin/perl +use strict; +use warnings; +use utf8; +use Time::Piece; +use Sys::Syslog qw(:standard :macros); +use OpenBSD::Pledge; +use OpenBSD::Unveil; + +my $dict_dir = '/var/www/var/lex/dict'; +my $state_file = '/var/www/var/lex/mailer.tsv'; +my $to = 'me@example.com'; +my $from = 'Lex '; +my $subject = 'Daily word'; +my $sendmail = '/usr/sbin/sendmail'; +my $syslog_tag = 'lex-mail'; + +# Connect to /dev/log before pledge/unveil so no late 'unix' +# socket creation occurs +openlog($syslog_tag, 'ndelay,pid', LOG_MAIL); + +unveil($dict_dir, 'r') or log_die("unveil $dict_dir: $!"); +unveil($state_file, 'rwc') or log_die("unveil $state_file: $!"); +unveil($sendmail, 'rx') or log_die("unveil $sendmail: $!"); +unveil('/etc/mailer.conf', 'r') or log_die("unveil /etc/mailer.conf: $!"); +unveil('/var/run/smtpd.sock', 'rw') or log_die("unveil smtpd.sock: $!"); +unveil('/dev/log', 'w') or log_die("unveil /dev/log: $!"); # Writing to pre-opened socket handle +unveil(); # Lock unveil rules + +pledge(qw(stdio rpath wpath cpath proc exec)) or log_die("pledge: $!"); + +sub log_die { + my ($msg) = @_; + syslog(LOG_ERR, "%s", $msg); + closelog(); + die $msg; +} + +# Read dictionary files +if (!opendir(my $dh, $dict_dir)) { + log_die("Cannot open $dict_dir: $!"); +} +my @current_files = grep { -f "$dict_dir/$_" && !/^\./ } readdir($dh); +closedir($dh); + +if (!@current_files) { + syslog(LOG_WARNING, "No files found in %s", $dict_dir); + closelog(); + exit 0; +} + +my %active_files = map { $_ => 1 } @current_files; +my %state; +my $today = localtime->ymd; + +if (-f $state_file) { + if (!open(my $sfh, '<', $state_file)) { + log_die("Cannot open $state_file: $!"); + } + while (my $line = <$sfh>) { + chomp $line; + next if $line =~ /^\s*$/; + my ($file, $next_due, $interval, $reviews, $factor) = split(/\t/, $line); + + if ($file && $active_files{$file}) { + $state{$file} = { + next_due => $next_due // $today, + interval => $interval // 1, + reviews => $reviews // 0, + factor => $factor // 2.5, + }; + } + } + close($sfh); +} + +# Initialize new files not yet in tsv +foreach my $file (@current_files) { + if (!$state{$file}) { + $state{$file} = { + next_due => $today, + interval => 1, + reviews => 0, + factor => 2.5, + }; + } +} + +# Select candidate file (spaced repetition) +my @due_files = grep { $state{$_}{next_due} le $today } @current_files; + +my $selected_file; +if (@due_files) { + $selected_file = $due_files[rand @due_files]; +} else { + @current_files = sort { $state{$a}{next_due} cmp $state{$b}{next_due} } @current_files; + $selected_file = $current_files[0]; + syslog(LOG_INFO, "No files due today; falling back to nearest due file: %s", $selected_file); +} + +# Read content +my $file_path = "$dict_dir/$selected_file"; +if (!open(my $fh, '<:utf8', $file_path)) { + log_die("Cannot open $file_path: $!"); +} +local $/; # slurp mode +my $body = <$fh>; +close($fh); + +# Send email via direct pipe +if (!open(my $mail_pipe, '|-', $sendmail, '-i', '-f', $from, $to)) { + log_die("Cannot execute $sendmail: $!"); +} +binmode($mail_pipe, ':utf8'); + +print $mail_pipe "From: $from\n"; +print $mail_pipe "To: $to\n"; +print $mail_pipe "Subject: $subject - $selected_file\n"; +print $mail_pipe "Content-Type: text/plain; charset=UTF-8\n\n"; +print $mail_pipe $body; + +if (!close($mail_pipe)) { + log_die("Error closing pipe to sendmail: $!"); +} + +# Update schedule +my $item = $state{$selected_file}; +my $interval = $item->{interval}; +my $factor = $item->{factor}; + +if ($item->{reviews} == 0) { + $interval = 1; +} elsif ($item->{reviews} == 1) { + $interval = 6; +} else { + $interval = int($interval * $factor); +} + +$item->{interval} = $interval; +$item->{reviews} += 1; + +my $now = localtime; +$item->{next_due} = ($now + ($interval * 86400))->ymd; + +if (!open(my $out_fh, '>', $state_file)) { + log_die("Cannot write to $state_file: $!"); +} +foreach my $file (keys %state) { + my $i = $state{$file}; + print $out_fh join("\t", $file, $i->{next_due}, $i->{interval}, $i->{reviews}, $i->{factor}), "\n"; +} +close($out_fh); + +syslog(LOG_INFO, "Mailed %s to %s (next due: %s)", $selected_file, $to, $item->{next_due}); +closelog(); -- cgit v1.2.3