Revision 93 (by dpavlin, 2006/12/19 15:04:05) added verbose to send_queued_messages so that SOAP call (SendTest, used for tests) doesn't
produce any output (and thus confuse SOAP CGI server)
package Nos;

use 5.008;
use strict;
use warnings;

require Exporter;

our @ISA = qw(Exporter);

our %EXPORT_TAGS = ( 'all' => [ qw(
) ] );

our @EXPORT_OK = ( @{ $EXPORT_TAGS{'all'} } );

our @EXPORT = qw( 
);

our $VERSION = '0.9';

use Class::DBI::Loader;
use Email::Valid;
use Email::Send;
use Carp;
use Email::Auth::AddressHash;
use Email::Simple;
use Email::Address;
use Mail::DeliveryStatus::BounceParser;
use Class::DBI::AbstractSearch;
use SQL::Abstract;
use Mail::Alias;
use Cwd qw(abs_path);


=head1 NAME

Nos - Notice Sender core module

=head1 SYNOPSIS

  use Nos;
  my $nos = new Nos();

=head1 DESCRIPTION

Notice sender is mail handler. It is not MTA, since it doesn't know how to
receive e-mails or send them directly to other hosts. It is not mail list
manager because it requires programming to add list members and send
messages. You can think of it as mechanisam for off-loading your e-mail
sending to remote server using SOAP service.

It's concept is based around B<lists>. Each list can have zero or more
B<members>. Each list can have zero or more B<messages>.

Here comes a twist: each outgoing message will have unique e-mail generated,
so Notice Sender will be able to link received replies (or bounces) with
outgoing messages.

It doesn't do much more than that. It B<can't> create MIME encoded e-mail,
send attachments, handle 8-bit characters in headers (which have to be
encoded) or anything else.

It will just queue your e-mail message to particular list (sending it to
possibly remote Notice Sender SOAP server just once), send it out at
reasonable rate (so that it doesn't flood your e-mail infrastructure) and
keep track replies.

It is best used to send small number of messages to more-or-less fixed
list of recipients while allowing individual responses to be examined.
Tipical use include replacing php e-mail sending code with SOAP call to
Notice Sender. It does support additional C<ext_id> field for each member
which can be used to track some unique identifier from remote system for
particular user.

It comes with command-line utility C<sender.pl> which can be used to perform
all available operation from scripts (see C<sender.pl --man>).
This command is also useful for debugging while writing client SOAP
application.

=head1 METHODS

=head2 new

Create new instance specifing database, user, password and options.

 my $nos = new Nos(
	dsn => 'dbi:Pg:dbname=notices',
	user => 'dpavlin',
	passwd => '',
	debug => 1,
	verbose => 1,
	hash_len => 8,
	full_hostname_in_aliases => 0,
 );

Parametar C<hash_len> defines length of hash which will be added to each
outgoing e-mail message to ensure that replies can be linked with sent e-mails.

C<full_hostname_in_aliases> will turn on old behaviour (not supported by Postfix
postalias) to include full hostname in aliases file.


=cut

sub new {
	my $class = shift;
	my $self = {@_};
	bless($self, $class);

	croak "need at least dsn" unless ($self->{'dsn'});

	$self->{'loader'} = Class::DBI::Loader->new(
		debug		=> $self->{'debug'},
		dsn		=> $self->{'dsn'},
		user		=> $self->{'user'},
		password	=> $self->{'passwd'},
		namespace	=> "Nos",
		additional_classes      => qw/Class::DBI::AbstractSearch/,
#		additional_base_classes => qw/My::Stuff/,
		relationships	=> 1,
	) || croak "can't init Class::DBI::Loader";

	$self->{'hash_len'} ||= 8;

	$self ? return $self : return undef;
}


=head2 create_list

Create new list. Required arguments are name of C<list>, C<email> address
and path to C<aliases> file.

 $nos->create_list(
 	list => 'My list',
	from => 'Outgoing from comment',
	email => 'my-list@example.com',
	aliases => '/etc/mail/mylist',
	archive => '/path/to/mbox/archive',
 );

Returns ID of newly created list.

Calls internally C<_add_list>, see details there.

=cut

sub create_list {
	my $self = shift;

	my $arg = {@_};

	confess "need list name" unless ($arg->{'list'});
	confess "need list email" unless ($arg->{'email'});

	$arg->{'list'} = lc($arg->{'list'});
	$arg->{'email'} = lc($arg->{'email'});

	my $l = $self->_get_list($arg->{'list'}) ||
		$self->_add_list( @_ ) ||
		return undef;

	return $l->id;
}


=head2 drop_list

Delete list from database.

 my $ok = drop_list(
 	list => 'My list'
	aliases => '/etc/mail/mylist',
 );

Returns false if list doesn't exist.

=cut

sub drop_list {
	my $self = shift;

	my $args = {@_};

	croak "need list to delete" unless ($args->{'list'});

	$args->{'list'} = lc($args->{'list'});

	my $aliases = $args->{'aliases'} || croak "need path to aliases file";

	my $lists = $self->{'loader'}->find_class('lists');

	my $this_list = $lists->search( name => $args->{'list'} )->first || return;

	$self->_remove_alias( email => $this_list->email, aliases => $aliases);

	$this_list->delete || croak "can't delete list\n";

	return $lists->dbi_commit || croak "can't commit";
}


=head2 add_member_to_list

Add new member to list

 $nos->add_member_to_list(
 	list => "My list",
	email => "john.doe@example.com",
	name => "John A. Doe",
	ext_id => 42,
 );

C<name> and C<ext_id> parametars are optional.

Return member ID if user is added.

=cut

sub add_member_to_list {
	my $self = shift;

	my $arg = {@_};

	my $email = lc($arg->{'email'}) || croak "can't add user without e-mail";
	my $name = $arg->{'name'} || '';
	my $list_name = lc($arg->{'list'}) || croak "need list name";
	my $ext_id = $arg->{'ext_id'};

	my $list = $self->_get_list($list_name) || croak "list $list_name doesn't exist";

	if (! Email::Valid->address($email)) {
		carp "SKIPPING $name <$email>\n";
		return 0;
	}

	carp "# $name <$email>\n" if ($self->{'verbose'});

	my $users = $self->{'loader'}->find_class('users');
	my $user_list = $self->{'loader'}->find_class('user_list');

	my $this_user = $users->find_or_create({
		email => $email,
	}) || croak "can't find or create member\n";

	if ($name && $this_user->name ne $name) {
		$this_user->name($name || '');
		$this_user->update;
	}

	if (defined($ext_id) && ($this_user->ext_id || '') ne $ext_id) {
		$this_user->ext_id($ext_id);
		$this_user->update;
	}

	my $user_on_list = $user_list->find_or_create({
		user_id => $this_user->id,
		list_id => $list->id,
	}) || croak "can't add user to list";

	$list->dbi_commit;
	$this_user->dbi_commit;
	$user_on_list->dbi_commit;

	return $this_user->id;
}

=head2 list_members

List all members of some list.

 my @members = list_members(
 	list => 'My list',
 );

Returns array of hashes with user information like this:

 $member = {
 	name => 'Dobrica Pavlinusic',
	email => 'dpavlin@rot13.org
 }

If list is not found, returns false. If there is C<ext_id> in user data,
it will also be returned.

=cut

sub list_members {
	my $self = shift;

	my $args = {@_};

	my $list_name = lc($args->{'list'}) || confess "need list name";

	my $lists = $self->{'loader'}->find_class('lists');
	my $user_list = $self->{'loader'}->find_class('user_list');

	my $this_list = $lists->search( name => $list_name )->first || return;

	my @results;

	foreach my $user_on_list ($user_list->search(list_id => $this_list->id)) {
		my $row = {
			name => $user_on_list->user_id->name,
			email => $user_on_list->user_id->email,
		};

		my $ext_id = $user_on_list->user_id->ext_id;
		$row->{'ext_id'} = $ext_id if (defined($ext_id));

		push @results, $row;
	}

	return @results;

}


=head2 delete_member

Delete member from database.

 my $ok = delete_member(
 	name => 'Dobrica Pavlinusic'
 );

 my $ok = delete_member(
 	email => 'dpavlin@rot13.org'
 );

Returns false if user doesn't exist.

This function will delete member from all lists (by cascading delete), so it
shouldn't be used lightly.

=cut

sub delete_member {
	my $self = shift;

	my $args = {@_};

	croak "need name or email of user to delete" unless ($args->{'name'} || $args->{'email'});

	$args->{'email'} = lc($args->{'email'}) if ($args->{'email'});

	my $key = 'name';
	$key = 'email' if ($args->{'email'});

	my $users = $self->{'loader'}->find_class('users');

	my $this_user = $users->search( $key => $args->{$key} )->first || return;

	$this_user->delete || croak "can't delete user\n";

	return $users->dbi_commit || croak "can't commit";
}

=head2 delete_member_from_list

Delete member from particular list.

 my $ok = delete_member_from_list(
 	list => 'My list',
 	email => 'dpavlin@rot13.org',
 );

Returns false if user doesn't exist on that particular list.

It will die if list or user doesn't exist. You have been warned (you might
want to eval this functon to prevent it from croaking).

=cut

sub delete_member_from_list {
	my $self = shift;

	my $args = {@_};

	croak "need list name and email of user to delete" unless ($args->{'list'} && $args->{'email'});

	$args->{'list'} = lc($args->{'list'});
	$args->{'email'} = lc($args->{'email'});

	my $user = $self->{'loader'}->find_class('users');
	my $list = $self->{'loader'}->find_class('lists');
	my $user_list = $self->{'loader'}->find_class('user_list');

	my $this_user = $user->search( email => $args->{'email'} )->first || croak "can't find user: ".$args->{'email'};
	my $this_list = $list->search( name => $args->{'list'} )->first || croak "can't find list: ".$args->{'list'};

	my $this_user_list = $user_list->search_where( list_id => $this_list->id, user_id => $this_user->id )->first || return;

	$this_user_list->delete || croak "can't delete user from list\n";

	return $user_list->dbi_commit || croak "can't commit";
}

=head2 add_message_to_list

Adds message to one list's queue for later sending.

 $nos->add_message_to_list(
 	list => 'My list',
 	message => 'Subject: welcome to list

 This is example message
 ',
 );	

On success returns ID of newly created (or existing) message.

Only required header in e-mail is C<Subject:>. C<From:> and C<To:> headers
will be automatically generated, but if you want to use own headers, just
include them in messages.

=cut

sub add_message_to_list {
	my $self = shift;

	my $args = {@_};

	my $list_name = lc($args->{'list'}) || confess "need list name";
	my $message_text = $args->{'message'} || croak "need message";

	my $m = Email::Simple->new($message_text) || croak "can't parse message";

	warn "message doesn't have Subject header\n" unless( $m->header('Subject') );

	my $lists = $self->{'loader'}->find_class('lists');

	my $this_list = $lists->search(
		name => $list_name,
	)->first || croak "can't find list $list_name";

	my $messages = $self->{'loader'}->find_class('messages');

	my $this_message = $messages->find_or_create({
		message => $message_text
	}) || croak "can't insert message";

	$this_message->dbi_commit() || croak "can't add message";

	my $queue = $self->{'loader'}->find_class('queue');

	$queue->find_or_create({
		message_id => $this_message->id,
		list_id => $this_list->id,
	}) || croak "can't add message ",$this_message->id," to list ",$this_list->id, ": ",$this_list->name;

	$queue->dbi_commit || croak "can't add message to list ",$this_list->name;

	return $this_message->id;
}


=head2 send_queued_messages

Send queued messages or just ones for selected list

 $nos->send_queued_messages(
 	list => 'My list',
	driver => 'smtp',
	sleep => 3,
	verbose => 1,
 );

Second option is driver which will be used for e-mail delivery. If not
specified, C<IO> driver will be used which will dump e-mail to C<STDERR>.

Other valid drivers are:

=over 10

=item smtp

Send e-mail using SMTP server at 127.0.0.1

=item verbose

Display diagnostic output to C<STDOUT> and C<STDERR>.

=back

Any other driver name will try to use C<Email::Send::that_driver> module.

Default sleep wait between two messages is 3 seconds.

This method will return number of succesfully sent messages.

=cut

sub send_queued_messages {
	my $self = shift;

	my $arg = {@_};

	my $list_name = lc($arg->{'list'}) || '';
	my $driver = $arg->{'driver'} || '';
	my $sleep = $arg->{'sleep'};
	my $verbose = $arg->{verbose};
	$sleep ||= 3 unless defined($sleep);

	# number of messages sent o.k.
	my $ok = 0;

	my $email_send_driver = 'Email::Send::IO';
	my @email_send_options;

	if (lc($driver) eq 'smtp') {
		$email_send_driver = 'Email::Send::SMTP';
		@email_send_options = ['127.0.0.1'];
	} elsif ($driver && $driver ne '') {
		$email_send_driver = 'Email::Send::' . $driver;
	} else {
		warn "dumping all messages to STDERR\n" if ($verbose);
	}

	my $lists = $self->{'loader'}->find_class('lists');
	my $queue = $self->{'loader'}->find_class('queue');
	my $user_list = $self->{'loader'}->find_class('user_list');
	my $sent = $self->{'loader'}->find_class('sent');

	my $my_q;
	if ($list_name ne '') {
		my $l_id = $lists->search_like( name => $list_name )->first ||
			croak "can't find list $list_name";
		$my_q = $queue->search_like( list_id => $l_id ) ||
			croak "can't find list $list_name";
	} else {
		$my_q = $queue->retrieve_all;
	}

	while (my $m = $my_q->next) {
		next if ($m->all_sent);

		print "sending message ",$m->message_id," enqueued on ",$m->date," to list ",$m->list_id->name,"\n" if ($verbose);
		my $msg = $m->message_id->message;

		foreach my $u ($user_list->search(list_id => $m->list_id)) {

			my $to_email = $u->user_id->email;

			my ($from,$domain) = split(/@/, $u->list_id->email, 2);

			if ($sent->search( message_id => $m->message_id, user_id => $u->user_id )) {
				print "SKIP $to_email message allready sent\n" if ($verbose);
			} else {
				print "=> $to_email " if ($verbose);

				my $secret = $m->list_id->name . " " . $u->user_id->email . " " . $m->message_id;
				my $auth = Email::Auth::AddressHash->new( $secret, $self->{'hash_len'} );

				my $hash = $auth->generate_hash( $to_email );

				my $from_addr;
				my $from_email_only = $from . "+" . $hash . ( $domain ? '@' . $domain : '');

				$from_addr .= '"' . $u->list_id->from_addr . '" ' if ($u->list_id->from_addr);
				$from_addr .= '<' . $from_email_only . '>';
				my $to = '"' . $u->user_id->name . '" <' . $to_email . '>';

				my $m_obj = Email::Simple->new($msg) || croak "can't parse message";

				$m_obj->header_set('Return-Path', $from_email_only) || croak "can't set Return-Path: header";
				#$m_obj->header_set('Sender', $from_email_only) || croak "can't set Sender: header";
				$m_obj->header_set('Errors-To', $from_email_only) || croak "can't set Errors-To: header";
				$m_obj->header_set('From', $from_addr) || croak "can't set From: header";
				$m_obj->header_set('To', $to) || croak "can't set To: header";

				$m_obj->header_set('X-Nos-Version', $VERSION);
				$m_obj->header_set('X-Nos-Hash', $hash);

				# really send e-mail
				my $sent_status;

				if (@email_send_options) {
					$sent_status = send $email_send_driver => $m_obj->as_string, @email_send_options;
				} else {
					$sent_status = send $email_send_driver => $m_obj->as_string;
				}

				croak "can't send e-mail: $sent_status\n\nOriginal e-mail follows:\n".$m_obj->as_string unless ($sent_status);
				my @bad;
				@bad = @{ $sent_status->prop('bad') } if (eval { $sent_status->can('prop') });
				croak "failed sending to ",join(",",@bad) if (@bad);

				if ($sent_status) {

					$sent->create({
						message_id => $m->message_id,
						user_id => $u->user_id,
						hash => $hash,
					});
					$sent->dbi_commit;

					print " - $sent_status\n" if ($verbose);

					$ok++;
				} else {
					warn "ERROR: $sent_status\n" if ($verbose);
				}

				if ($sleep) {
					warn "sleeping $sleep seconds\n" if ($verbose);
					sleep($sleep);
				}
			}
		}
		$m->all_sent(1);
		$m->update;
		$m->dbi_commit;
	}

	return $ok;

}

=head2 inbox_message

Receive single message for list's inbox.

 my $ok = $nos->inbox_message(
 	list => 'My list',
	message => $message,
 );

This method is used by C<sender.pl> when receiving e-mail messages.

=cut

sub inbox_message {
	my $self = shift;

	my $arg = {@_};

	return unless ($arg->{'message'});
	croak "need list name" unless ($arg->{'list'});

	$arg->{'list'} = lc($arg->{'list'});

	my $this_list = $self->_get_list($arg->{'list'}) || croak "can't find list ".$arg->{'list'}."\n";

	my $m = Email::Simple->new($arg->{'message'}) || croak "can't parse message";

	my $to = $m->header('To') || die "can't find To: address in incomming message\n";

	my $return_path = $m->header('Return-Path') || '';

	my @addrs = Email::Address->parse( $to );

	die "can't parse To: $to address\n" unless (@addrs);

	my $hl = $self->{'hash_len'} || confess "no hash_len?";

	my $hash;

	foreach my $a (@addrs) {
		if ($a->address =~ m/\+([a-f0-9]{$hl})@/i) {
			$hash = $1;
			last;
		}
	}

	#warn "can't find hash in e-mail $to\n" unless ($hash);

	my $sent = $self->{'loader'}->find_class('sent');

	# will use null if no matching message_id is found
	my $sent_msg;
	$sent_msg = $sent->search( hash => $hash )->first if ($hash);

	my ($message_id, $user_id) = (undef, undef);	# init with NULL

	if ($sent_msg) {
		$message_id = $sent_msg->message_id || carp "no message_id";
		$user_id = $sent_msg->user_id || carp "no user_id";
	} else {
		#warn "can't find sender with hash $hash\n";
		my $users = $self->{'loader'}->find_class('users');
		my $from = $m->header('From');
		$from = $1 if ($from =~ m/<(.*)>/);
		my $this_user = $users->search( email => lc($from) )->first;
		$user_id = $this_user->id if ($this_user);
	}


	my $is_bounce = 0;

	if ($return_path eq '<>' || $return_path eq '') {
		no warnings;
		my $bounce = eval { Mail::DeliveryStatus::BounceParser->new(
			$arg->{'message'}, { report_non_bounces=>1 },
		) };
		#warn "can't check if this message is bounce!" if ($@);
	
		$is_bounce++ if ($bounce && $bounce->is_bounce);
	}

	my $received = $self->{'loader'}->find_class('received');

	my $this_received = $received->find_or_create({
		user_id => $user_id,
		list_id => $this_list->id,
		message_id => $message_id,
		message => $arg->{'message'},
		bounced => $is_bounce,
	}) || croak "can't insert received message";

	$this_received->dbi_commit;

#	print "message_id: ",($message_id || "not found")," -- $is_bounce\n";
}

=head2 received_messages

Returns all received messages for given list or user.

 my @received = $nos->received_messages(
 	list => 'My list',
	email => "john.doe@example.com",
	from_date => '2005-01-01 10:15:00',
	to_date => '2005-01-01 12:00:00',
	message => 0,
 );

If don't specify C<list> or C<email> it will return all received messages.
Results will be sorted by received date, oldest first.

Other optional parametars include:

=over 10

=item from_date

Date (in ISO format) for lower limit of dates received

=item to_date

Return just messages older than this date

=item message

Include whole received message in result. This will probably make result
array very large. Use with care.

=back

Date ranges are inclusive, so results will include messages sent on
particular date specified with C<date_from> or C<date_to>.

Each element in returned array will have following structure:

 my $row = {
	id => 42,			# unique ID of received message
	list => 'My list',		# useful if filtering by email
	ext_id => 9999,			# ext_id from message sender
	email => 'jdoe@example.com',	# e-mail of message sender
	bounced => 0,			# true if message is bounce
	date => '2005-08-24 18:57:24',	# date of receival in ISO format
 }

If you specified C<message> option, this hash will also have C<message> key
which will contain whole received message.

=cut

sub received_messages {
	my $self = shift;

	my $arg = {@_} if (@_);

#	croak "need list name or email" unless ($arg->{'list'} || $arg->{'email'});

	my $sql = qq{
			select
				received.id as id,
				lists.name as list,
				users.ext_id as ext_id,
				users.email as email,
	};
	$sql .= qq{		message,} if ($arg->{'message'});
	$sql .= qq{
				bounced,received.date as date
			from received
			join lists on lists.id = list_id
			join users on users.id = user_id
	};

	my $order = qq{	order by date asc };

	my $where;

	$where->{'lists.name'} = lc($arg->{'list'}) if ($arg->{'list'});
	$where->{'users.email'} = lc($arg->{'email'}) if ($arg->{'email'});
	$where->{'received.date'} = { '>=', $arg->{'date_from'} } if ($arg->{'date_from'});
	$where->{'received.date'} = { '<=', $arg->{'date_to'} } if ($arg->{'date_to'});

	# hum, yammy one-liner
	my($stmt, @bind)  = SQL::Abstract->new->where($where);

	my $dbh = $self->{'loader'}->find_class('received')->db_Main;

	my $sth = $dbh->prepare($sql . $stmt . $order);
	$sth->execute(@bind);
	return $sth->fetchall_hash;
}


=head1 INTERNAL METHODS

Beware of dragons! You shouldn't need to call those methods directly.


=head2 _add_aliases

Add or update alias in C</etc/aliases> (or equivalent) file for selected list

 my $ok = $nos->add_aliases(
 	list => 'My list',
	email => 'my-list@example.com',
	aliases => '/etc/mail/mylist',
	archive => '/path/to/mbox/archive',

 );

C<archive> parametar is optional.

Return false on failure.

=cut

sub _add_aliases {
	my $self = shift;

	my $arg = {@_};

	foreach my $o (qw/list email aliases/) {
		croak "need $o option" unless ($arg->{$o});
	}

	my $aliases = $arg->{'aliases'};
	my $email = $arg->{'email'};
	my $list = $arg->{'list'};

	unless (-e $aliases) {
		warn "aliases file $aliases doesn't exist, creating empty\n";
		open(my $fh, '>', $aliases) || croak "can't create $aliases: $!";
		close($fh);
		chmod 0777, $aliases || warn "can't change permission to 0777";
	}

	die "FATAL: aliases file $aliases is not writable\n" unless (-w $aliases);

	my $a = new Mail::Alias($aliases) || croak "can't open aliases file $aliases: $!";

	my $target = '';

	if (my $archive = $arg->{'archive'}) {
		$target .= "$archive, ";

		if (! -e $archive) {
			warn "please make sure that file $archive is writable for your e-mail user (defaulting to bad 777 permission for now)";

			open(my $fh, '>', $archive) || croak "can't create archive file $archive: $!";
			close($fh);
			chmod 0777, $archive || croak "can't chmod archive file $archive to 0777: $!";
		}
	}

	# resolve my path to absolute one
	my $self_path = abs_path($0);
	$self_path =~ s#/[^/]+$##;
	$self_path =~ s#/t/*$#/#;

	$target .= qq#"| cd $self_path && ./sender.pl --inbox='$list'"#;

	# remove hostname from email to make Postfix's postalias happy
	$email =~ s/@.+// if (not $self->{full_hostname_in_aliases});

	if ($a->exists($email)) {
		$a->update($email, $target) or croak "can't update alias ".$a->error_check;
	} else {
		$a->append($email, $target) or croak "can't add alias ".$a->error_check;
	}

#	$a->write($aliases) or croak "can't save aliases $aliases ".$a->error_check;

	return 1;
}

=head2 _add_list

Create new list

 my $list_obj = $nos->_add_list(
 	list => 'My list',
	from => 'Outgoing from comment',
	email => 'my-list@example.com',
	aliases => '/etc/mail/mylist',
 );

Returns C<Class::DBI> object for created list.

C<email> address can be with domain or without it if your
MTA appends it. There is no checking for validity of your
list e-mail. Flexibility comes with resposibility, so please
feed correct (and configured) return addresses.

=cut

sub _add_list {
	my $self = shift;

	my $arg = {@_};

	my $name = lc($arg->{'list'}) || confess "can't add list without name";
	my $email = lc($arg->{'email'}) || confess "can't add list without e-mail";
	my $aliases = lc($arg->{'aliases'}) || confess "can't add list without path to aliases file";

	my $from_addr = $arg->{'from'};

	my $lists = $self->{'loader'}->find_class('lists');

	$self->_add_aliases(
		list => $name,
		email => $email,
		aliases => $aliases,
	) || warn "can't add alias $email for list $name";

	my $l = $lists->find_or_create({
		name => $name,
		email => $email,
	});

	croak "can't add list $name\n" unless ($l);

	if ($from_addr && $l->from_addr ne $from_addr) {
		$l->from_addr($from_addr);
		$l->update;
	}

	$l->dbi_commit;

	return $l;

}



=head2 _get_list

Get list C<Class::DBI> object.

 my $list_obj = $nos->check_list('My list');

Returns false on failure.

=cut

sub _get_list {
	my $self = shift;

	my $name = shift || return;

	my $lists = $self->{'loader'}->find_class('lists') || confess "can't find lists class";

	return $lists->search({ name => lc($name) })->first;
}


=head2 _remove_alias

Remove list alias

 my $ok = $nos->_remove_alias(
 	email => 'mylist@example.com',
	aliases => '/etc/mail/mylist',
 );

Returns true if list is removed or false if list doesn't exist. Dies in case of error.

=cut

sub _remove_alias {
	my $self = shift;

	my $arg = {@_};

	my $email = lc($arg->{'email'}) || confess "can't remove alias without email";
	my $aliases = lc($arg->{'aliases'}) || confess "can't remove alias without list";

	my $a = new Mail::Alias($aliases) || croak "can't open aliases file $aliases: $!";

	if ($a->exists($email)) {
		$a->delete($email) || croak "can't remove alias $email";
	} else {
		return 0;
	}

	return 1;

}

###
### SOAP
###

package Nos::SOAP;

use Carp;

=head1 SOAP methods

This methods are thin wrappers to provide SOAP calls. They are grouped in
C<Nos::SOAP> package which is in same F<Nos.pm> module file.

Usually, you want to use named variables in your SOAP calls if at all
possible.

However, if you have broken SOAP library (like PHP SOAP class from PEAR)
you will want to use positional arguments (in same order as documented for
methods below).

=cut

my $nos;


=head2 new

Create new SOAP object

 my $soap = new Nos::SOAP(
	dsn => 'dbi:Pg:dbname=notices',
	user => 'dpavlin',
	passwd => '',
	debug => 1,
	verbose => 1,
	hash_len => 8,
	aliases => '/etc/aliases',
 );

If you are writing SOAP server (like C<soap.cgi> example), you will need to
call this method once to make new instance of Nos::SOAP and specify C<dsn>
and options for it.

=cut

sub new {
	my $class = shift;
	my $self = {@_};

	croak "need aliases parametar" unless ($self->{'aliases'});

	bless($self, $class);

	$nos = new Nos( @_ ) || die "can't create Nos object";

	$self ? return $self : return undef;
}


=head2 CreateList

 $message_id = CreateList(
	list => 'My list',
	from => 'Name of my list',
	email => 'my-list@example.com'
 ); 

=cut

sub CreateList {
	my $self = shift;

	my $aliases = $nos->{'aliases'} || croak "need 'aliases' argument to new constructor";

	if ($_[0] !~ m/^HASH/) {
		return $nos->create_list(
			list => $_[0], from => $_[1], email => $_[2],
			aliases => $aliases,
		);
	} else {
		return $nos->create_list( %{ shift @_ }, aliases => $aliases );
	}
}


=head2 DropList

 $ok = DropList(
	list => 'My list',
 ); 

=cut

sub DropList {
	my $self = shift;

	my $aliases = $nos->{'aliases'} || croak "need 'aliases' argument to new constructor";

	if ($_[0] !~ m/^HASH/) {
		return $nos->drop_list(
			list => $_[0],
			aliases => $aliases,
		);
	} else {
		return $nos->drop_list( %{ shift @_ }, aliases => $aliases );
	}
}

=head2 AddMemberToList

 $member_id = AddMemberToList(
	list => 'My list',
	email => 'e-mail@example.com',
	name => 'Full Name',
	ext_id => 42,
 );

=cut

sub AddMemberToList {
	my $self = shift;

	if ($_[0] !~ m/^HASH/) {
		return $nos->add_member_to_list(
			list => $_[0], email => $_[1], name => $_[2], ext_id => $_[3],
		);
	} else {
		return $nos->add_member_to_list( %{ shift @_ } );
	}
}


=head2 ListMembers

 my @members = ListMembers(
 	list => 'My list',
 );

Returns array of hashes with user informations, see C<list_members>.

=cut

sub ListMembers {
	my $self = shift;

	my $list_name;

	if ($_[0] !~ m/^HASH/) {
		$list_name = shift;
	} else {
		$list_name = $_[0]->{'list'};
	}

	return [ $nos->list_members( list => $list_name ) ];
}


=head2 DeleteMemberFromList

 $member_id = DeleteMemberFromList(
	list => 'My list',
	email => 'e-mail@example.com',
 );

=cut

sub DeleteMemberFromList {
	my $self = shift;

	if ($_[0] !~ m/^HASH/) {
		return $nos->delete_member_from_list(
			list => $_[0], email => $_[1],
		);
	} else {
		return $nos->delete_member_from_list( %{ shift @_ } );
	}
}


=head2 AddMessageToList

 $message_id = AddMessageToList(
	list => 'My list',
	message => 'From: My list...'
 ); 

=cut

sub AddMessageToList {
	my $self = shift;

	if ($_[0] !~ m/^HASH/) {
		return $nos->add_message_to_list(
			list => $_[0], message => $_[1],
		);
	} else {
		return $nos->add_message_to_list( %{ shift @_ } );
	}
}

=head2 MessagesReceived

Return statistics about received messages.

 my @result = MessagesReceived(
 	list => 'My list',
	email => 'jdoe@example.com',
	from_date => '2005-01-01 10:15:00',
	to_date => '2005-01-01 12:00:00',
	message => 0,
 );

You must specify C<list> or C<email> or any combination of those two. Other
parametars are optional.

For format of returned array element see C<received_messages>.

=cut

sub MessagesReceived {
	my $self = shift;

	if ($_[0] !~ m/^HASH/) {
		die "need at least list or email" unless (scalar @_ < 2);
		return \@{ $nos->received_messages(
			list => $_[0], email => $_[1],
			from_date => $_[2], to_date => $_[3],
			message => $_[4]
		) };
	} else {
		my $arg = shift;
		die "need list or email argument" unless ($arg->{'list'} || $arg->{'email'});
		return \@{ $nos->received_messages( %{ $arg } ) };
	}
}

=head2 SendTest

Internal function which does e-mail sending using C<Email::Send::Test> driver.

  my $sent = SendTest( list => 'My list' );

Returns number of messages sent

=cut

sub SendTest {
	my $self = shift;
	my $args = shift;
	die "list name required" unless ($args->{list});

	require Email::Send::Test;

	my $nr_sent = $nos->send_queued_messages(
		list => $args->{list},
		driver => 'Test',
		sleep => 0,
		verbose => 0,
	);

	my @emails = Email::Send::Test->emails;

	open(my $tmp, ">/tmp/soap-debug");
	use Data::Dump qw/dump/;
	print $tmp "sent $nr_sent emails\n", dump(@emails);
	close($tmp);

	return $nr_sent;
}

###

=head1 NOTE ON ARRAYS IN SOAP

Returning arrays from SOAP calls is somewhat fuzzy (at least to me). It
seems that SOAP::Lite client thinks that it has array with one element which
is array of hashes with data.

=head1 EXPORT

Nothing.

=head1 SEE ALSO

mailman, ezmlm, sympa, L<Mail::Salsa>


=head1 AUTHOR

Dobrica Pavlinusic, E<lt>dpavlin@rot13.orgE<gt>


=head1 COPYRIGHT AND LICENSE

Copyright (C) 2005 by Dobrica Pavlinusic

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself, either Perl version 5.8.4 or,
at your option, any later version of Perl 5 you may have available.


=cut

1;