#!/usr/bin/perl -w # Copyright (C) 2013, Eric Wong and all contributors # License: AGPLv3 or later (https://www.gnu.org/licenses/agpl-3.0.txt) # This is the normal command-line client for users use strict; use warnings; use Getopt::Long; use Ssoma::Git; use Ssoma::Extractor; use File::Path::Expand qw/expand_filename/; use File::Path qw/make_path/; use File::Temp qw/tempfile/; use File::Spec qw//; use Email::LocalDelivery; use constant CRON_RAND_DELAY => 60; # adjust as necessary Getopt::Long::Configure("require_order", "pass_through"); our %opts; GetOptions( "help|h" => \$opts{help}, "quiet|q" => \$opts{quiet}, "force|f" => \$opts{force}, ) or usage(1); $ENV{SSOMA_HOME} ||= expand_filename("~/.ssoma"); # these expand automatically to the associated cmd_$name, so "add" # calls cmd_add, "sync" calls cmd_sync, and so forth our %cmd = ( "add" => { doc => "start watching a new list", arg => "LISTNAME URL TYPE:/path/to/destination [TARGET]", long => "TYPE must be one of 'maildir', 'mbox', 'imap' ". "or 'command'", }, "sync" => { doc => "sync target(s) for existing LISTNAME", arg => "[LISTNAME] [TARGET]", opt => { cron => \$opts{cron}, 'since=s' => \$opts{since}, 'after=s' => \$opts{since}, } }, "cat" => { doc => "show a message by Message-ID", arg => "MESSAGE-ID [LISTNAME|GIT_DIR]", }, ); my $cmd = shift @ARGV; usage("", 1) unless defined $cmd; $cmd eq "help" and usage("", 0); $cmd{$cmd} or usage("", 1); my $cmd_sub = eval { no strict 'refs'; *{"cmd_$cmd"}; } or die "BUG: $cmd not implemented\n"; if (my $opt = $cmd{$cmd}->{opt}) { GetOptions(%$opt) or usage(1); } $cmd_sub->(@ARGV); exit 0; sub usage { my ($cmd, $exit) = @_; my $fd = $exit ? \*STDERR : \*STDOUT; print $fd "Usage: ssoma [opts] [command-opts] [args]\n"; print $fd "Available commands:\n" unless $cmd; foreach my $c (sort keys %cmd) { next if $cmd && $cmd ne $c; my $pad = 'A10'; print $fd ' ', pack($pad, $c), $cmd{$c}->{doc}, "\n"; print $fd ' ', pack($pad, ''), $cmd{$c}->{arg}, "\n"; my $long = $cmd{$c}->{long}; if ($long) { print $fd ' ', pack($pad, ''), $long, "\n"; } my $opt = $cmd{$c}->{opt} or next; foreach (sort keys %$opt) { # prints out arguments as they should be passed: my $x = s#[:=]s$## ? '' : (s#[:=]i$## ? '' : ''); print $fd ' ' x 14, join(', ', map { length $_ > 1 ? "--$_" : "-$_" } split /\|/, $_)," $x\n"; } } exit $exit; } sub check_listname { my ($name) = @_; $name =~ /\A[a-zA-Z0-9]/ or die "LISTNAME must start with an alphanumeric char\n"; $name =~ /[a-zA-Z0-9]\z/ or die "LISTNAME must end with an alphanumeric char\n"; $name =~ /\A[\w\.\-]+\z/ or die "LISTNAME must only contain alphanumerics, dashes, periods and underscores\n"; } sub cmd_add { my ($listname, $url, $dest, $target) = @_; (defined($url) && defined($listname) && defined($dest)) or usage("add", 1); check_listname($listname); $dest =~ /\A(mbox|maildir|command|imaps?):(.+)\z/ or die usage("add", 1); my ($type, $path) = ($1, $2); my $imap; if ($type =~ /\Aimaps?\z/) { $imap = 1; } else { $path = File::Spec->rel2abs($path); } # Email::LocalDelivery relies on this trailing slash for # maildir distinction if (($type eq "maildir") && ($path !~ m!/\z!)) { $path .= "/"; } elsif (($type eq "mbox") && ($path =~ m!/\z!)) { die "mbox `$path' must not end with a trailing slash\n"; } $target = "local" unless defined $target; my $dir = "$ENV{SSOMA_HOME}/$listname.git"; make_path($ENV{SSOMA_HOME}); my $git = Ssoma::Git->new($dir); my @init_args; push @init_args, '-q' if $opts{quiet}; $git->init_db(@init_args); my $state = "$git->{git_dir}/ssoma.state"; if ($imap) { local $ENV{GIT_CONFIG} = "$git->{git_dir}/config"; require URI; # no imap:// support in URI, yet, but URI has ftp:// # for passwords my $uri = $dest; $uri =~ s{\A(imaps?):}{ftp:}; my $scheme = $1; my $u = URI->new($uri); $u->scheme or die "no scheme from $dest\n"; defined(my $host = $u->host) or die "no host from $dest\n"; my $port = $u->_port; x(qw/git config imap.port/, $port) if (defined $port); x(qw/git config imap.host/, "$scheme://$host"); defined(my $user = $u->user) or die "no user in $dest\n";; x(qw/git config imap.user/, $user); my $path = $u->path; defined $path or $path = "INBOX"; $path =~ s!\A/!!; # no leading slash x(qw/git config imap.folder/, $path); warn_imap_pass($u->password, $ENV{GIT_CONFIG}); # this only needs to be set for Extractor to follow local $ENV{GIT_CONFIG} = $state; x(qw/git config/, "target.$target.imap", "true"); } else { local $ENV{GIT_CONFIG} = $state; my $cfg = $type eq "command" ? "command" : "path"; x(qw/git config/, "target.$target.$cfg", $path); } $git->sync_do(sub { $git->tmp_git_do(sub { x(qw/git remote add --mirror=fetch origin/, $url); }); }); } sub foreach_list { my ($sub) = @_; foreach my $dir (glob("$ENV{SSOMA_HOME}/*.git")) { -d $dir or next; $sub->($dir); } } sub cmd_sync { my ($listname, @targets) = @_; if ($opts{cron}) { $opts{quiet} = 1; sleep(rand(CRON_RAND_DELAY)); } if (defined $listname) { check_listname($listname); do_sync("$ENV{SSOMA_HOME}/$listname.git", \@targets); } else { foreach_list(sub { do_sync($_[0], []) }); } } sub cmd_cat { my ($message_id, $listname) = @_; # write to a temporary mbox because Email::LocalDelivery works # that way. my ($fh, $mbox) = tempfile(TMPDIR => 1, SUFFIX => '.mbox'); if (defined $listname) { my $path = -d $listname ? $listname : "$ENV{SSOMA_HOME}/$listname.git"; do_cat($path, $message_id, $mbox); } else { foreach_list(sub { do_cat($_[0], $message_id, $mbox, 1) }); } unlink $mbox or warn "error unlinking $mbox: $!\n"; foreach (<$fh>) { print $_ or warn "failed printing to stdout: $!\n"; } close $fh or die "error closing $mbox: $!\n"; } sub do_sync { my ($dir, $targets) = @_; my $git = Ssoma::Git->new($dir); my $ex = Ssoma::Extractor->new($git); my $since = $opts{since}; # no targets? sync all of them if (scalar(@$targets) == 0) { my $cfg = $git->config_list("$git->{git_dir}/ssoma.state"); my %t; foreach my $k (keys %$cfg) { $k =~ /\Atarget\.(\w+)\.(?:path|imap|command)\z/ or next; $t{$1} = 1; } @$targets = keys %t; } $git->sync_do(sub { $git->tmp_git_do(sub { my @cmd = qw/git fetch/; push @cmd, '-q' if $opts{quiet}; push @cmd, '-f' if $opts{force}; x(@cmd); }); }); foreach my $target (@$targets) { $ex->extract($target, $since); } } sub x { system(@_) and die join(' ', @_). " failed: $?\n"; } sub warn_imap_pass { my ($pass, $file) = @_; if (defined $pass && length $pass) { print STDERR <new($dir); my $ex = Ssoma::Extractor->new($git); $ex->midextract($message_id, $mbox, $missing_ok); }