aboutsummaryrefslogtreecommitdiffstats
path: root/pullimap
blob: 8142be8ea2d4e0f24faba5b96d28b67cf6539aca (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
#!/usr/bin/perl -T

#----------------------------------------------------------------------
# Pull mails from an IMAP mailbox and deliver them to an SMTP session
# Copyright © 2016-2020 Guilhem Moulin <guilhem@fripost.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#----------------------------------------------------------------------

use v5.20.2;
use strict;
use warnings;

our $VERSION = '0.5.4';
my $NAME = 'pullimap';

use Errno 'EINTR';
use Fcntl qw/O_CREAT O_RDWR O_DSYNC F_SETLK F_WRLCK SEEK_SET F_GETFD F_SETFD FD_CLOEXEC/;
use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/;
use List::Util 'first';
use Socket qw/PF_INET PF_INET6 SOCK_STREAM IPPROTO_TCP/;

use Net::IMAP::InterIMAP 0.0.5 qw/xdg_basedir read_config compact_set/;

# Clean up PATH
$ENV{PATH} = join ':', qw{/usr/bin /bin};
delete @ENV{qw/IFS CDPATH ENV BASH_ENV/};

my %CONFIG;
sub usage(;$) {
    my $rv = shift // 0;
    if ($rv) {
        print STDERR "Usage: $NAME [OPTIONS] SECTION\n"
                    ."Try '$NAME --help' or consult the manpage for more information.\n";
    }
    else {
        print STDERR "Usage: $NAME [OPTIONS] SECTION\n"
                    ."Consult the manpage for more information.\n";
    }
    exit $rv;
}

usage(1) unless GetOptions(\%CONFIG, qw/config=s quiet|q debug+ help|h idle:i no-delivery/);
usage(0) if $CONFIG{help};
usage(1) unless $#ARGV == 0 and $ARGV[0] ne '_';


#######################################################################
# Read and validate configuration
#
my $CONF = do {
    my $conffile = delete($CONFIG{config}) // "config";
    $conffile = xdg_basedir( XDG_CONFIG_HOME => ".config", $NAME, $conffile );
    read_config( $conffile
               , [$ARGV[0]]
               , statefile => qr/\A(\P{Control}+)\z/
               , mailbox => qr/\A([\x01-\x7F]+)\z/
               , 'deliver-method' => qr/\A([ls]mtp:\[.*\]:\d+)\z/
               , 'deliver-ehlo' => qr/\A(\P{Control}+)\z/
               , 'deliver-rcpt' => qr/\A(\P{Control}+)\z/
               , 'purge-after' => qr/\A(\d*)\z/
               )->{$ARGV[0]};
};

my ($MAILBOX, $STATE);
do {
    $MAILBOX = $CONF->{mailbox} // 'INBOX';

    my $statefile = $CONF->{statefile} // $ARGV[0];
    die "Missing option statefile" unless defined $statefile;
    $statefile = xdg_basedir( XDG_DATA_HOME => ".local/share", $NAME, $statefile );

    my $mode = O_RDWR | O_DSYNC;
    # don't auto-create in long-lived mode
    $mode |= O_CREAT unless defined $CONFIG{idle};

    sysopen($STATE, $statefile, $mode, 0600) or die "Can't open $statefile: $!";
    # XXX we need to pack the struct flock manually: not portable!
    my $struct_flock = pack('s!s!l!l!i!', F_WRLCK, SEEK_SET, 0, 0, 0);
    fcntl($STATE, F_SETLK, $struct_flock) or die "Can't lock $statefile: $!";
    my $flags = fcntl($STATE, F_GETFD, 0)       or die "fcntl F_GETFD: $!";
    fcntl($STATE, F_SETFD, $flags | FD_CLOEXEC) or die "fcntl F_SETFD: $!";

    # We have no version number in the statefile, but if we ever need a
    # migration, we'll add a 1-byte header for the version number, and
    # assume version 1.0 if the size of the file is a multiple of 4
    # bytes. (We can also use the fact that bytes 5 to 8 are never all 0.)
};


#######################################################################

# Read a UID (32-bits integer) from the statefile, or undef if we're at
# the end of the statefile
sub readUID() {
    my $n = sysread($STATE, my $buf, 4) // die "read: $!";
    return if $n == 0; # EOF
    # file length is a multiple of 4 bytes, and we always read 4 bytes at a time
    die "Corrupted state file!" if $n != 4;
    unpack('N', $buf);
}

# Write a UID (32-bits integer) to the statefile
sub writeUID($) {
    my $uid = pack('N', shift);
    my $offset = 0;
    for ( my $offset = 0
        ; $offset < 4
        ; $offset += syswrite($STATE, $uid, 4-$offset, $offset) // die "write: $!"
    ) {}
    # no need to sync (or flush) since $STATE is opened with O_DSYNC
}


#######################################################################
# SMTP/LMTP part
#
my ($SMTP, $SMTP_PIPELINING);
sub sendmail($$) {
    my ($from, $rfc822) = @_;
    unless (defined $SMTP) {
        # TODO we need to be able to reconnect when the server closes
        # the connection due to a timeout (RFC 5321 section 4.5.3.2)
        my ($fam, $addr) = (PF_INET, $CONF->{'deliver-method'} // 'smtp:[127.0.0.1]:25');
        $addr =~ s/^([ls]mtp):// or die;
        my $ehlo = $1 eq 'lmtp' ? 'LHLO' : $1 eq 'smtp' ? 'EHLO' : die;
        $ehlo .= ' '. ($CONF->{'deliver-ehlo'} // 'localhost.localdomain');

        my $port = $addr =~ s/:(\d+)$// ? $1 : die;
        $addr =~ s/^\[(.*)\]$/$1/ or die;
        $fam = PF_INET6 if $addr =~ /:/;
        $addr = Socket::inet_pton($fam, $addr) // die "Invalid address $addr\n";
        my $sockaddr = $fam == PF_INET  ? Socket::pack_sockaddr_in($port,  $addr)
                     : $fam == PF_INET6 ? Socket::pack_sockaddr_in6($port, $addr)
                     : die;

        socket($SMTP, $fam, SOCK_STREAM, IPPROTO_TCP) or die "socket: $!";
        until (connect($SMTP, $sockaddr)) {
            next if $! == EINTR; # try again if connect(2) was interrupted by a signal
            die "connect: $!";
        }
        binmode($SMTP) // die "binmode: $!";

        smtp_resp('220');
        my @r = smtp_send($ehlo => '250');
        $SMTP_PIPELINING = grep {$_ eq 'PIPELINING'} @r; # SMTP pipelining (RFC 2920)
    }
    my $rcpt = $CONF->{'deliver-rcpt'} // getpwuid($>) // die;

    # return codes are from RFC 5321 section 4.3.2
    smtp_send( "MAIL FROM:<$from>" => '250'
             , "RCPT TO:<$rcpt>"   => '250'
             , "DATA"              => '354'
             );

    print STDERR "C: [...]\n" if $CONFIG{debug};
    if (!defined $$rfc822 or $$rfc822 eq "") {
        # RFC 5321 section 4.1.1.4: if there was no mail data, the first
        # "\r\n" ends the DATA command itself
        $SMTP->printflush("\r\n.\r\n") or die;
    } else {
        my $offset = 0;
        my $length = length($$rfc822);
        while ((my $end = index($$rfc822, "\r\n", $offset) + 2) != 1) {
            my $line = substr($$rfc822, $offset, $end-$offset);
            # RFC 5321 sec. 4.5.2: if the line starts with a dot, double it
            $line = ".".$line if substr($line, 0, 1) eq ".";
            $SMTP->print($line) or die;
            $offset = $end;
        }
        if ($offset < $length) {
            # the last line did not end with "\r\n"; add it in order to
            # have the receiving SMTP server recognize the "end of data"
            # condition.  See RFC 5321 sec. 4.1.1.4
            my $line = substr($$rfc822, $offset);
            $line = ".".$line if substr($line, 0, 1) eq ".";
            $SMTP->print($line, "\r\n") or die;
        }
        $SMTP->printflush(".\r\n") or die;
    }
    smtp_resp('250');
}
sub smtp_resp($) {
    my $code = shift;
    my @resp;
    while(1) {
        local $_ = $SMTP->getline() // die;
        s/\r\n\z// or die "Invalid SMTP reply: $_";
        print STDERR "S: $_\n" if $CONFIG{debug};
        s/\A\Q$code\E([ -])// or die "SMTP error: Expected $code, got: $_\n";
        push @resp, $_;
        return @resp if $1 eq ' ';
    }
}
sub smtp_send(@) {
    my (@cmd, @code, @r);
    while (@_) {
        push @cmd,  shift // die;
        push @code, shift // die;
    }
    if ($SMTP_PIPELINING) { # SMTP pipelining (RFC 2920)
        print STDERR (map {"C: $_\n"} @cmd) if $CONFIG{debug};
        $SMTP->printflush(map {"$_\r\n"} @cmd) or die;
        @r = smtp_resp($_) foreach @code;
    }
    else {
        foreach (@cmd) {
            print STDERR "C: $_\n" if $CONFIG{debug};
            $SMTP->printflush("$_\r\n") or die;
            @r = smtp_resp(shift(@code));
        }
    }
    return @r;
}


#######################################################################
# Initialize the cache from the statefile, then pull new messages from
# the remote mailbox
#
my $IMAP = do {
    my %config = (%$CONF, %CONFIG{qw/quiet debug/}, name => $ARGV[0]);
    $config{keepalive} = 1 if defined $CONFIG{idle};
    $config{'logger-prefix'} = "%?n?%?m?%n(%m)&%n?: ?";
    delete $config{mailbox}; # use SELECTed mailbox in log messages
    Net::IMAP::InterIMAP::->new( %config );
};

# Remove messages with UID < UIDNEXT and INTERNALDATE at most
# $CONF->{'purge-after'} days ago.
my $LAST_PURGED;
sub purge() {
    my $days = $CONF->{'purge-after'} // return;
    my ($uidnext) = $IMAP->get_cache('UIDNEXT');
    return unless $days ne '' and 1<$uidnext;
    my $set = "1:".($uidnext-1);

    unless ($days == 0) {
        my $now = time;
        return if defined $LAST_PURGED and $now - $LAST_PURGED < 43200; # purge every 12h
        $LAST_PURGED = $now;

        my @now = gmtime($now - $days*86400);
        my @m = qw/Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec/; # RFC 3501's date-month
        my $date = sprintf("%02d-%s-%04d", $now[3], $m[$now[4]], $now[5]+1900);
        my $ext = $IMAP->incapable('ESEARCH') ? undef : [qw/COUNT ALL/];
        my @uid = $IMAP->search((defined $ext ? "RETURN (".join(' ', @$ext).') ' : '')
                                ."UID $set BEFORE $date");
        my $count;
        if (defined $ext) {
            my ($uid_indicator, %resp) = @uid;
            $IMAP->panic() unless defined $uid_indicator and $uid_indicator = 'UID';
            $count = $resp{COUNT} // $IMAP->panic();
            $set = $resp{ALL}; # MUST NOT be present if there are no matches
        } else {
            $count = $#uid+1;
            $set = $count == 0 ? undef : compact_set(@uid);
        }
        $IMAP->log("Removing $count UID(s) $set") if $count > 0 and !$CONFIG{quiet};
    }

    if (defined $set) {
        $IMAP->silent_store($set, '+', '\Deleted');
        $IMAP->expunge($set);
    }

    # pull messages that have been received in the meantime
    pull() if $IMAP->has_new_mails($MAILBOX);
}

# Use BODY.PEEK[] so if something gets wrong, unpulled messages
# won't be marked as \Seen in the mailbox
my $ATTRS = "ENVELOPE INTERNALDATE";
$ATTRS .= " BODY.PEEK[]" unless $CONFIG{'no-delivery'};

my $RE_ATOM = qr/[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x41-\x5A\x5E-\x7E]+/;
my $DOT_STRING = qr/\A$RE_ATOM(?:\.$RE_ATOM)*\z/;
sub pull_callback($$) {
    my ($uids, $mail) = @_;
    return unless exists $mail->{RFC822} or $CONFIG{'no-delivery'}; # not for us

    my $uid = $mail->{UID};
    my $e = $mail->{ENVELOPE}->[3];
    my $sender = '';
    if (defined $e and defined (my $l = $e->[0]->[2]) and defined (my $d = $e->[0]->[3])) {
        if ($l =~ $DOT_STRING) {
            $sender = $l.'@'.$d;
        } elsif ($l =~ /\A[\x20-\x7E]*\z/) {
            # quote the local part if not Dot-string (RFC 5321)
            $l =~ s/([\x22\x5C])/\\$1/g; # escape double-quote and backslash
            $sender = '"'.$l.'"@'.$d;
        }
    }
    $IMAP->log("UID $uid from <$sender> ($mail->{INTERNALDATE})") unless $CONFIG{quiet};

    sendmail($sender, $mail->{RFC822}) unless $CONFIG{'no-delivery'};

    push @$uids, $uid;
    writeUID($uid);
}

# Pull new messages from IMAP and deliver them to SMTP, then update the
# statefile
sub pull(;$) {
    my $ignore = shift // [];
    my @uid;

    my $callback = sub($) { pull_callback(\@uid, shift) };

    do {
        # invariant: we're at pos 8 + 4*(1+$#ignore + 1+$#uids) in the statefile
        $IMAP->pull_new_messages($ATTRS, $callback, @$ignore);

        # now that everything has been deliverd, mark @ignore and @uid as \Seen
        $IMAP->silent_store(compact_set(@$ignore, @uid), '+', '\Seen') if @$ignore or @uid;
    }
    # repeat if we got a message in the meantime
    while ($IMAP->has_new_mails($MAILBOX));

    # terminate the SMTP transmission channel gracefully, cf RFC 5321 section 4.5.3.2
    smtp_send('QUIT' => '221') if defined $SMTP;
    undef $SMTP;

    # update the statefile
    my $p = sysseek($STATE, 4, SEEK_SET) // die "seek: $!";
    die "Couldn't seek to 4" unless $p == 4; # safety check
    my ($uidnext) = $IMAP->get_cache('UIDNEXT');
    writeUID($uidnext);
    truncate($STATE, 8) // die "truncate: $!";
}

do {
    my $uidvalidity = readUID();
    my $uidnext = readUID();
    my $ignore = [];

    $IMAP->set_cache($MAILBOX, UIDVALIDITY => $uidvalidity, UIDNEXT => $uidnext);
    $IMAP->select($MAILBOX);

    unless (defined $uidvalidity) {
        ($uidvalidity) = $IMAP->get_cache('UIDVALIDITY');
        # we were at pos 0 before the write, at pos 4 afterwards
        writeUID($uidvalidity);
        die if defined $uidnext; # sanity check
    }

    if (!defined $uidnext) {
        # we were at pos 4 before the write, at pos 8 afterwards
        writeUID(1);
    }
    else {
        # put the remaining UIDs in the @ignore list: these messages
        # have already been delivered, but the process exited before the
        # statefile was updated
        while (defined (my $uid = readUID())) {
            push @$ignore, $uid;
        }
    }
    pull($ignore);
    purge();
};
unless (defined $CONFIG{idle}) {
    $IMAP->logout();
    exit 0;
}

$CONFIG{idle} = 1740 if defined $CONFIG{idle} and $CONFIG{idle} == 0; # 29 mins
while(1) {
    pull() if $IMAP->idle($CONFIG{idle}, \&Net::IMAP::InterIMAP::has_new_mails);
    purge();
}