package Fripost::Schema::Local; =head1 NAME Local.pm - Localpart related method for the Fripost Schema =head1 DESCRIPTION This module abstracts the LDAP schema definition and provides methods to add, list or delete virtual users, aliases or lists. =cut use 5.010_000; use strict; use warnings; use utf8; use parent 'Fripost::Schema'; use Fripost::Schema::Util qw/concat split_addr canonical_dn ldap_error dn2mail/; use Net::IDN::Encode qw/email_to_ascii email_to_unicode/; use Net::LDAP::Util 'escape_filter_value'; =head1 REPRESENTATION Virtual users, aliases and lists are imported and exported as hash references, with the following keys: =over 4 =item B A UTF-8 string representing the (internationalized) e-mail address for the user, alias or list. =item B => user|alias|list The type of the entry. =item B => 0|1 Whether or not the entry is active. =item B An array reference containing UTF-8 strings describing the entry. =item B => 0|1 (List only) Whether or not the entry is pending. New lists are always marked as pending, and it is up to the list manager's side to unlock them. =item B (User only) A string e.g., C<100 MB> representing the current quota on the user's mailboxes. =item B (Alias and list only) An optional array reference containing the (internationalized) e-mails addresses of the entry owners. =item B (User only) An optional array reference containing a (internationalized) e-mails addresses that will also receive every single message sent to that user. =item B (Alias only) An array reference containing a (internationalized) e-mails addresses that will receive messages sent to that alias. =item B mailman|schleuder (List only) The list manager associated with list entries. =head1 METHODS =over 4 =item B (I, I) Search for I, or list all the known (and visible) users, aliases and lists when I is not defined. If I does not contain a '@' symbol, it is interpreted as a domain name, which limits the scope of that search. In list context, return a list of entries represented as hash references, as explained above. In scalar context, only the first entry found is returned. In void context, no attributes are returned from the LDAP server, but it may nevertheless be useful, to ensure that the result set is not empty for instance. The following options are considered: =over 4 =item B<-no-escape> => 0|1 By default, the local and domain parts of I - when defined - are safely escaped before insertion into the LDAP DN and filter. This flag disables escaping. It is useful if I contains wildcards for instance. Note that in case the domain part contains wildcard, this method will query the LDAP server for every single matching domain. =item B<-filter> => locked|unlocked Limit the search scope to pending / non-pending entries only. =item B<-type> => user|alias|list Limit the search scope to the given entry type. =item B<-keys> An array reference containing the attributes that are to be retrived from the LDAP server. Note that Access Control List may prevent the current user to read or even search anything, though. The default is to retrieve every visible attribute, unless in void context where B<-keys> is set to [] that is, no attribute is sent back to the client. =item B<-count> => 0|1 Return the number of entries in the result set. When set, the B<-keys> option is bypassed not to ask any attribute from the server. =item B<-sort> => 0|1 In list context, sort the results per localpart. =back Errors can be caught with options B<-die> and B<-error>, see B for details. =cut sub search { my $self = shift; my $in = shift; my %options = @_; # Nothing to do after an error. return if $options{'-error'} && ${$options{'-error'}}; # If there is not '@', we interpret $in as a domain name. $in =~ s/^([^\@]+)$/\@$1/; my ($localname, $domainname) = split_addr($in); my @filters; if (defined $options{'-type'}) { # Limit the scope to the given type. if ($options{'-type'} eq 'user') { push @filters, 'objectClass=FripostVirtualUser'; } elsif ($options{'-type'} eq 'alias') { push @filters, 'objectClass=FripostVirtualAlias'; } elsif ($options{'-type'} eq 'list') { push @filters, 'objectClass=FripostVirtualList'; } } else { push @filters, '(|(objectClass=FripostVirtualUser)'. '(objectClass=FripostVirtualAlias)'. '(objectClass=FripostVirtualList))'; } my @domainnames; if ($domainname) { if ($options{'-no-escape'} and $domainname =~ /\*/) { # If the domain part contains a wildcard, we have to query # the LDAP server to list the matching domains. my %opts = ( '-no-escape' => 1, -keys => [ 'name' ]) ; $opts{'-filter'} = 'unlocked'; foreach (qw/-filter -error -die/) { $opts{$_} = $options{$_} if $options{$_}; } push @domainnames, map {$_->{name}} $self->domain->search($domainname, %opts); } else { # Otherwise, a single query is enough. $domainname = Net::LDAP::Util::escape_dn_value($domainname) unless $options{'-no-escape'}; push @domainnames, $domainname; } } if ($localname) { $localname = Net::LDAP::Util::escape_filter_value($localname) unless $options{'-no-escape'}; push @filters, 'fvl='.$localname; } if (defined $options{'-filter'}) { push @filters, 'objectClass=FripostPendingEntry' if $options{'-filter'} eq 'locked'; push @filters, '!(objectClass=FripostPendingEntry)' if $options{'-filter'} eq 'unlocked'; } my $attrs = []; if (not (defined wantarray)) { # In void context, we are only interested in whether or not the # result set is empty. $attrs = [ '1.1' ]; } elsif (defined $options{'-keys'}) { $attrs = @{$options{'-keys'}} ? [ &_keys_to_attrs(@{$options{'-keys'}}) ] : [ '1.1' ]; } my $filter = Fripost::Schema::Util::ldap_and_filter(@filters); my $count = 0; my @resultset; foreach my $domainname (@domainnames) { # For query the server for each matching domain. my $locals = $self->ldap->search( base => $self->mail2dn('@'.$domainname) , scope => 'one' , deref => 'never' , filter => $filter , attrs => $attrs ); ldap_error($locals, %options) // return; next unless defined wantarray; # We'll drop the result anyway if ($options{'-count'}) { $count += $locals->count; } elsif (wantarray) { push @resultset, &_entries_to_locals( $domainname, $options{'-keys'}, $locals->entries ); } else { # In scalar context, we stop here if we got a match. return &_entries_to_locals( $domainname, $options{'-keys'}, $locals->pop_entry ) if $locals->count; } } return $count if $options{'-count'}; # In list context, we return the whole result set, maybe sorted. $options{'-sort'} ? sort { $a->{name} cmp $b->{name} } @resultset : @resultset; } # Map a list of LDAP::Entry object into our public representation of # users, aliases and lists. sub _entries_to_locals { my $domainname = shift; my $keys = shift // []; my @locals; foreach my $entry (@_) { # Ignore bogus entries. return unless defined $entry; my %local; foreach my $attr ($entry->attributes) { my $val = $entry->get_value($attr, asref => 1); if ($attr eq 'fvl') { $local{name} = email_to_unicode($val->[0].'@'.$domainname) if not @$keys or grep { $_ eq 'name' } @$keys; } elsif ($attr eq 'fripostIsStatusActive') { $local{isActive} = $val->[0] eq 'TRUE' if not @$keys or grep { $_ eq 'isActive' } @$keys; } elsif ($attr eq 'objectClass') { if (grep { lc $_ eq lc 'FripostVirtualUser' } @$val) { $local{type} = 'user'; } elsif (grep { lc $_ eq lc 'FripostVirtualAlias' } @$val) { $local{type} = 'alias'; } elsif (grep { lc $_ eq lc 'FripostVirtualList' } @$val) { $local{type} = 'list'; } $local{isPending} = scalar (grep { lc $_ eq lc 'FripostPendingEntry' } @$val ) if not @$keys or grep { $_ eq 'isPending' } @$keys; } elsif ($attr eq 'description') { $local{description} = [ map { Encode::_utf8_on($_); $_ } @$val ] if not @$keys or grep { $_ eq 'description' } @$keys; } elsif ($attr eq 'fripostUserQuota') { $local{quota} = $val->[0] if not @$keys or grep { $_ eq 'quota' } @$keys; } elsif ($attr eq 'fripostOwner') { $local{owner} = [ map { dn2mail($_) } @$val ] if not @$keys or grep { $_ eq 'owner' } @$keys; } elsif ($attr eq 'fripostOptionalMaildrop') { $local{forward} = [ map { email_to_unicode($_) } @$val ] if not @$keys or grep { $_ eq 'forward' } @$keys; } elsif ($attr eq 'fripostMaildrop') { $local{destination} = [ map { email_to_unicode($_) } @$val ] if not @$keys or grep { $_ eq 'destination' } @$keys; } elsif ($attr eq 'fripostListManager') { $local{transport} = $val->[0] if not @$keys or grep { $_ eq 'transport' } @$keys; } else { die "Missing translation for local attribute ‘".$attr."’."; } } # Stop after the first processed domain in scalar mode. return \%local unless wantarray; push @locals, \%local; } return @locals; } =head1 METHODS =over 4 =item B (I, I) Returns a hash with all the (visible) attributes for the given entry. An additional 'type' attribute gives the type of *the* found entry (possible values are 'user', 'alias', and 'list'). =cut sub get { my $self = shift; my $loc = shift; my %options = @_; my $concat = $options{'-concat'}; my ($l,$d) = split_addr( $loc, -encode => 'ascii' ); $l = escape_filter_value($l); my $locals = $self->ldap->search( base => canonical_dn({fvd => $d}, @{$self->suffix}), scope => 'one', deref => 'never', filter => "(|(&(objectClass=FripostVirtualUser)(fvu=$l)) (&(objectClass=FripostVirtualAlias)(fva=$l)) (&(objectClass=FripostVirtualList)(fvl=$l)))", attrs => [ qw/fvu description fripostIsStatusActive fripostOptionalMaildrop fripostUserQuota fva fripostMaildrop fvl fripostListManager/ ] ); if ($locals->code) { die $options{'-die'}."\n" if defined $options{'-die'}; die $locals->error."\n"; } # The following is not supposed to happen. Note that there is # nothing in the LDAP schema to prevent that, but it's not too # critical as Postfix searchs for user, aliases and lists in # that order. die "Error: Multiple matching entries found." if $locals->count > 1; my $local = $locals->pop_entry; unless (defined $local) { die $options{'-die'}."\n" if defined $options{'-die'}; die "No such such entry ‘".$loc."’.\n"; } my %ret; if ($local->dn =~ /^fvu=/) { $ret{type} = 'user'; $ret{user} = $local->get_value('fvu'); $ret{forwards} = concat($concat, map { email_to_unicode($_) } $local->get_value('fripostOptionalMaildrop')) } elsif ($local->dn =~ /^fva=/) { $ret{type} = 'alias'; $ret{alias} = $local->get_value('fva'); $ret{maildrop} = concat($concat, map { email_to_unicode($_) } $local->get_value('fripostMaildrop')) } elsif ($local->dn =~ /^fvl=/) { $ret{type} = 'list'; $ret{list} = $local->get_value('fvl'); $ret{transport} = $local->get_value('fripostListManager'); } $ret{isactive} = $local->get_value('fripostIsStatusActive') eq 'TRUE'; $ret{description} = concat($concat, $local->get_value('description')); $ret{ispending} = ($local->get_value('fripostIsStatusPending') // '') eq 'TRUE'; return %ret; } =item B (I, I) Returns 1 if the given I@I exists, and 0 otherwise. The authenticated user needs to have search access to the 'entry' attribute. =cut sub exists { my $self = shift; my ($l,$d) = split_addr( shift, -encode => 'ascii' ); my %options = @_; # We may not have read access to the list commands # The trick is somewhat dirty, but it's safe enough since postfix # delivers to users, aliases, and lists with different # priorities (and lists have the lowest). my @cmds = qw/admin bounces confirm join leave owner request subscribe unsubscribe bounce sendkey/; my @tests = ( {fvu => $l}, {fva => $l}, {fvl => $l} ); foreach (@cmds) { # If the entry is of the form 'foo-command', we need to ensure # that no list 'foo' exists, otherwise the new entry would # override foo's command. if ($l =~ s/-$_$//) { push @tests, {fvl => $l}; last; } } if (defined $options{t} and $options{t} eq 'list') { # If that's a list that is to be created, we need to ensure that # none of its commands exists. foreach (@cmds) { my $l2 = $l.'-'.$_; push @tests, {fvu => $l2}, {fva => $l2}; } } foreach (@tests) { my $dn = canonical_dn($_, {fvd => $d}, @{$self->suffix}); my $mesg = $self->ldap->search( base => $dn , scope => 'base' , deref => 'never' , filter => 'objectClass=*' , attrs => [ '1.1' ] ); return 1 unless $mesg->code; # 0 Success unless ($mesg->code == 32) { # 32 No such object die $options{'-die'}."\n" if defined $options{'-die'}; die $mesg->error."\n"; } } return 0; } =back =head1 GLOBAL OPTIONS If the B<-concat> option is present, it will intersperse multi-valued attributes. Otherwise, an array reference containing every values will be returned for these attributes. The B<-die> option, if present, overides LDAP croaks and errors. =cut =head1 AUTHOR Guilhem Moulin C<< >> =head1 COPYRIGHT Copyright 2012,2013 Guilhem Moulin. =head1 LICENSE This program is free software; you can redistribute it and/or modify it under the same terms as perl itself. =cut 1; __END__