Net-MitDK/lib/Net/MitDK.pm
package Net::MitDK;
use strict;
use warnings;
our $VERSION = '0.07';
use Encode qw(encode decode);
use DateTime;
use MIME::Entity;
use MIME::Base64;
use IO::Lambda qw(:all);
use IO::Lambda::HTTP::Client;
use IO::Lambda::HTTP::UserAgent;
use HTTP::Request::Common;
use JSON::XS qw(encode_json decode_json);
sub new
{
my ( $class, %opt ) = @_;
my $self = bless {
profile => 'default',
ua => IO::Lambda::HTTP::UserAgent->new,
root => 'https://gateway.mit.dk/view/client',
mgr => Net::MitDK::ProfileManager->new,
session => {},
config => {},
%opt,
}, $class;
$self->mgr->homepath( $opt{homepath}) if defined $opt{homepath};
if ( defined $self->{profile}) {
my ($config, $error) = $self->mgr->load( $self->profile );
return (undef, $error) unless $config;
$self->{config} = $config;
}
return $self;
}
sub config { $_[0]->{config} }
sub refresh_config
{
my $self = shift;
if ( $self->mgr->refresh_needed( $self->profile ) ) {
my ($config, $error) = $self->mgr->load( $self->profile );
return (undef, $error) unless $config;
$self->{config} = $config;
}
return 1;
}
sub ua { $_[0]->{ua} }
sub root { $_[0]->{root} }
sub mgr { $_[0]->{mgr} }
sub token { $_[0]->{config}->{token} }
sub profile
{
return $_[0]->{profile} unless $#_;
my ( $self, $profile ) = @_;
return undef if $profile eq $self->{profile};
my ($config, $error) = $self->mgr->load( $profile );
return $error unless $config;
$self->{session} = {};
$self->{config} = $config;
$self->{profile} = $profile;
return undef;
}
sub request
{
my ($self, $method, $uri, $content, $options) = @_;
my ($ok, $error) = $self->refresh_config;
return lambda { undef, $error } unless $ok;
my %extra;
if ($method eq 'get' ) {
$method = \&HTTP::Request::Common::GET;
$options = $content;
} else {
$method = \&HTTP::Request::Common::POST;
$extra{content} = encode_json($content);
$extra{'content-type'} = 'application/json';
}
$options //= {};
lambda {
my $token = $self->config->{token};
context $self->ua->request( $method->(
$self->root . '/' . $uri,
ngdptoken => $token->{ngdp}->{access_token},
mitdktoken => $token->{dpp}->{access_token},
%extra
));
tail {
my $response = shift;
return (undef, $response) unless ref $response;
my $json;
unless ($response->is_success) {
if ( $response->header('content-type') eq 'application/json') {
eval { $json = decode_json($response->content) };
goto PLAIN if $@;
goto PLAIN if grep { ! exists $json->{$_} } qw(code message);
my $err = "$json->{code}: $json->{message}";
$err .= "(" . join(' ', @{$_->{fieldError}}) . ')'
if $json->{fieldError} && ref($_->{fieldError}) eq 'ARRAY';
return undef, $err;
} else {
PLAIN:
return undef, $response->content
}
}
return $response if $options->{raw};
return undef, 'invalid content'
unless $response->header('Content-Type') eq 'application/json';
eval { $json = decode_json($response->content) };
return undef, "invalid response ($@)"
unless $json;
if ( $json->{errorMessages} && ref($json->{errorMessages}) eq 'ARRAY') {
$error = join("\n", map {
my $err = "$_->{code}: $_->{message}";
$err .= "(" . join(' ', @{$_->{fieldError}}) . ')'
if $_->{fieldError} && ref($_->{fieldError}) eq 'ARRAY';
$err
} @{ $json->{errorMessages} });
return undef, $error if length $error;
}
return $json;
}};
}
sub get { shift->request( get => @_ ) }
sub post { shift->request( post => @_ ) }
sub first_login
{
my ($self, $json) = @_;
return $self->authorization_refresh( $json->{refresh_token}, $json->{ngdp}->{refresh_token});
}
sub renew_lease
{
my ($self) = @_;
my $token = $self->config->{token};
return $self->authorization_refresh( $token->{dpp}->{refresh_token}, $token->{ngdp}->{refresh_token});
}
sub update_config
{
my $self = shift;
return $self->mgr->save( $self->profile, $self->{config});
}
sub authorization_refresh
{
my ($self, $dpp, $ngdp) = @_;
return lambda {
context $self->post('authorization/refresh?client_id=view-client-id-mobile-prod-1-id' => {
dppRefreshToken => $dpp,
ngdpRefreshToken => $ngdp,
});
tail {
my ($json, $error) = @_;
return $json, $error unless $json;
return undef, "bad response:".encode_json($json) unless exists $json->{dpp} and exists $json->{ngdp};
$self->{config}->{token} = $json;
return $self->update_config;
}}
}
sub mailboxes
{
my $self = shift;
return lambda {
return $self->{session}->{mailboxes} if $self->{session}->{mailboxes};
context $self->get('mailboxes');
tail {
my ( $json, $error ) = @_;
return ($json, $error) unless $json;
($json) = grep { $_->{dataSource} eq 'DP_PUBLIC' } @{$json->{groupedMailboxes}->[0]->{mailboxes}};
return (undef, "mailboxes: bad structure") unless $json;
return $self->{session}->{mailboxes} = $json;
}};
}
sub folders
{
my $self = shift;
return lambda {
return $self->{session}->{folders} if $self->{session}->{folders};
context $self-> mailboxes;
tail {
return @_ unless $_[0];
context $self->post('folders/query' => {
"mailboxes" => { DP_PUBLIC => $self->{session}->{mailboxes}->{id} }
});
tail {
my ( $json, $errors ) = @_;
return ($json, $errors) unless $json;
my %folders;
while ( my ( $k, $v ) = each %{$json->{folders}}) {
$folders{$k} = $v->[0]->{id};
}
return (undef, "folders: bad structure") unless keys %folders;
return $self->{session}->{folders} = \%folders;
}}};
}
sub messages
{
my ( $self, $offset, $limit ) = @_;
return lambda {
context $self-> folders;
tail {
return @_ unless $_[0];
my $session = $self->{session};
context $self->post('messages/query' => {
size => $limit,
sortFields => ["receivedDateTime:DESC"],
folders => [{
dataSource => 'DP_PUBLIC',
foldersId => [$session->{folders}->{INBOX}],
mailboxId => $session->{mailboxes}->{id},
startIndex => $offset,
}],
});
tail {
@_
}}};
}
sub list_all_messages
{
my $self = shift;
my $offset = 0;
my $limit = 100;
my @ret;
return lambda {
context $self->messages($offset, $limit);
tail {
my ($json, $error) = @_;
return ($json, $error) unless $json;
push @ret, @{ $json->{results} };
return \@ret if @{ $json->{results} } < $limit;
$offset += $limit;
context $self->messages($offset, $limit);
again;
}};
}
sub fetch_file
{
my ( $self, $message, $document, $file ) = @_;
return $self->get('DP_PUBLIC/' .
"mailboxes/$self->{session}->{mailboxes}->{id}/" .
"messages/$message->{id}/" .
"documents/$message->{documents}->[$document]->{id}/" .
"files/$message->{documents}->[$document]->{files}->[$file]->{id}/".
"content",
{raw => 1},
);
}
sub fetch_message_and_attachments
{
my ($self, $message, %opt) = @_;
my @ret;
my @errors;
my $error_policy = $opt{error_policy} // 'default';
return lambda {
my @files;
my ( $ndoc, $nfile ) = (0,0);
for my $doc ( @{ $message->{documents} } ) {
for my $file ( @{ $doc->{files} } ) {
push @files, [ $ndoc, $nfile++ ];
}
$nfile = 0;
$ndoc++;
}
return [] unless @files;
($ndoc, $nfile) = @{ shift @files };
context $self-> fetch_file($message, $ndoc, $nfile);
tail {
my ($resp, $error) = @_;
unless ( defined $resp ) {
if ( $error_policy eq 'strict') {
return ($resp, $error);
} elsif ( $error_policy eq 'warning') {
push @errors, $error;
} else {
push @errors, $error;
push @ret, [ $ndoc, $nfile, $error ];
}
} else {
push @ret, [ $ndoc, $nfile, $resp->content ];
}
unless ( @files ) {
# if at least one attachment is successful, treat errors as warnings
return \@ret, undef, @errors if @ret;
return undef, $errors[0];
}
($ndoc, $nfile) = @{ shift @files };
context $self-> fetch_file($message, $ndoc, $nfile);
again;
}};
}
sub safe_encode
{
my ($enc, $text) = @_;
utf8::downgrade($text, 'fail silently please');
return (utf8::is_utf8($text) || $text =~ /[\x80-\xff]/) ? encode($enc, $text) : $text;
}
sub assemble_mail
{
my ( $self, $msg, $attachments ) = @_;
my $sender = $msg->{sender}->{label};
my $received = $msg->{receivedDateTime} // '';
my $date;
if ( $received =~ /^(\d{4})-(\d{2})-(\d{2})T(\d\d):(\d\d):(\d\d)/) {
$date = DateTime->new(
year => $1,
month => $2,
day => $3,
hour => $4,
minute => $5,
second => $6,
);
} else {
$date = DateTime->now;
}
$received = $date->strftime('%a, %d %b %Y %H:%M:%S %z');
my $from = $self->config->{email_from} // 'noreply@mit.dk';
my $mail = MIME::Entity->build(
From => ( safe_encode('MIME-Q', $sender) . " <$from>" ) ,
To => ( safe_encode('MIME-Q', $self->{session}->{mailboxes}->{ownerName}) . ' <' . ( $ENV{USER} // 'you' ) . '@localhost>' ),
Subject => safe_encode('MIME-Header', $msg->{label}),
Data => encode('utf-8', "Mail from $sender"),
Date => $received,
Charset => 'utf-8',
Encoding => 'quoted-printable',
'X-Net-MitDK' => "v/$VERSION",
);
for ( @$attachments ) {
my ( $ndoc, $nfile, $body ) = @$_;
my $file = $msg->{documents}->[$ndoc]->{files}->[$nfile];
my $fn = $file->{filename};
Encode::_utf8_off($body);
my $entity = $mail->attach(
Type => $file->{encodingFormat},
Encoding => 'base64',
Data => $body,
Filename => $fn,
);
# XXX hack filename for utf8
next unless $fn =~ m/[^\x00-\x80]/;
$fn = Encode::encode('MIME-B', $fn);
for ( 'Content-disposition', 'Content-type') {
my $v = $entity->head->get($_);
$v =~ s/name="(.*)"/name="$fn"/;
$entity->head->replace($_, $v);
}
}
return
'From noreply@localhost ' .
$date->strftime('%a %b %d %H:%M:%S %Y') . "\n" .
$mail->stringify
;
}
package
Net::MitDK::ProfileManager;
use Fcntl ':seek', ':flock';
use JSON::XS qw(encode_json decode_json);
sub new
{
my $self = bless {
timestamps => {},
homepath => undef,
readonly => 0,
}, shift;
return $self;
}
sub _homepath
{
if ( exists $ENV{HOME}) {
return $ENV{HOME};
} elsif ( $^O =~ /win/i && exists $ENV{USERPROFILE}) {
return $ENV{USERPROFILE};
} elsif ( $^O =~ /win/i && exists $ENV{WINDIR}) {
return $ENV{WINDIR};
} else {
return '.';
}
}
sub readonly { $#_ ? $_[0]->{readonly} = $_[1] : $_[0]->{readonly} }
sub homepath
{
$#_ ? $_[0]->{homepath} = $_[1] : ($_[0]->{homepath} // _homepath . '/.mitdk')
}
sub list
{
my $self = shift;
my $home = $self->homepath;
return unless -d $home;
my @list;
for my $profile ( <$home/*.profile> ) {
$profile =~ m[\/([^\/]+)\.profile] or next;
push @list, $1;
}
return @list;
}
sub create
{
my ($self, $profile, %opt) = @_;
my $file = $self->homepath . "/$profile.profile";
if ( -f $file ) {
return 2 if $opt{ok_if_exists};
return (undef, "Profile exists already");
}
return $self->save($profile, $opt{payload} // {} );
}
sub lock
{
my $f = shift;
return 1 if flock( $f, LOCK_NB | LOCK_EX);
sleep(1);
return 1 if flock( $f, LOCK_NB | LOCK_EX);
sleep(1);
return flock( $f, LOCK_NB | LOCK_EX);
}
sub load
{
my ($self, $profile ) = @_;
my $file = $self->homepath . "/$profile.profile";
return (undef, "No such profile") unless -f $file;
local $/;
open my $f, "<", $file or return (0, "Cannot open $file:$!");
return (undef, "Cannot acquire lock on $file") unless lock($f);
my $r = <$f>;
close $f;
my $json;
eval { $json = decode_json($r) };
return (undef, "Corrupted profile $file: $@") unless $json;
$self->{timestamps}->{$profile} = time;
return $json;
}
sub save
{
my ($self, $profile, $hash) = @_;
return (undef, "$profile is readonly") if $self->readonly;
my $home = $self->homepath;
unless ( -d $home ) {
mkdir $home or return (undef, "Cannot create $home: $!");
return (undef, "cannot chmod 0750 $home:$!") unless chmod 0750, $home;
if ( $^O !~ /win32/i) {
my (undef,undef,$gid) = getgrnam('nobody');
return (undef, "no group `nobody`") unless defined $gid;
return (undef, "cannot chown user:nobody $home:$!") unless chown $>, $gid, $home;
}
}
my $json;
my $encoder = JSON::XS->new->ascii->pretty;
eval { $json = $encoder->encode($hash) };
return (undef, "Cannot serialize profile: $!") if $@;
my $file = "$home/$profile.profile";
my $f;
if ( -f $file ) {
open $f, "+<", $file or return (undef, "Cannot create $file:$!");
return (undef, "Cannot acquire lock on $file") unless lock($f);
seek $f, 0, SEEK_SET;
truncate $f, 0 or return (undef, "Cannot save $file:$!");
} else {
open $f, ">", $file or return (undef, "Cannot create $file:$!");
}
print $f $json or return (undef, "Cannot save $file:$!");
close $f or return (undef, "Cannot save $file:$!");
if ( $^O !~ /win32/i) {
return (undef, "cannot chmod 0640 $file:$!") unless chmod 0640, $file;
my (undef,undef,$gid) = getgrnam('nobody');
return (undef, "no group `nobody`") unless defined $gid;
return (undef, "cannot chown user:nobody $file:$!") unless chown $>, $gid, $file;
}
$self->{timestamps}->{$profile} = time;
return 1;
}
sub remove
{
my ($self, $profile) = @_;
unlink $self->homepath . "/$profile.profile" or return (undef, "Cannot remove $profile:$!");
return 1;
}
sub refresh_needed
{
my ( $self, $profile ) = @_;
return 0 unless exists $self->{timestamps}->{$profile};
my $file = $self->homepath . "/$profile.profile";
my ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,$atime,$mtime,$ctime,$blksize,$blocks) = stat($file);
return 0 unless defined $mtime;
return $mtime > $self->{timestamps}->{$profile};
}
1;
=pod
=head1 NAME
Net::MitDK - perl API for http://mit.dk/
=head1 DESCRIPTION
Read-only interface for MitDK. See README for more info.
=head1 AUTHOR
Dmitry Karasik <dmitry@karasik.eu.org>
=cut