credential-libsecret: unlock locked secrets
[gitweb.git] / git-send-email.perl
index ae9f8698c5a4842c2c0d63db51dde067ddeeb1c9..da81be40cb7f9af1a960be2c22f8e75c9d16822e 100755 (executable)
 use 5.008;
 use strict;
 use warnings;
+use POSIX qw/strftime/;
 use Term::ReadLine;
 use Getopt::Long;
 use Text::ParseWords;
-use Data::Dumper;
 use Term::ANSIColor;
 use File::Temp qw/ tempdir tempfile /;
 use File::Spec::Functions qw(catfile);
@@ -46,6 +46,7 @@ package main;
 sub usage {
        print <<EOT;
 git send-email [options] <file | directory | rev-list options >
+git send-email --dump-aliases
 
   Composing:
     --from                  <str>  * Email From:
@@ -75,6 +76,8 @@ sub usage {
                                      Pass an empty string to disable certificate
                                      verification.
     --smtp-domain           <str>  * The domain name sent to HELO/EHLO handshake
+    --smtp-auth             <str>  * Space-separated list of allowed AUTH mechanisms.
+                                     This setting forces to use one of the listed mechanisms.
     --smtp-debug            <0|1>  * Disable, enable Net::SMTP debug.
 
   Automating:
@@ -99,6 +102,9 @@ sub usage {
                                      `git format-patch` ones.
     --force                        * Send even if safety checks would prevent it.
 
+  Information:
+    --dump-aliases                 * Dump configured aliases and exit.
+
 EOT
        exit(1);
 }
@@ -178,6 +184,7 @@ sub format_2822_time {
 my $format_patch;
 my $compose_filename;
 my $force = 0;
+my $dump_aliases = 0;
 
 # Handle interactive edition of files.
 my $multiedit;
@@ -208,7 +215,7 @@ sub do_edit {
 my ($to_cmd, $cc_cmd);
 my ($smtp_server, $smtp_server_port, @smtp_server_options);
 my ($smtp_authuser, $smtp_encryption, $smtp_ssl_cert_path);
-my ($identity, $aliasfiletype, @alias_files, $smtp_domain);
+my ($identity, $aliasfiletype, @alias_files, $smtp_domain, $smtp_auth);
 my ($validate, $confirm);
 my (@suppress_cc);
 my ($auto_8bit_encoding);
@@ -237,8 +244,8 @@ sub do_edit {
     "smtpserveroption" => \@smtp_server_options,
     "smtpuser" => \$smtp_authuser,
     "smtppass" => \$smtp_authpass,
-    "smtpsslcertpath" => \$smtp_ssl_cert_path,
     "smtpdomain" => \$smtp_domain,
+    "smtpauth" => \$smtp_auth,
     "to" => \@initial_to,
     "tocmd" => \$to_cmd,
     "cc" => \@initial_cc,
@@ -256,6 +263,7 @@ sub do_edit {
 
 my %config_path_settings = (
     "aliasesfile" => \@alias_files,
+    "smtpsslcertpath" => \$smtp_ssl_cert_path,
 );
 
 # Handle Uncouth Termination
@@ -288,6 +296,11 @@ sub signal_handler {
 
 my $help;
 my $rc = GetOptions("h" => \$help,
+                    "dump-aliases" => \$dump_aliases);
+usage() unless $rc;
+die "--dump-aliases incompatible with other options\n"
+    if !$help and $dump_aliases and @ARGV;
+$rc = GetOptions(
                    "sender|from=s" => \$sender,
                     "in-reply-to=s" => \$initial_reply_to,
                    "subject=s" => \$initial_subject,
@@ -310,6 +323,7 @@ sub signal_handler {
                    "smtp-ssl-cert-path=s" => \$smtp_ssl_cert_path,
                    "smtp-debug:i" => \$debug_net_smtp,
                    "smtp-domain:s" => \$smtp_domain,
+                   "smtp-auth=s" => \$smtp_auth,
                    "identity=s" => \$identity,
                    "annotate!" => \$annotate,
                    "no-annotate" => sub {$annotate = 0},
@@ -460,25 +474,11 @@ sub read_config {
 ($repoauthor) = Git::ident_person(@repo, 'author');
 ($repocommitter) = Git::ident_person(@repo, 'committer');
 
-# Verify the user input
-
-foreach my $entry (@initial_to) {
-       die "Comma in --to entry: $entry'\n" unless $entry !~ m/,/;
-}
-
-foreach my $entry (@initial_cc) {
-       die "Comma in --cc entry: $entry'\n" unless $entry !~ m/,/;
-}
-
-foreach my $entry (@bcclist) {
-       die "Comma in --bcclist entry: $entry'\n" unless $entry !~ m/,/;
-}
-
 sub parse_address_line {
        if ($have_mail_address) {
                return map { $_->format } Mail::Address->parse($_[0]);
        } else {
-               return split_addrs($_[0]);
+               return Git::parse_mailboxes($_[0]);
        }
 }
 
@@ -524,11 +524,16 @@ sub parse_sendmail_aliases {
                if (/^\s*alias\s+(?:-group\s+\S+\s+)*(\S+)\s+(.*)$/) {
                        my ($alias, $addr) = ($1, $2);
                        $addr =~ s/#.*$//; # mutt allows # comments
-                        # commas delimit multiple addresses
-                       $aliases{$alias} = [ split_addrs($addr) ];
+                       # commas delimit multiple addresses
+                       my @addr = split_addrs($addr);
+
+                       # quotes may be escaped in the file,
+                       # unescape them so we do not double-escape them later.
+                       s/\\"/"/g foreach @addr;
+                       $aliases{$alias} = \@addr
                }}},
        mailrc => sub { my $fh = shift; while (<$fh>) {
-               if (/^alias\s+(\S+)\s+(.*)$/) {
+               if (/^alias\s+(\S+)\s+(.*?)\s*$/) {
                        # spaces delimit multiple addresses
                        $aliases{$1} = [ quotewords('\s+', 0, $2) ];
                }}},
@@ -561,7 +566,10 @@ sub parse_sendmail_aliases {
        }
 }
 
-($sender) = expand_aliases($sender) if defined $sender;
+if ($dump_aliases) {
+    print "$_\n" for (sort keys %aliases);
+    exit(0);
+}
 
 # is_format_patch_arg($f) returns 0 if $f names a patch, or 1 if
 # $f is a revision list specification to be passed to format-patch.
@@ -613,6 +621,8 @@ sub is_format_patch_arg {
        push @files, $repo->command('format-patch', '-o', tempdir(CLEANUP => 1), @rev_list_opts);
 }
 
+@files = handle_backup_files(@files);
+
 if ($validate) {
        foreach my $f (@files) {
                unless (-p $f) {
@@ -807,7 +817,10 @@ sub file_declares_8bit_cte {
        }
 }
 
-if (!defined $sender) {
+if (defined $sender) {
+       $sender =~ s/^\s+|\s+$//g;
+       ($sender) = expand_aliases($sender);
+} else {
        $sender = $repoauthor || $repocommitter || '';
 }
 
@@ -816,9 +829,10 @@ sub file_declares_8bit_cte {
 # But it's a no-op to run sanitize_address on an already sanitized address.
 $sender = sanitize_address($sender);
 
+my $to_whom = "To whom should the emails be sent (if anyone)?";
 my $prompting = 0;
 if (!@initial_to && !defined $to_cmd) {
-       my $to = ask("Who should the emails be sent to (if any)? ",
+       my $to = ask("$to_whom ",
                     default => "",
                     valid_re => qr/\@.*\./, confirm_only => 1);
        push @initial_to, parse_address_line($to) if defined $to; # sanitized/validated later
@@ -839,12 +853,9 @@ sub expand_one_alias {
        return $aliases{$alias} ? expand_aliases(@{$aliases{$alias}}) : $alias;
 }
 
-@initial_to = expand_aliases(@initial_to);
-@initial_to = validate_address_list(sanitize_address_list(@initial_to));
-@initial_cc = expand_aliases(@initial_cc);
-@initial_cc = validate_address_list(sanitize_address_list(@initial_cc));
-@bcclist = expand_aliases(@bcclist);
-@bcclist = validate_address_list(sanitize_address_list(@bcclist));
+@initial_to = process_address_list(@initial_to);
+@initial_cc = process_address_list(@initial_cc);
+@bcclist = process_address_list(@bcclist);
 
 if ($thread && !defined $initial_reply_to && $prompting) {
        $initial_reply_to = ask(
@@ -916,7 +927,7 @@ sub validate_address {
                        cleanup_compose_files();
                        exit(0);
                }
-               $address = ask("Who should the email be sent to (if any)? ",
+               $address = ask("$to_whom ",
                        default => "",
                        valid_re => qr/\@.*\./, confirm_only => 1);
        }
@@ -941,7 +952,7 @@ sub validate_address_list {
 sub make_message_id {
        my $uniq;
        if (!defined $message_id_stamp) {
-               $message_id_stamp = sprintf("%s-%s", time, $$);
+               $message_id_stamp = strftime("%Y%m%d%H%M%S.$$", gmtime(time));
                $message_id_serial = 0;
        }
        $message_id_serial++;
@@ -956,7 +967,7 @@ sub make_message_id {
                require Sys::Hostname;
                $du_part = 'user@' . Sys::Hostname::hostname();
        }
-       my $message_id_template = "<%s-git-send-email-%s>";
+       my $message_id_template = "<%s-%s>";
        $message_id = sprintf($message_id_template, $uniq, $du_part);
        #print "new message id = $message_id\n"; # Was useful for debugging
 }
@@ -1037,15 +1048,17 @@ sub sanitize_address {
                return $recipient;
        }
 
+       # remove non-escaped quotes
+       $recipient_name =~ s/(^|[^\\])"/$1/g;
+
        # rfc2047 is needed if a non-ascii char is included
        if ($recipient_name =~ /[^[:ascii:]]/) {
-               $recipient_name =~ s/^"(.*)"$/$1/;
                $recipient_name = quote_rfc2047($recipient_name);
        }
 
        # double quotes are needed if specials or CTLs are included
        elsif ($recipient_name =~ /[][()<>@,;:\\".\000-\037\177]/) {
-               $recipient_name =~ s/(["\\\r])/\\$1/g;
+               $recipient_name =~ s/([\\\r])/\\$1/g;
                $recipient_name = qq["$recipient_name"];
        }
 
@@ -1057,6 +1070,14 @@ sub sanitize_address_list {
        return (map { sanitize_address($_) } @_);
 }
 
+sub process_address_list {
+       my @addr_list = map { parse_address_line($_) } @_;
+       @addr_list = expand_aliases(@addr_list);
+       @addr_list = sanitize_address_list(@addr_list);
+       @addr_list = validate_address_list(@addr_list);
+       return @addr_list;
+}
+
 # Returns the local Fully Qualified Domain Name (FQDN) if available.
 #
 # Tightly configured MTAa require that a caller sends a real DNS
@@ -1136,6 +1157,12 @@ sub smtp_auth_maybe {
                Authen::SASL->import(qw(Perl));
        };
 
+       # Check mechanism naming as defined in:
+       # https://tools.ietf.org/html/rfc4422#page-8
+       if ($smtp_auth && $smtp_auth !~ /^(\b[A-Z0-9-_]{1,20}\s*)*$/) {
+               die "invalid smtp auth: '${smtp_auth}'";
+       }
+
        # TODO: Authentication may fail not because credentials were
        # invalid but due to other reasons, in which we should not
        # reject credentials.
@@ -1148,6 +1175,20 @@ sub smtp_auth_maybe {
                'password' => $smtp_authpass
        }, sub {
                my $cred = shift;
+
+               if ($smtp_auth) {
+                       my $sasl = Authen::SASL->new(
+                               mechanism => $smtp_auth,
+                               callback => {
+                                       user => $cred->{'username'},
+                                       pass => $cred->{'password'},
+                                       authname => $cred->{'username'},
+                               }
+                       );
+
+                       return !!$smtp->auth($sasl);
+               }
+
                return !!$smtp->auth($cred->{'username'}, $cred->{'password'});
        });
 
@@ -1178,8 +1219,7 @@ sub ssl_verify_params {
                return (SSL_verify_mode => SSL_VERIFY_PEER(),
                        SSL_ca_file => $smtp_ssl_cert_path);
        } else {
-               print STDERR "Not using SSL_VERIFY_PEER because the CA path does not exist.\n";
-               return (SSL_verify_mode => SSL_VERIFY_NONE());
+               die "CA path \"$smtp_ssl_cert_path\" does not exist";
        }
 }
 
@@ -1300,6 +1340,13 @@ sub send_message {
                        require Net::SMTP::SSL;
                        $smtp_domain ||= maildomain();
                        require IO::Socket::SSL;
+
+                       # Suppress "variable accessed once" warning.
+                       {
+                               no warnings 'once';
+                               $IO::Socket::SSL::DEBUG = 1;
+                       }
+
                        # Net::SMTP::SSL->new() does not forward any SSL options
                        IO::Socket::SSL::set_client_defaults(
                                ssl_verify_params());
@@ -1347,7 +1394,11 @@ sub send_message {
                $smtp->mail( $raw_from ) or die $smtp->message;
                $smtp->to( @recipients ) or die $smtp->message;
                $smtp->data or die $smtp->message;
-               $smtp->datasend("$header\n$message") or die $smtp->message;
+               $smtp->datasend("$header\n") or die $smtp->message;
+               my @lines = split /^/, $message;
+               foreach my $line (@lines) {
+                       $smtp->datasend("$line") or die $smtp->message;
+               }
                $smtp->dataend() or die $smtp->message;
                $smtp->code =~ /250|200/ or die "Failed to send $subject\n".$smtp->message;
        }
@@ -1566,8 +1617,8 @@ sub send_message {
                ($confirm =~ /^(?:auto|compose)$/ && $compose && $message_num == 1));
        $needs_confirm = "inform" if ($needs_confirm && $confirm_unconfigured && @cc);
 
-       @to = validate_address_list(sanitize_address_list(@to));
-       @cc = validate_address_list(sanitize_address_list(@cc));
+       @to = process_address_list(@to);
+       @cc = process_address_list(@cc);
 
        @to = (@initial_to, @to);
        @cc = (@initial_cc, @cc);
@@ -1678,6 +1729,44 @@ sub validate_patch {
        return;
 }
 
+sub handle_backup {
+       my ($last, $lastlen, $file, $known_suffix) = @_;
+       my ($suffix, $skip);
+
+       $skip = 0;
+       if (defined $last &&
+           ($lastlen < length($file)) &&
+           (substr($file, 0, $lastlen) eq $last) &&
+           ($suffix = substr($file, $lastlen)) !~ /^[a-z0-9]/i) {
+               if (defined $known_suffix && $suffix eq $known_suffix) {
+                       print "Skipping $file with backup suffix '$known_suffix'.\n";
+                       $skip = 1;
+               } else {
+                       my $answer = ask("Do you really want to send $file? (y|N): ",
+                                        valid_re => qr/^(?:y|n)/i,
+                                        default => 'n');
+                       $skip = ($answer ne 'y');
+                       if ($skip) {
+                               $known_suffix = $suffix;
+                       }
+               }
+       }
+       return ($skip, $known_suffix);
+}
+
+sub handle_backup_files {
+       my @file = @_;
+       my ($last, $lastlen, $known_suffix, $skip, @result);
+       for my $file (@file) {
+               ($skip, $known_suffix) = handle_backup($last, $lastlen,
+                                                      $file, $known_suffix);
+               push @result, $file unless $skip;
+               $last = $file;
+               $lastlen = length($file);
+       }
+       return @result;
+}
+
 sub file_has_nonascii {
        my $fn = shift;
        open(my $fh, '<', $fn)