Postfix Checking Maildir Disk Usage / Quota On The Fly

Postfix does not have built-in quota inspection feature. i’ve seen people on mailing list asking how to check maildir usage on the fly. Add-ons such as VDA , vda inspect quota after email was accepted. When someone over quota, they will bounce mail, Just imagine, when bad guys sent lots of email to overdrawn account with fake return address/sender envelope, on purpose. Our mail server will be backscatter source. spamming undelivered message to innocent people.

Maildrop and Dovecot can handle quota better, but still they’re all inspect maildir usage after accepting the mail. And likely they will bounce email after the first inspect overquota maildir. Ideally, sender should be rejected at smtp conversation time. RCPT TO stage will perfect place for inspecting recipient maildir usage. Before postfix introducing tcp_table, the best solutions was creating map for overquota user. this can be done by using script by querying user quota constant specified in database, then compared to usage in maildirsize file or maildir disk usage.

I wrote this simple perl script, has functions to inspect user quota specified in database, and maildir disk usage. it runs as daemon. it’s not perfect. the script lack of ability when dealing with email address alias or email address extension. Just keep in mind tcp_table connection is not protected and the server is not authenticated.

There are two main functions which are used in this script. checksqlsize and checksize.

checksqlsize is used to check user quota specified in the database. you can adjust parameters in the script as needed,

sub checksqlsize {
        my $user = $_[0];
        my $sqlresult;
        trim($user);
        my $dbh = DBI->connect('DBI:mysql:postfixdb:localhost', 'user', 'password', { RaiseError => 1 });
        my $sth = $dbh->prepare(qq{SELECT quota FROM mailbox WHERE username='$user'});
        $sth->execute();
        while (my @row = $sth->fetchrow_array) {
                $sqlresult = $row[0];
        }
        $sth->finish();
        $dbh->disconnect;
        if ($sqlresult >= 0 ) {
                return $sqlresult;
        } else {
                return undef;
        }
}


checksize is used to check amount of bytes that have been in use maildir.

sub checksize {
        my $diruser = $_[0];
        trim($diruser);
        my $size;
        find(sub{ -f and ( $size += -s ) }, $diruser );
        if (defined $size) {
                $size = sprintf("%u",$size);
                return $size;
        }
        return undef;
}

Complete script will be like this, save it as quota.pl or you can name it as you wish.

#!/usr/bin/perl
use File::Find;
use strict;
use warnings;
use DBI;
use DBD::mysql;
use Sys::Syslog qw(:DEFAULT setlogsock);
use base qw(Net::Server::PreFork);

#
# Initalize and open syslog.
#
openlog('postfix-quota::','pid','mail');

__PACKAGE__->run;
exit;

###

sub configure_hook {
        my $self = shift;

        $self->{server}->{port}     = '127.0.0.1:20028';
        $self->{server}->{user}     = 'postfix';
        $self->{server}->{group}    = 'postfix';
        $self->{server}->{pid_file} = '/tmp/size.pid';
        $self->{server}->{setsid}   = 1;
        $self->{basedir}            = "/path/to/postfix/maildir/";

}

### process the request
sub process_request {
        my $self = shift;
        while(my $line = <STDIN>) {
                chomp($line);
                if ($line=~/^get\s+(.+)/i) {
                        my $user = $1;
                        trim($user);
                        my $sqlsize = checksqlsize($user);
                        if (defined $sqlsize && $sqlsize == 0) {
                                print STDOUT "200 DUNNO\n";
                                next;
                        }

                        my $usrdirsize = $user;
                        $usrdirsize =~ s/\@example\.com$/\//;
                        my $dir = $self->{basedir} . $usrdirsize;
                        my $dirsize = checksize($dir);

                        if (defined $dirsize && defined $sqlsize) {
                        syslog("info","Checking %s maildir size: define=%s, diskusage=%s", $user, $sqlsize, $dirsize);
                                if ( $dirsize > $sqlsize ) {
                                        print STDOUT "200 452 4.2.2 $user is over quota! maildir size: define=$sqlsize, diskusage=$dirsize\n";
                                        next;
                                }
                        }
                }
                print STDOUT "200 DUNNO\n";
        }
}

sub trim{
        $_[0]=~s/^\s+//;
        $_[0]=~s/\s+$//;
        return;
}

sub checksize {
        my $diruser = $_[0];
        trim($diruser);
        my $size;
        find(sub{ -f and ( $size += -s ) }, $diruser );
        if (defined $size) {
                $size = sprintf("%u",$size);
                return $size;
        }
        return undef;
}

sub checksqlsize {
        my $user = $_[0];
        my $sqlresult;
        trim($user);
        my $dbh = DBI->connect('DBI:mysql:postfixdb:localhost', 'user', 'password', { RaiseError => 1 });
        my $sth = $dbh->prepare(qq{SELECT quota FROM mailbox WHERE username='$user'});
        $sth->execute();
        while (my @row = $sth->fetchrow_array) {
                $sqlresult = $row[0];
        }
        $sth->finish();
        $dbh->disconnect;
        if ($sqlresult >= 0 ) {
                return $sqlresult;
        } else {
                return undef;
        }
}

1;

In my test environment i’m using maildir path like this:

/path/to/postfix/maildir/

maildir are username part, without domain.
example:

/path/to/postfix/kutukupret/

maildir has user and group permission set to “postfix”.
the script run on localhost port 20028 or whatever number you would like to specified.

        $self->{server}->{port}     = '127.0.0.1:20028';
        $self->{server}->{user}     = 'postfix';
        $self->{server}->{group}    = 'postfix';
        $self->{server}->{pid_file} = '/tmp/quota.pid';
        $self->{server}->{setsid}   = 1;
        $self->{basedir}            = "/path/to/postfix/maildir/";

Run it from shell

# ./quota.pl

I reproduced overquota user condition for testing the script, testing can be done directly by telneting localhost 20028.

# telnet localhost 20028
Trying 127.0.0.1...
Connected to localhost.localdomain (127.0.0.1).
Escape character is '^]'.
get test@example.com
200 REJECT test@example.com is over quota! maildir size: define=1024000, diskusage=1728398

Now, Let’s put it alltogether

In main.cf somewhere in smtpd_recipient_restrictions add this check_recipient_access

smtpd_recipient_restrictions =
   check_recipient_access tcp:[127.0.0.1]:20028,
   permit_mynetworks,
   permit_sasl_authenticated,
   reject_unauth_destination,
.....
.....

Reload postfix, check it from real world

# telnet mx.example.com 25
Trying xxx.xxx.xx.xxx...
Connected to xxx.xxx.xx.xxx.
Escape character is '^]'.
220 mx.example.com
ehlo mx.example.org
250-mx1.csmcom.com
250-SIZE 5242880
250-STARTTLS
250-AUTH LOGIN PLAIN
250-AUTH=LOGIN PLAIN
250-ENHANCEDSTATUSCODES
250-8BITMIME
250 DSN
mail from:<me@example.org>
250 2.1.0 Ok
rcpt to:<test@example.com>
554-5.7.1 <test@example.com>: Recipient address rejected: test@example.com is over quota! maildir size: define=1024000, diskusage=1728398
554-5.7.1 For assistance, send email to support@help.example.com.
554-5.7.1 Please provide the following information in your problem report:
554 5.7.1 time (May 16 09:38:40), client address (xxx.xxx.xxx.xxx) and server (mx.example.com).
quit
221 2.0.0 Bye
Connection closed by foreign host.

Use it with caution, because the script will hammering the disk alot since it will do the probing frequently.

4 Comments

    • hmm, yes you’re right. sender must have a chance to retry. good point. 🙂

  1. Pol

    Hi and thanks for all! I’ve a question: on my system I use postfix and courier with virtual users. Quota of each email should be manage to postfix or pop/imap server? (or both?)

    thanks

    Pol

    • how did you store your virtual user? if you are using sql database you can do it with postfixadmin, it’s support quota entry. the database used by both postfix virtual user and courier.

Leave a Reply

Your email address will not be published. Required fields are marked *