@ -1,40 +1,46 @@
from mailu import models
""" Mailu command line interface
"""
from flask import current_app as app
import sys
from flask import cli as flask_cli
import flask
import os
import os
import socket
import socket
import uuid
import uuid
import click
import click
import yaml
from flask import current_app as app
from flask . cli import FlaskGroup , with_appcontext
from mailu import models
from mailu . schemas import MailuSchema , Logger , RenderJSON
db = models . db
db = models . db
@click.group ( )
@click.group ( cls = FlaskGroup , context_settings = { ' help_option_names ' : [ ' -? ' , ' -h ' , ' --help ' ] } )
def mailu ( cls = flask_cli . FlaskGroup ) :
def mailu ( ) :
""" Mailu command line
""" Mailu command line
"""
"""
@mailu.command ( )
@mailu.command ( )
@ flask_cli. with_appcontext
@ with_appcontext
def advertise ( ) :
def advertise ( ) :
""" Advertise this server against statistic services.
""" Advertise this server against statistic services.
"""
"""
if os . path . isfile ( app . config [ " INSTANCE_ID_PATH " ] ) :
if os . path . isfile ( app . config [ ' INSTANCE_ID_PATH ' ] ) :
with open ( app . config [ " INSTANCE_ID_PATH " ] , " r " ) as handle :
with open ( app . config [ ' INSTANCE_ID_PATH ' ] , ' r ' ) as handle :
instance_id = handle . read ( )
instance_id = handle . read ( )
else :
else :
instance_id = str ( uuid . uuid4 ( ) )
instance_id = str ( uuid . uuid4 ( ) )
with open ( app . config [ " INSTANCE_ID_PATH " ] , " w " ) as handle :
with open ( app . config [ ' INSTANCE_ID_PATH ' ] , ' w ' ) as handle :
handle . write ( instance_id )
handle . write ( instance_id )
if not app . config [ " DISABLE_STATISTICS " ] :
if not app . config [ ' DISABLE_STATISTICS ' ] :
try :
try :
socket . gethostbyname ( app . config [ " STATS_ENDPOINT " ] . format ( instance_id ) )
socket . gethostbyname ( app . config [ ' STATS_ENDPOINT ' ] . format ( instance_id ) )
except :
except OSError :
pass
pass
@ -43,7 +49,7 @@ def advertise():
@click.argument ( ' domain_name ' )
@click.argument ( ' domain_name ' )
@click.argument ( ' password ' )
@click.argument ( ' password ' )
@click.option ( ' -m ' , ' --mode ' )
@click.option ( ' -m ' , ' --mode ' )
@ flask_cli. with_appcontext
@ with_appcontext
def admin ( localpart , domain_name , password , mode = ' create ' ) :
def admin ( localpart , domain_name , password , mode = ' create ' ) :
""" Create an admin user
""" Create an admin user
' mode ' can be :
' mode ' can be :
@ -58,7 +64,7 @@ def admin(localpart, domain_name, password, mode='create'):
user = None
user = None
if mode == ' ifmissing ' or mode == ' update ' :
if mode == ' ifmissing ' or mode == ' update ' :
email = ' {} @ {} ' . format ( localpart , domain_name )
email = f' { localpart } @ { domain_name } '
user = models . User . query . get ( email )
user = models . User . query . get ( email )
if user and mode == ' ifmissing ' :
if user and mode == ' ifmissing ' :
@ -86,7 +92,7 @@ def admin(localpart, domain_name, password, mode='create'):
@click.argument ( ' localpart ' )
@click.argument ( ' localpart ' )
@click.argument ( ' domain_name ' )
@click.argument ( ' domain_name ' )
@click.argument ( ' password ' )
@click.argument ( ' password ' )
@ flask_cli. with_appcontext
@ with_appcontext
def user ( localpart , domain_name , password ) :
def user ( localpart , domain_name , password ) :
""" Create a user
""" Create a user
"""
"""
@ -108,16 +114,16 @@ def user(localpart, domain_name, password):
@click.argument ( ' localpart ' )
@click.argument ( ' localpart ' )
@click.argument ( ' domain_name ' )
@click.argument ( ' domain_name ' )
@click.argument ( ' password ' )
@click.argument ( ' password ' )
@ flask_cli. with_appcontext
@ with_appcontext
def password ( localpart , domain_name , password ) :
def password ( localpart , domain_name , password ) :
""" Change the password of an user
""" Change the password of an user
"""
"""
email = ' {0} @ {1} ' . format ( localpart , domain_name )
email = f' { localpart } @ { domain_name } '
user = models . User . query . get ( email )
user = models . User . query . get ( email )
if user :
if user :
user . set_password ( password )
user . set_password ( password )
else :
else :
print ( " User " + email + " not found. " )
print ( f' User { email } not found. ' )
db . session . commit ( )
db . session . commit ( )
@ -126,7 +132,7 @@ def password(localpart, domain_name, password):
@click.option ( ' -u ' , ' --max-users ' )
@click.option ( ' -u ' , ' --max-users ' )
@click.option ( ' -a ' , ' --max-aliases ' )
@click.option ( ' -a ' , ' --max-aliases ' )
@click.option ( ' -q ' , ' --max-quota-bytes ' )
@click.option ( ' -q ' , ' --max-quota-bytes ' )
@ flask_cli. with_appcontext
@ with_appcontext
def domain ( domain_name , max_users = - 1 , max_aliases = - 1 , max_quota_bytes = 0 ) :
def domain ( domain_name , max_users = - 1 , max_aliases = - 1 , max_quota_bytes = 0 ) :
""" Create a domain
""" Create a domain
"""
"""
@ -142,9 +148,9 @@ def domain(domain_name, max_users=-1, max_aliases=-1, max_quota_bytes=0):
@click.argument ( ' localpart ' )
@click.argument ( ' localpart ' )
@click.argument ( ' domain_name ' )
@click.argument ( ' domain_name ' )
@click.argument ( ' password_hash ' )
@click.argument ( ' password_hash ' )
@ flask_cli. with_appcontext
@ with_appcontext
def user_import ( localpart , domain_name , password_hash ) :
def user_import ( localpart , domain_name , password_hash ) :
""" Import a user along with password hash .
""" Import a user along with password hash
"""
"""
domain = models . Domain . query . get ( domain_name )
domain = models . Domain . query . get ( domain_name )
if not domain :
if not domain :
@ -160,14 +166,14 @@ def user_import(localpart, domain_name, password_hash):
db . session . commit ( )
db . session . commit ( )
# TODO: remove deprecated config_update function?
@mailu.command ( )
@mailu.command ( )
@click.option ( ' -v ' , ' --verbose ' )
@click.option ( ' -v ' , ' --verbose ' )
@click.option ( ' -d ' , ' --delete-objects ' )
@click.option ( ' -d ' , ' --delete-objects ' )
@ flask_cli. with_appcontext
@ with_appcontext
def config_update ( verbose = False , delete_objects = False ) :
def config_update ( verbose = False , delete_objects = False ) :
""" sync configuration with data from YAML-formatted stdin """
""" Sync configuration with data from YAML (deprecated)
import yaml
"""
import sys
new_config = yaml . safe_load ( sys . stdin )
new_config = yaml . safe_load ( sys . stdin )
# print new_config
# print new_config
domains = new_config . get ( ' domains ' , [ ] )
domains = new_config . get ( ' domains ' , [ ] )
@ -187,13 +193,13 @@ def config_update(verbose=False, delete_objects=False):
max_aliases = max_aliases ,
max_aliases = max_aliases ,
max_quota_bytes = max_quota_bytes )
max_quota_bytes = max_quota_bytes )
db . session . add ( domain )
db . session . add ( domain )
print ( " Added " + str ( domain_config ) )
print ( f' Added { domain_config } ' )
else :
else :
domain . max_users = max_users
domain . max_users = max_users
domain . max_aliases = max_aliases
domain . max_aliases = max_aliases
domain . max_quota_bytes = max_quota_bytes
domain . max_quota_bytes = max_quota_bytes
db . session . add ( domain )
db . session . add ( domain )
print ( " Updated " + str ( domain_config ) )
print ( f' Updated { domain_config } ' )
users = new_config . get ( ' users ' , [ ] )
users = new_config . get ( ' users ' , [ ] )
tracked_users = set ( )
tracked_users = set ( )
@ -209,7 +215,7 @@ def config_update(verbose=False, delete_objects=False):
domain_name = user_config [ ' domain ' ]
domain_name = user_config [ ' domain ' ]
password_hash = user_config . get ( ' password_hash ' , None )
password_hash = user_config . get ( ' password_hash ' , None )
domain = models . Domain . query . get ( domain_name )
domain = models . Domain . query . get ( domain_name )
email = ' {0} @ {1} ' . format ( localpart , domain_name )
email = f' { localpart } @ { domain_name } '
optional_params = { }
optional_params = { }
for k in user_optional_params :
for k in user_optional_params :
if k in user_config :
if k in user_config :
@ -239,13 +245,13 @@ def config_update(verbose=False, delete_objects=False):
print ( str ( alias_config ) )
print ( str ( alias_config ) )
localpart = alias_config [ ' localpart ' ]
localpart = alias_config [ ' localpart ' ]
domain_name = alias_config [ ' domain ' ]
domain_name = alias_config [ ' domain ' ]
if typ e( alias_config [ ' destination ' ] ) is str :
if isinstanc e( alias_config [ ' destination ' ] , str ) :
destination = alias_config [ ' destination ' ] . split ( ' , ' )
destination = alias_config [ ' destination ' ] . split ( ' , ' )
else :
else :
destination = alias_config [ ' destination ' ]
destination = alias_config [ ' destination ' ]
wildcard = alias_config . get ( ' wildcard ' , False )
wildcard = alias_config . get ( ' wildcard ' , False )
domain = models . Domain . query . get ( domain_name )
domain = models . Domain . query . get ( domain_name )
email = ' {0} @ {1} ' . format ( localpart , domain_name )
email = f' { localpart } @ { domain_name } '
if not domain :
if not domain :
domain = models . Domain ( name = domain_name )
domain = models . Domain ( name = domain_name )
db . session . add ( domain )
db . session . add ( domain )
@ -275,7 +281,7 @@ def config_update(verbose=False, delete_objects=False):
domain_name = manager_config [ ' domain ' ]
domain_name = manager_config [ ' domain ' ]
user_name = manager_config [ ' user ' ]
user_name = manager_config [ ' user ' ]
domain = models . Domain . query . get ( domain_name )
domain = models . Domain . query . get ( domain_name )
manageruser = models . User . query . get ( user_name + ' @ ' + domain_name )
manageruser = models . User . query . get ( f ' { user_name } @ { domain_name } ' )
if manageruser not in domain . managers :
if manageruser not in domain . managers :
domain . managers . append ( manageruser )
domain . managers . append ( manageruser )
db . session . add ( domain )
db . session . add ( domain )
@ -284,26 +290,117 @@ def config_update(verbose=False, delete_objects=False):
if delete_objects :
if delete_objects :
for user in db . session . query ( models . User ) . all ( ) :
for user in db . session . query ( models . User ) . all ( ) :
if not ( user . email in tracked_users ) :
if not user . email in tracked_users :
if verbose :
if verbose :
print ( " Deleting user: " + str ( user . email ) )
print ( f' Deleting user: { user . email } ' )
db . session . delete ( user )
db . session . delete ( user )
for alias in db . session . query ( models . Alias ) . all ( ) :
for alias in db . session . query ( models . Alias ) . all ( ) :
if not ( alias . email in tracked_aliases ) :
if not alias . email in tracked_aliases :
if verbose :
if verbose :
print ( " Deleting alias: " + str ( alias . email ) )
print ( f' Deleting alias: { alias . email } ' )
db . session . delete ( alias )
db . session . delete ( alias )
for domain in db . session . query ( models . Domain ) . all ( ) :
for domain in db . session . query ( models . Domain ) . all ( ) :
if not ( domain . name in tracked_domains ) :
if not domain . name in tracked_domains :
if verbose :
if verbose :
print ( " Deleting domain: " + str ( domain . name ) )
print ( f' Deleting domain: { domain . name } ' )
db . session . delete ( domain )
db . session . delete ( domain )
db . session . commit ( )
db . session . commit ( )
@mailu.command ( )
@click.option ( ' -v ' , ' --verbose ' , count = True , help = ' Increase verbosity. ' )
@click.option ( ' -s ' , ' --secrets ' , is_flag = True , help = ' Show secret attributes in messages. ' )
@click.option ( ' -d ' , ' --debug ' , is_flag = True , help = ' Enable debug output. ' )
@click.option ( ' -q ' , ' --quiet ' , is_flag = True , help = ' Quiet mode - only show errors. ' )
@click.option ( ' -c ' , ' --color ' , is_flag = True , help = ' Force colorized output. ' )
@click.option ( ' -u ' , ' --update ' , is_flag = True , help = ' Update mode - merge input with existing config. ' )
@click.option ( ' -n ' , ' --dry-run ' , is_flag = True , help = ' Perform a trial run with no changes made. ' )
@click.argument ( ' source ' , metavar = ' [FILENAME|-] ' , type = click . File ( mode = ' r ' ) , default = sys . stdin )
@with_appcontext
def config_import ( verbose = 0 , secrets = False , debug = False , quiet = False , color = False ,
update = False , dry_run = False , source = None ) :
""" Import configuration as YAML or JSON from stdin or file
"""
log = Logger ( want_color = color or None , can_color = sys . stdout . isatty ( ) , secrets = secrets , debug = debug )
log . lexer = ' python '
log . strip = True
log . verbose = 0 if quiet else verbose
log . quiet = quiet
context = {
' import ' : True ,
' update ' : update ,
' clear ' : not update ,
' callback ' : log . track_serialize ,
}
schema = MailuSchema ( only = MailuSchema . Meta . order , context = context )
try :
# import source
with models . db . session . no_autoflush :
config = schema . loads ( source )
# flush session to show/count all changes
if not quiet and ( dry_run or verbose ) :
db . session . flush ( )
# check for duplicate domain names
config . check ( )
except Exception as exc :
if msg := log . format_exception ( exc ) :
raise click . ClickException ( msg ) from exc
raise
# don't commit when running dry
if dry_run :
log . changes ( ' Dry run. Not committing changes. ' )
db . session . rollback ( )
else :
log . changes ( ' Committing changes. ' )
db . session . commit ( )
@mailu.command ( )
@click.option ( ' -f ' , ' --full ' , is_flag = True , help = ' Include attributes with default value. ' )
@click.option ( ' -s ' , ' --secrets ' , is_flag = True ,
help = ' Include secret attributes (dkim-key, passwords). ' )
@click.option ( ' -d ' , ' --dns ' , is_flag = True , help = ' Include dns records. ' )
@click.option ( ' -c ' , ' --color ' , is_flag = True , help = ' Force colorized output. ' )
@click.option ( ' -o ' , ' --output-file ' , ' output ' , default = sys . stdout , type = click . File ( mode = ' w ' ) ,
help = ' Save configuration to file. ' )
@click.option ( ' -j ' , ' --json ' , ' as_json ' , is_flag = True , help = ' Export configuration in json format. ' )
@click.argument ( ' only ' , metavar = ' [FILTER]... ' , nargs = - 1 )
@with_appcontext
def config_export ( full = False , secrets = False , color = False , dns = False , output = None , as_json = False , only = None ) :
""" Export configuration as YAML or JSON to stdout or file
"""
log = Logger ( want_color = color or None , can_color = output . isatty ( ) )
only = only or MailuSchema . Meta . order
context = {
' full ' : full ,
' secrets ' : secrets ,
' dns ' : dns ,
}
try :
schema = MailuSchema ( only = only , context = context )
if as_json :
schema . opts . render_module = RenderJSON
log . lexer = ' json '
log . strip = True
print ( log . colorize ( schema . dumps ( models . MailuConfig ( ) ) ) , file = output )
except Exception as exc :
if msg := log . format_exception ( exc ) :
raise click . ClickException ( msg ) from exc
raise
@mailu.command ( )
@mailu.command ( )
@click.argument ( ' email ' )
@click.argument ( ' email ' )
@flask_cli.with_appcontext
@ with_appcontext
def user_delete ( email ) :
def user_delete ( email ) :
""" delete user """
""" delete user """
user = models . User . query . get ( email )
user = models . User . query . get ( email )
@ -314,7 +411,7 @@ def user_delete(email):
@mailu.command ( )
@mailu.command ( )
@click.argument ( ' email ' )
@click.argument ( ' email ' )
@ flask_cli. with_appcontext
@ with_appcontext
def alias_delete ( email ) :
def alias_delete ( email ) :
""" delete alias """
""" delete alias """
alias = models . Alias . query . get ( email )
alias = models . Alias . query . get ( email )
@ -328,7 +425,7 @@ def alias_delete(email):
@click.argument ( ' domain_name ' )
@click.argument ( ' domain_name ' )
@click.argument ( ' destination ' )
@click.argument ( ' destination ' )
@click.option ( ' -w ' , ' --wildcard ' , is_flag = True )
@click.option ( ' -w ' , ' --wildcard ' , is_flag = True )
@ flask_cli. with_appcontext
@ with_appcontext
def alias ( localpart , domain_name , destination , wildcard = False ) :
def alias ( localpart , domain_name , destination , wildcard = False ) :
""" Create an alias
""" Create an alias
"""
"""
@ -341,7 +438,7 @@ def alias(localpart, domain_name, destination, wildcard=False):
domain = domain ,
domain = domain ,
wildcard = wildcard ,
wildcard = wildcard ,
destination = destination . split ( ' , ' ) ,
destination = destination . split ( ' , ' ) ,
email = " %s @ %s " % ( localpart , domain_name )
email = f' { localpart } @ { domain_name } '
)
)
db . session . add ( alias )
db . session . add ( alias )
db . session . commit ( )
db . session . commit ( )
@ -352,7 +449,7 @@ def alias(localpart, domain_name, destination, wildcard=False):
@click.argument ( ' max_users ' )
@click.argument ( ' max_users ' )
@click.argument ( ' max_aliases ' )
@click.argument ( ' max_aliases ' )
@click.argument ( ' max_quota_bytes ' )
@click.argument ( ' max_quota_bytes ' )
@ flask_cli. with_appcontext
@ with_appcontext
def setlimits ( domain_name , max_users , max_aliases , max_quota_bytes ) :
def setlimits ( domain_name , max_users , max_aliases , max_quota_bytes ) :
""" Set domain limits
""" Set domain limits
"""
"""
@ -367,16 +464,12 @@ def setlimits(domain_name, max_users, max_aliases, max_quota_bytes):
@mailu.command ( )
@mailu.command ( )
@click.argument ( ' domain_name ' )
@click.argument ( ' domain_name ' )
@click.argument ( ' user_name ' )
@click.argument ( ' user_name ' )
@ flask_cli. with_appcontext
@ with_appcontext
def setmanager ( domain_name , user_name = ' manager ' ) :
def setmanager ( domain_name , user_name = ' manager ' ) :
""" Make a user manager of a domain
""" Make a user manager of a domain
"""
"""
domain = models . Domain . query . get ( domain_name )
domain = models . Domain . query . get ( domain_name )
manageruser = models . User . query . get ( user_name + ' @ ' + domain_name )
manageruser = models . User . query . get ( f ' { user_name } @ { domain_name } ' )
domain . managers . append ( manageruser )
domain . managers . append ( manageruser )
db . session . add ( domain )
db . session . add ( domain )
db . session . commit ( )
db . session . commit ( )
if __name__ == ' __main__ ' :
cli ( )