#!/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@example.com'; my $from_header = "Lex <$from>"; my $subject = 'Daily word'; my $sendmail = '/usr/sbin/sendmail'; my $syslog_tag = 'lex-mail'; my $max_interval = 365; # Upper bound limit (1 year) # 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 my $dh; if (!opendir($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); # Truncate state file if it exists if (-f $state_file) { if (open(my $clear_fh, '>', $state_file)) { close($clear_fh); } else { syslog(LOG_ERR, "Failed to clear %s: %s", $state_file, $!); } } closelog(); exit 0; } my %active_files = map { $_ => 1 } @current_files; my %state; my $today = localtime->ymd; if (-f $state_file) { my $sfh; if (!open($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 + fair scheduling) my @due_files = grep { $state{$_}{next_due} le $today } @current_files; my $selected_file; if (@due_files == 1) { $selected_file = $due_files[0]; } elsif (@due_files > 1) { # Weighted random choice: files with fewer reviews get higher probability weights my %weights; my $total_weight = 0; foreach my $file (@due_files) { # Inversely proportional weight (1 / (reviews + 1)) my $w = 1.0 / ($state{$file}{reviews} + 1); $weights{$file} = $w; $total_weight += $w; } my $rand_point = rand($total_weight); my $accum = 0; foreach my $file (@due_files) { $accum += $weights{$file}; if ($rand_point <= $accum) { $selected_file = $file; last; } } $selected_file //= $due_files[-1]; # Fallback } 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"; my $fh; if (!open($fh, '<:utf8', $file_path)) { log_die("Cannot open $file_path: $!"); } local $/; # slurp mode my $body = <$fh>; close($fh); # Send email via direct pipe my $mail_pipe; if (!open($mail_pipe, '|-', $sendmail, '-i', '-f', $from, $to)) { log_die("Cannot execute $sendmail: $!"); } binmode($mail_pipe, ':utf8'); print $mail_pipe "From: $from_header\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); } # Cap interval if ($interval > $max_interval) { $interval = $max_interval; } $item->{interval} = $interval; $item->{reviews} += 1; my $now = localtime; $item->{next_due} = ($now + ($interval * 86400))->ymd; # Toss deleted files from the tsv. foreach my $file (keys %state) { delete $state{$file} unless $active_files{$file}; } # Atomic write using a temporary state file my $tmp_file = "$state_file.tmp"; my $out_fh; if (!open($out_fh, '>', $tmp_file)) { log_die("Cannot write to $tmp_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) or log_die("Error closing $tmp_file: $!"); if (!rename($tmp_file, $state_file)) { log_die("Cannot atomic swap $tmp_file to $state_file: $!"); } syslog(LOG_INFO, "Mailed %s to %s (next due: %s)", $selected_file, $to, $item->{next_due}); closelog();