First commit working UI V1

This commit is contained in:
Tom Bloor 2019-08-03 19:10:09 +01:00
commit 324a7c6a5e
Signed by: TBSliver
GPG key ID: 4657C7EBE42CC5CC
49 changed files with 23199 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
dbic.conf
dump.conf
my-mailserver.production.conf
my-mailserver.*.conf
mail/*

60
bin/alias.pl Normal file
View file

@ -0,0 +1,60 @@
#! /usr/bin/env perl
use strict;
use warnings;
use 5.020;
use Getopt::Long::Descriptive;
use lib 'lib';
use My::Mailserver::Schema;
my ( $opt, $usage ) = describe_options(
"domain.pl %o",
[ 'source|s=s', 'The source of the alias' ],
[ 'target|t=s', 'The destination for the alias' ],
[ 'domain|d=s', 'The domain to operate on' ],
[ 'add|a', 'Add a new domain (requires domain)' ],
[ 'remove|r', 'Remove a domain (requires domain)' ],
[ 'list|l', 'List all domains' ],
[ 'help', 'Print usage message and exit', { shortcircuit => 1 } ],
);
say( $usage->text ), exit if $opt->help;
my $schema = My::Mailserver::Schema->connect('MAILSERVER_MANAGER');
my $rs = $schema->resultset('VirtualAlias');
if ( $opt->list ) {
my $line = "%5s | %20s | %20s | %20s\n";
printf $line, 'ID', 'Domain', 'Alias', 'Destination';
while ( my $result = $rs->next ) {
printf $line, $result->id, $result->domain->name, $result->source, $result->destination;
}
}
elsif ( $opt->domain && $opt->source && $opt->target ) {
my $domain = $schema->resultset('VirtualDomain')->find({ name => $opt->domain });
my $alias = $domain->find_or_new_related('virtual_aliases', {
source => $opt->source,
destination => $opt->target
});
if ( $opt->add && $domain ) {
if ( $alias->in_storage ) {
die "Alias already exists";
} else {
$alias->insert;
say "Created new alias";
}
}
elsif ( $opt->remove ) {
if ( $alias->in_storage ) {
$alias->delete;
say "Deleted alias";
} else {
die "Alias not found";
}
}
}
else {
die "Must provide domain, source, and target";
}

55
bin/domain.pl Normal file
View file

@ -0,0 +1,55 @@
#! /usr/bin/env perl
use strict;
use warnings;
use 5.020;
use Getopt::Long::Descriptive;
use lib 'lib';
use My::Mailserver::Schema;
my ( $opt, $usage ) = describe_options(
"domain.pl %o",
[ 'domain|d=s', 'The domain to operate on' ],
[ 'add|a', 'Add a new domain (requires domain)' ],
[ 'remove|r', 'Remove a domain (requires domain)' ],
[ 'list|l', 'List all domains' ],
[ 'help', 'Print usage message and exit', { shortcircuit => 1 } ],
);
say( $usage->text ), exit if $opt->help;
my $schema = My::Mailserver::Schema->connect('MAILSERVER_MANAGER');
my $rs = $schema->resultset('VirtualDomain');
if ( $opt->list ) {
my $line = "%5s | %70s\n";
printf $line, 'ID', 'Domain';
while ( my $result = $rs->next ) {
printf $line, $result->id, $result->name;
}
}
elsif ( $opt->add && $opt->domain ) {
my $domain = $rs->find_or_new({ name => $opt->domain });
if ( $domain->in_storage ) {
die "Domain already exists";
} else {
$domain->insert;
say "Created new domain";
}
}
elsif ($opt->remove && $opt->domain ) {
my $domain = $rs->find({name => $opt->domain });
if ( $domain ) {
$domain->delete;
say "Deleted domain";
} else {
die "Domain not found";
}
}
else {
die "Must provide domain";
}

72
bin/user.pl Normal file
View file

@ -0,0 +1,72 @@
#! /usr/bin/env perl
use strict;
use warnings;
use 5.020;
use Getopt::Long::Descriptive;
use lib 'lib';
use My::Mailserver::Schema;
my ( $opt, $usage ) = describe_options(
"domain.pl %o",
[ 'email|e=s', 'The users new email' ],
[ 'password|p=s', 'The password for the user' ],
[ 'domain|d=s', 'The domain to operate on' ],
[ 'add|a', 'Add a new user (requires domain, email and password)' ],
[ 'remove|r', 'Remove a user (requires domain and email)' ],
[ 'update|u', 'Update user (requires domain, email and password' ],
[ 'list|l', 'List all users' ],
[ 'help', 'Print usage message and exit', { shortcircuit => 1 } ],
);
say( $usage->text ), exit if $opt->help;
my $schema = My::Mailserver::Schema->connect('MAILSERVER_MANAGER');
my $rs = $schema->resultset('VirtualUser');
if ( $opt->list ) {
my $line = "%5s | %20s | %20s\n";
printf $line, 'ID', 'Domain', 'Email';
while ( my $result = $rs->next ) {
printf $line, $result->id, $result->domain->name, $result->email;
}
}
elsif ( $opt->domain && $opt->email ) {
my $domain = $schema->resultset('VirtualDomain')->find({ name => $opt->domain });
my $alias = $domain->find_or_new_related('virtual_users', {
email => $opt->email,
});
if ( $opt->add && $domain ) {
if ( $alias->in_storage ) {
die "Alias already exists";
} elsif( $opt->password ) {
$alias->password( $opt->password );
$alias->insert;
say "Created new user";
} else {
die "Requires password";
}
}
elsif ( $opt->update && $domain ) {
if ( $alias->in_storage ) {
$alias->password( $opt->password );
$alias->update;
say "Updated password";
} else {
die "User not found";
}
}
elsif ( $opt->remove ) {
if ( $alias->in_storage ) {
$alias->delete;
say "Deleted user";
} else {
die "User not found";
}
}
}
else {
die "Must provide domain and email";
}

13
dbic.conf.example Normal file
View file

@ -0,0 +1,13 @@
<MAILSERVER_MANAGER>
dsn dbi:mysql:mailserver
user some_username
password some_password
</MAILSERVER_MANAGER>
<MAILSERVER_RUNTIME>
dsn dbi:mysql:mailserver
user some_username
password some_password
</MAILSERVER_RUNTIME>
<MAILSERVER_TESTING>
dsn dbi:sqlite::memory:
</MAILSERVER_TESTING>

90
lib/My/Mailserver.pm Normal file
View file

@ -0,0 +1,90 @@
package My::Mailserver;
use Mojo::Base 'Mojolicious';
use My::Mailserver::Schema;
use Mojo::Loader 'load_class';
has db => sub {
my $c = shift;
return My::Mailserver::Schema->connect($c->app->config->{db_config});
};
has email_transport => sub {
my $c = shift;
my $transport = $c->app->config->{mail_transport};
my $transport_class = "Email::Sender::Transport::$transport";
my $e = load_class $transport_class;
die qq{Loading "$transport_class" failed: $e} if ref $e;
return $transport_class->new($c->app->config->{mail_transport_config} // {});
};
# This method will run once at server start
sub startup {
my $self = shift;
push @{ $self->commands->namespaces }, 'My::Mailserver::Command';
# Load configuration from hash returned by config file
my $config = $self->plugin('Config', {
default => {
mail_transport => 'Maildir',
mail_transport_config => {
dir => $self->home->child('mail'),
},
},
});
# Configure the application
$self->secrets($config->{secrets});
$self->plugin('Authentication' => {
'load_user' => sub {
my ( $c, $user_id ) = @_;
return $c->schema->resultset('VirtualUser')->find($user_id);
},
'validate_user' => sub {
my ( $c, $email, $password, $args) = @_;
my $user = $c->schema->resultset('VirtualUser')->find({email => $email});
if ( defined $user ) {
if ( $user->check_password( $password ) ) {
return $user->id;
}
}
return;
},
});
$self->helper( schema => sub { $self->db } );
$self->helper( email_transport => sub { $self->email_transport });
# Router
my $r = $self->routes;
$r->get('/')->to('auth#login_get');
$r->post('/')->to('auth#login_post');
$r->post('/recover')->to('auth#recover_post');
$r->get('/reset/:code')->to('auth#reset_get');
$r->post('/reset/:code')->to('auth#reset_post');
$r->any('/logout')->to('auth#logout_any');
my $auth_route = $r->under('/dashboard')->to('auth#under');
$auth_route->get('/')->to('dashboard#index');
$auth_route->post('/change_password')->to('dashboard#change_password');
$auth_route->post('/change_recovery')->to('dashboard#change_recovery');
my $manager_route = $auth_route->under('/manager')->to('auth#is_manager');
$manager_route->get('/')->to('manager#index');
$manager_route->get('/domains')->to('manager#domains');
$manager_route->get('/users')->to('manager#users');
$manager_route->get('/alias')->to('manager#alias');
$manager_route->post('/add_domain')->to('manager#add_domain');
$manager_route->post('/del_domain')->to('manager#del_domain');
$manager_route->post('/add_user')->to('manager#add_user');
$manager_route->post('/del_user')->to('manager#del_user');
$manager_route->post('/add_alias')->to('manager#add_alias');
$manager_route->post('/del_alias')->to('manager#del_alias');
}
1;

View file

@ -0,0 +1,52 @@
package My::Mailserver::Command::roundcube_password;
use Mojo::Base 'Mojolicious::Command';
use Mojo::Util qw/ getopt dumper /;
has description => sub { 'Manage passwords using roundcube password plugin' };
has usage => sub { shift->extract_usage };
sub run {
my ( $self, @args ) = @_;
my $command = '';
print dumper \@args;
getopt \@args,
'command=s' => \$command,
'user=s' => \my $user,
'old_pass=s' => \my $old_pass,
'new_pass=s' => \my $new_pass;
print dumper $command;
if ( $command eq 'save' ) { $self->save( $old_pass, $new_pass, $user ) }
else { die "Command not recognised" }
}
sub save {
my ( $self, $old_pass, $new_pass, $email ) = @_;
my $user = $self->app->schema->resultset('VirtualUser')->find({ email => $email });
if ( $user ) {
print dumper $user->email;
if ( $user->check_password( $old_pass ) ) {
$user->password( $new_pass );
$user->update;
print "PASSWORD_SUCCESS";
} else {
print "PASSWORD_ERROR";
}
} else {
print "PASSWORD_CONNECT_ERROR";
}
}
1;
=head1 SYNOPSIS
Usage: APPLICATION roundcube_password [OPTIONS]
=cut

View file

@ -0,0 +1,90 @@
package My::Mailserver::Controller::Auth;
use Mojo::Base 'Mojolicious::Controller';
use Email::Stuffer;
sub under {
my $c = shift;
if ($c->is_user_authenticated) {
$c->current_user->recovery_codes->delete;
return 1;
}
$c->redirect_to('/');
return 0;
}
sub is_manager {
...
}
sub login_get {
my $c = shift;
$c->redirect_to('/dashboard') if $c->is_user_authenticated;
}
sub login_post {
my $c = shift;
my $v = $c->validation;
$v->csrf_protect;
$v->required('email');
$v->required('password');
if ($v->is_valid && $c->authenticate($v->param('email'), $v->param('password'))) {
return $c->redirect_to('/dashboard');
};
return $c->redirect_to('/');
}
sub recover_post {
my $c = shift;
my $v = $c->validation;
$v->csrf_protect;
$v->required('recovery_email');
if ($v->is_valid) {
my $email = $c->schema->resultset('VirtualUser')->find({email => $v->param('recovery_email')});
if (defined $email) {
my $recovery = $email->new_recovery_code;
$c->app->log->debug('Created recovery token: ' . $recovery->code);
my $url = $c->url_for('/reset/' . $recovery->code)->to_abs;
my $email_body = $c->render_to_string(
template => '_email/reset',
email => $email,
url => $url,
format => 'txt',
);
Email::Stuffer
->from('postmaster@' . $email->domain->name)
->to($email->email)
->subject('Password Reset')
->text_body($email_body)
->transport($c->email_transport)
->send_or_die;
} else {
$c->app->log->debug('Email not found');
}
} else {
$c->app->log->debug('Invalid Form');
}
# You'l never know.... :D
$c->flash(success => 'If that email exists, a recovery link has been sent');
return $c->redirect_to('/');
}
sub reset_get {
my $self = shift;
}
sub reset_post {
my $self = shift;
}
sub logout_any {
my $c = shift;
$c->logout;
$c->redirect_to('/');
}
1;

View file

@ -0,0 +1,60 @@
package My::Mailserver::Controller::Dashboard;
use Mojo::Base 'Mojolicious::Controller';
sub index {
my $c = shift;
$c->stash->{domain} = $c->current_user->domain;
$c->stash->{aliases} = $c->schema->resultset('VirtualAlias')->search(
{ destination => $c->current_user->email },
{ order_by => 'source' }
);
$c->stash->{recovery_email} = $c->current_user->recovery_email;
}
sub change_password {
my $c = shift;
my $v = $c->validation;
$v->csrf_protect;
$v->required('current_password');
$v->required('new_password');
$v->required('confirm_password');
if ($v->is_valid) {
if ($c->current_user->check_password($v->param('current_password'))) {
if ($v->param('new_password') eq $v->param('confirm_password')) {
$c->current_user->password($v->param('new_password'));
$c->current_user->update;
$c->flash(success => 'Password changed successfully');
} else {
$c->flash(error => 'New and Confirm password must match');
}
} else {
$c->flash(error => 'Current password incorrect');
}
} else {
$c->app->log->debug('invalid form');
$c->flash(error => 'All form fields required');
}
$c->redirect_to('/dashboard');
}
sub change_recovery {
my $c = shift;
my $v = $c->validation;
$v->csrf_protect;
$v->required('recovery_email');
if ($v->is_valid) {
$c->current_user->recovery_email($v->param('recovery_email'));
$c->current_user->update;
$c->flash(success => 'Recovery email changed successfully');
} else {
$c->flash(error => 'Failed to change recovery email');
}
$c->redirect_to('/dashboard');
}
1;

View file

@ -0,0 +1,8 @@
package My::Mailserver::Controller::Manager;
use Mojo::Base 'Mojolicious::Controller';
sub index {
my $self = shift;
}
1;

View file

@ -0,0 +1,20 @@
use utf8;
package My::Mailserver::Schema;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
use strict;
use warnings;
use base 'DBIx::Class::Schema::Config';
__PACKAGE__->load_namespaces;
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2019-06-23 14:49:31
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:2K/yVBuVVVwGBBwJAlOoyw
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,99 @@
use utf8;
package My::Mailserver::Schema::Result::ManagersDomain;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
My::Mailserver::Schema::Result::ManagersDomain
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<managers_domains>
=cut
__PACKAGE__->table("managers_domains");
=head1 ACCESSORS
=head2 domain_id
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=head2 manager_id
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=cut
__PACKAGE__->add_columns(
"domain_id",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
"manager_id",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
);
=head1 PRIMARY KEY
=over 4
=item * L</domain_id>
=item * L</manager_id>
=back
=cut
__PACKAGE__->set_primary_key("domain_id", "manager_id");
=head1 RELATIONS
=head2 domain
Type: belongs_to
Related object: L<My::Mailserver::Schema::Result::VirtualDomain>
=cut
__PACKAGE__->belongs_to(
"domain",
"My::Mailserver::Schema::Result::VirtualDomain",
{ id => "domain_id" },
{ is_deferrable => 1, on_delete => "RESTRICT", on_update => "RESTRICT" },
);
=head2 manager
Type: belongs_to
Related object: L<My::Mailserver::Schema::Result::VirtualUser>
=cut
__PACKAGE__->belongs_to(
"manager",
"My::Mailserver::Schema::Result::VirtualUser",
{ id => "manager_id" },
{ is_deferrable => 1, on_delete => "RESTRICT", on_update => "RESTRICT" },
);
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2019-06-17 02:39:38
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:N9hivlqaxQB72BI7BH9tZA
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,118 @@
use utf8;
package My::Mailserver::Schema::Result::RecoveryCode;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
My::Mailserver::Schema::Result::RecoveryCode
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<recovery_codes>
=cut
__PACKAGE__->table("recovery_codes");
=head1 ACCESSORS
=head2 id
data_type: 'integer'
is_auto_increment: 1
is_nullable: 0
=head2 user_id
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=head2 code
data_type: 'varchar'
is_nullable: 0
size: 255
=head2 created
data_type: 'timestamp'
datetime_undef_if_invalid: 1
default_value: 'current_timestamp()'
is_nullable: 0
=cut
__PACKAGE__->add_columns(
"id",
{ data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
"user_id",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
"code",
{ data_type => "varchar", is_nullable => 0, size => 255 },
"created",
{
data_type => "timestamp",
datetime_undef_if_invalid => 1,
default_value => "current_timestamp()",
is_nullable => 0,
},
);
=head1 PRIMARY KEY
=over 4
=item * L</id>
=back
=cut
__PACKAGE__->set_primary_key("id");
=head1 UNIQUE CONSTRAINTS
=head2 C<code>
=over 4
=item * L</code>
=back
=cut
__PACKAGE__->add_unique_constraint("code", ["code"]);
=head1 RELATIONS
=head2 user
Type: belongs_to
Related object: L<My::Mailserver::Schema::Result::VirtualUser>
=cut
__PACKAGE__->belongs_to(
"user",
"My::Mailserver::Schema::Result::VirtualUser",
{ id => "user_id" },
{ is_deferrable => 1, on_delete => "CASCADE", on_update => "RESTRICT" },
);
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2019-06-17 02:39:38
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:UeAp7iDyLd6gYYoanmZEfg
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,98 @@
use utf8;
package My::Mailserver::Schema::Result::VirtualAlias;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
My::Mailserver::Schema::Result::VirtualAlias
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<virtual_aliases>
=cut
__PACKAGE__->table("virtual_aliases");
=head1 ACCESSORS
=head2 id
data_type: 'integer'
is_auto_increment: 1
is_nullable: 0
=head2 domain_id
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=head2 source
data_type: 'varchar'
is_nullable: 0
size: 255
=head2 destination
data_type: 'varchar'
is_nullable: 0
size: 255
=cut
__PACKAGE__->add_columns(
"id",
{ data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
"domain_id",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
"source",
{ data_type => "varchar", is_nullable => 0, size => 255 },
"destination",
{ data_type => "varchar", is_nullable => 0, size => 255 },
);
=head1 PRIMARY KEY
=over 4
=item * L</id>
=back
=cut
__PACKAGE__->set_primary_key("id");
=head1 RELATIONS
=head2 domain
Type: belongs_to
Related object: L<My::Mailserver::Schema::Result::VirtualDomain>
=cut
__PACKAGE__->belongs_to(
"domain",
"My::Mailserver::Schema::Result::VirtualDomain",
{ id => "domain_id" },
{ is_deferrable => 1, on_delete => "CASCADE", on_update => "RESTRICT" },
);
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2019-06-15 22:53:38
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:otU7xR9XhoziLALEVhQUZw
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,146 @@
use utf8;
package My::Mailserver::Schema::Result::VirtualDomain;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
My::Mailserver::Schema::Result::VirtualDomain
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<virtual_domains>
=cut
__PACKAGE__->table("virtual_domains");
=head1 ACCESSORS
=head2 id
data_type: 'integer'
is_auto_increment: 1
is_nullable: 0
=head2 name
data_type: 'varchar'
is_nullable: 0
size: 255
=head2 imap_host
data_type: 'varchar'
is_nullable: 1
size: 255
=head2 smtp_host
data_type: 'varchar'
is_nullable: 1
size: 255
=head2 mail_host
data_type: 'varchar'
is_nullable: 1
size: 255
=cut
__PACKAGE__->add_columns(
"id",
{ data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
"name",
{ data_type => "varchar", is_nullable => 0, size => 255 },
"imap_host",
{ data_type => "varchar", is_nullable => 1, size => 255 },
"smtp_host",
{ data_type => "varchar", is_nullable => 1, size => 255 },
"mail_host",
{ data_type => "varchar", is_nullable => 1, size => 255 },
);
=head1 PRIMARY KEY
=over 4
=item * L</id>
=back
=cut
__PACKAGE__->set_primary_key("id");
=head1 RELATIONS
=head2 managers_domains
Type: has_many
Related object: L<My::Mailserver::Schema::Result::ManagersDomain>
=cut
__PACKAGE__->has_many(
"managers_domains",
"My::Mailserver::Schema::Result::ManagersDomain",
{ "foreign.domain_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
=head2 virtual_aliases
Type: has_many
Related object: L<My::Mailserver::Schema::Result::VirtualAlias>
=cut
__PACKAGE__->has_many(
"virtual_aliases",
"My::Mailserver::Schema::Result::VirtualAlias",
{ "foreign.domain_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
=head2 virtual_users
Type: has_many
Related object: L<My::Mailserver::Schema::Result::VirtualUser>
=cut
__PACKAGE__->has_many(
"virtual_users",
"My::Mailserver::Schema::Result::VirtualUser",
{ "foreign.domain_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
=head2 managers
Type: many_to_many
Composing rels: L</managers_domains> -> manager
=cut
__PACKAGE__->many_to_many("managers", "managers_domains", "manager");
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2019-07-07 04:05:03
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:zFGn1x/l57uM9QohW5mwjg
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,208 @@
use utf8;
package My::Mailserver::Schema::Result::VirtualUser;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
My::Mailserver::Schema::Result::VirtualUser
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 TABLE: C<virtual_users>
=cut
__PACKAGE__->table("virtual_users");
=head1 ACCESSORS
=head2 id
data_type: 'integer'
is_auto_increment: 1
is_nullable: 0
=head2 domain_id
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=head2 password
data_type: 'varchar'
is_nullable: 0
size: 255
=head2 email
data_type: 'varchar'
is_nullable: 0
size: 255
=head2 recovery_email
data_type: 'varchar'
is_nullable: 1
size: 255
=head2 is_manager
data_type: 'tinyint'
default_value: 0
is_nullable: 1
=cut
__PACKAGE__->add_columns(
"id",
{ data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
"domain_id",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
"password",
{ data_type => "varchar", is_nullable => 0, size => 255 },
"email",
{ data_type => "varchar", is_nullable => 0, size => 255 },
"recovery_email",
{ data_type => "varchar", is_nullable => 1, size => 255 },
"is_manager",
{ data_type => "tinyint", default_value => 0, is_nullable => 1 },
);
=head1 PRIMARY KEY
=over 4
=item * L</id>
=back
=cut
__PACKAGE__->set_primary_key("id");
=head1 UNIQUE CONSTRAINTS
=head2 C<email>
=over 4
=item * L</email>
=back
=cut
__PACKAGE__->add_unique_constraint("email", ["email"]);
=head1 RELATIONS
=head2 domain
Type: belongs_to
Related object: L<My::Mailserver::Schema::Result::VirtualDomain>
=cut
__PACKAGE__->belongs_to(
"domain",
"My::Mailserver::Schema::Result::VirtualDomain",
{ id => "domain_id" },
{ is_deferrable => 1, on_delete => "CASCADE", on_update => "RESTRICT" },
);
=head2 managers_domains
Type: has_many
Related object: L<My::Mailserver::Schema::Result::ManagersDomain>
=cut
__PACKAGE__->has_many(
"managers_domains",
"My::Mailserver::Schema::Result::ManagersDomain",
{ "foreign.manager_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
=head2 recovery_codes
Type: has_many
Related object: L<My::Mailserver::Schema::Result::RecoveryCode>
=cut
__PACKAGE__->has_many(
"recovery_codes",
"My::Mailserver::Schema::Result::RecoveryCode",
{ "foreign.user_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
=head2 domains
Type: many_to_many
Composing rels: L</managers_domains> -> domain
=cut
__PACKAGE__->many_to_many("domains", "managers_domains", "domain");
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2019-06-17 02:39:38
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:U24kyZS/j+9Gvs2gGlVBIw
__PACKAGE__->load_components('PassphraseColumn');
__PACKAGE__->add_columns(
'+password' => {
passphrase => 'crypt',
passphrase_class => 'BlowfishCrypt',
passphrase_args => {
salt_random => 1,
cost => 8,
},
passphrase_check_method => 'check_password',
},
);
sub new_recovery_code {
my $self = shift;
my $code;
my $max_tries = 10;
while (1) {
$code = eval {
$self->create_related('recovery_codes', {
code => _gen_code(),
});
};
warn $max_tries;
last if $code;
last unless $max_tries;
$max_tries--;
}
die "Error generating code" unless $code;
return $code;
}
use String::Random;
sub _gen_code {
my $gen = String::Random->new;
$gen->{'A'} = [@{$gen->{'C'}}, @{$gen->{'c'}}, @{$gen->{'n'}}];
return $gen->randpattern('A' x 64);
}
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

4
my-mailserver.conf Normal file
View file

@ -0,0 +1,4 @@
{
secrets => ['thisisonlyatest'],
db_config => 'MAILSERVER_TESTING',
}

1912
public/css/bootstrap-grid.css vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

7
public/css/bootstrap-grid.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

331
public/css/bootstrap-reboot.css vendored Normal file
View file

@ -0,0 +1,331 @@
/*!
* Bootstrap Reboot v4.1.3 (https://getbootstrap.com/)
* Copyright 2011-2018 The Bootstrap Authors
* Copyright 2011-2018 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: sans-serif;
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
-ms-overflow-style: scrollbar;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
@-ms-viewport {
width: device-width;
}
article, aside, figcaption, figure, footer, header, hgroup, main, nav, section {
display: block;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
text-align: left;
background-color: #fff;
}
[tabindex="-1"]:focus {
outline: 0 !important;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
margin-bottom: 0.5rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-original-title] {
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
border-bottom: 0;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: .5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
dfn {
font-style: italic;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -.25em;
}
sup {
top: -.5em;
}
a {
color: #007bff;
text-decoration: none;
background-color: transparent;
-webkit-text-decoration-skip: objects;
}
a:hover {
color: #0056b3;
text-decoration: underline;
}
a:not([href]):not([tabindex]) {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):focus {
outline: 0;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
}
pre {
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
-ms-overflow-style: scrollbar;
}
figure {
margin: 0 0 1rem;
}
img {
vertical-align: middle;
border-style: none;
}
svg {
overflow: hidden;
vertical-align: middle;
}
table {
border-collapse: collapse;
}
caption {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
color: #6c757d;
text-align: left;
caption-side: bottom;
}
th {
text-align: inherit;
}
label {
display: inline-block;
margin-bottom: 0.5rem;
}
button {
border-radius: 0;
}
button:focus {
outline: 1px dotted;
outline: 5px auto -webkit-focus-ring-color;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
html [type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
padding: 0;
border-style: none;
}
input[type="radio"],
input[type="checkbox"] {
box-sizing: border-box;
padding: 0;
}
input[type="date"],
input[type="time"],
input[type="datetime-local"],
input[type="month"] {
-webkit-appearance: listbox;
}
textarea {
overflow: auto;
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
display: block;
width: 100%;
max-width: 100%;
padding: 0;
margin-bottom: .5rem;
font-size: 1.5rem;
line-height: inherit;
color: inherit;
white-space: normal;
}
progress {
vertical-align: baseline;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
outline-offset: -2px;
-webkit-appearance: none;
}
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
summary {
display: list-item;
cursor: pointer;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

8
public/css/bootstrap-reboot.min.css vendored Normal file
View file

@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v4.1.3 (https://getbootstrap.com/)
* Copyright 2011-2018 The Bootstrap Authors
* Copyright 2011-2018 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */

File diff suppressed because one or more lines are too long

9030
public/css/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

7
public/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6461
public/js/bootstrap.bundle.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

7
public/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3944
public/js/bootstrap.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

7
public/js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11
script/my_mailserver Normal file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
BEGIN { unshift @INC, "$FindBin::Bin/../lib" }
use Mojolicious::Commands;
# Start command line interface for application
Mojolicious::Commands->start_app('My::Mailserver');

24
sql/001-mail_setup.sql Normal file
View file

@ -0,0 +1,24 @@
CREATE TABLE `virtual_domains` (
`id` int(11) NOT NULL auto_increment,
`name` varchar(50) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `virtual_users` (
`id` int(11) NOT NULL auto_increment,
`domain_id` int(11) NOT NULL,
`password` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `virtual_aliases` (
`id` int(11) NOT NULL auto_increment,
`domain_id` int(11) NOT NULL,
`source` varchar(255) NOT NULL,
`destination` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

20
sql/002-user_setup.sql Normal file
View file

@ -0,0 +1,20 @@
CREATE TABLE `managers_domains` (
`domain_id` int(11) NOT NULL,
`manager_id` int(11) NOT NULL,
PRIMARY KEY (`domain_id`, `manager_id`),
FOREIGN KEY (domain_id) REFERENCES virtual_domains(id),
FOREIGN KEY (manager_id) REFERENCES virtual_users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `recovery_codes` (
`id` int(11) NOT NULL auto_increment,
`user_id` int(11) NOT NULL,
`code` varchar(255) NOT NULL,
`created` timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `code` (`code`),
FOREIGN KEY (user_id) REFERENCES virtual_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE `virtual_users` ADD COLUMN `recovery_email` varchar(255) DEFAULT NULL;
ALTER TABLE `virtual_users` ADD COLUMN `is_manager` BOOLEAN DEFAULT FALSE;

5
sql/003-domain_info.sql Normal file
View file

@ -0,0 +1,5 @@
ALTER TABLE `virtual_domains` CHANGE COLUMN `name` `name` varchar(255) NOT NULL;
ALTER TABLE `virtual_domains` ADD COLUMN `imap_host` varchar(255) DEFAULT NULL;
ALTER TABLE `virtual_domains` ADD COLUMN `smtp_host` varchar(255) DEFAULT NULL;
ALTER TABLE `virtual_domains` ADD COLUMN `mail_host` varchar(255) DEFAULT NULL;

11
sql/dump.conf.example Normal file
View file

@ -0,0 +1,11 @@
schema_class My::Mailserver::Schema
<connect_info>
dsn dbi:mysql:mailserver
user username
pass some password
</connect_info>
<loader_options>
dump_directory ./lib
</loader_options>

14
t/auth.t Normal file
View file

@ -0,0 +1,14 @@
use Mojo::Base -strict;
use Test::More;
use Test::Mojo;
my $t = Test::Mojo->new('My::Mailserver');
$t->get_ok('/')
->status_is(200)
->content_like(qr/Login/i)
->element_exists('input[name=email][type=text]')
->element_exists('input[name=password][type=password]')
->element_exists('input[name=csrf_token][type=hidden]');
done_testing();

9
t/basic.t Normal file
View file

@ -0,0 +1,9 @@
use Mojo::Base -strict;
use Test::More;
use Test::Mojo;
my $t = Test::Mojo->new('My::Mailserver');
$t->get_ok('/')->status_is(200)->content_like(qr/Login/i);
done_testing();

View file

@ -0,0 +1,13 @@
Hi,
A password reset was requested for <%= $email->email %>. Please visit
the following url to reset:
<%= $url %>
The url will be valid for 1 hour. If you did not request this reset,
then either ignore this message or log into your account normally.
Thanks,
Postmaster

View file

@ -0,0 +1,44 @@
% layout 'default';
% title 'Login';
<div class="container">
<div class="row justify-content-center">
<div class="col-auto">
<h1>Login</h1>
</div>
</div>
<div class="row justify-content-center">
<div class="col-auto">
<form action="<%= url_for '/' %>" method="POST" class="form">
<div class="form-group">
<%= label_for 'email' => 'Email' %>
<%= text_field 'email' => (class => 'form-control', id => 'email') %>
</div>
<div class="form-group">
<%= label_for 'password' => 'Password' %>
<%= password_field 'password' => (class => 'form-control', id => 'password') %>
</div>
<%= csrf_field %>
<button type="submit" class="btn btn-primary btn-block">Login</button>
</form>
</div>
</div>
<div class="row justify-content-center">
<div class="col-auto mt-3">
<button class="btn btn-link" data-toggle="collapse" data-target="#recovery-form" href="#">Lost Password?</button>
</div>
</div>
<div class="row justify-content-center">
<div class="col-auto collapse" id="recovery-form">
<form action="<%= url_for '/recover' %>" method="post">
<div class="form-group">
<%= label_for 'recovery_email' => 'Email' %>
<%= text_field 'recovery_email' => (class => 'form-control', id => 'recovery_email') %>
</div>
<%= csrf_field %>
<button type="submit" class="btn btn-primary btn-block">Send Recovery Email</button>
</form>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,89 @@
% layout 'main';
% title 'Dashboard';
<div class="container">
<div class="row justify-content-center">
<div class="col-auto">
<h1>Dashboard</h1>
</div>
</div>
<% if ( my $error = flash('error') ) { %>
<div class="row">
<div class="col alert alert-warning">
<span class="text-black-50">Error:</span> <%= $error %>
</div>
</div>
<% } %>
<% if ( my $success = flash('success') ) { %>
<div class="row">
<div class="col alert alert-success">
<span class="text-black-50">Error:</span> <%= $success %>
</div>
</div>
<% } %>
<div class="row justify-content-center">
<div class="col-12 card-columns">
<div class="card">
<div class="card-body">
<h5 class="card-title">Info</h5>
<p class="card-text">Information for accessing either the hosted mail option, or setting up your mail software.</p>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">Webmail: <%= link_to $domain->mail_host => $domain->mail_host %></li>
<li class="list-group-item">IMAP: <%= $domain->imap_host %></li>
<li class="list-group-item">SMTP: <%= $domain->smtp_host %></li>
<li class="list-group-item">Use your email and password for authentication on both IMAP and SMTP</li>
</ul>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Aliases</h5>
<p class="card-text">All Aliases set up for you</p>
</div>
<ul class="list-group list-group-flush">
<% foreach my $alias ( $aliases->all ) { %>
<li class="list-group-item"><%= $alias->source %></li>
<% } %>
</ul>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Change Password</h5>
<p class="card-text">Gotta have some way of doing it, after all...</p>
<form action="<%= url_for '/dashboard/change_password' %>" method="post">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" class="form-control" id="current_password" name="current_password">
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" class="form-control" id="new_password" name="new_password">
</div>
<div class="form-group">
<label for="confirm_password">Confirm Password</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password">
</div>
<%= csrf_field %>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Recovery Email</h5>
<p class="card-text">In the chance you forget your password, this at least lets you recover it!</p>
<ul class="list-group list-group-flush">
<li class="list-group-item">Current Address: <%= $recovery_email %></li>
</ul>
<form action="<%= url_for '/dashboard/change_recovery' %>" method="post">
<div class="form-group">
<label for="current_password">Email</label>
<input type="email" class="form-control" id="recovery_email" name="recovery_email">
</div>
<%= csrf_field %>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
</head>
<body><%= content %></body>
</html>

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="<%= url_for '/dashboard' %>">Mail Dashboard</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="<%= url_for '/logout' %>">Logout</a>
</li>
</ul>
</div>
</nav>
<%= content %>
</body>
</html>