Browse Source

Import previous repo

master
CCrypto 2 years ago
commit
d7066c8f1d
34 changed files with 3073 additions and 0 deletions
  1. +62
    -0
      .gitignore
  2. +22
    -0
      LICENSE
  3. +106
    -0
      README.md
  4. +63
    -0
      alembic/env.py
  5. +24
    -0
      alembic/script.py.mako
  6. +24
    -0
      alembic/versions/2584e227ce1_initial.py
  7. +102
    -0
      development.ini
  8. +186
    -0
      pizzavolus/__init__.py
  9. +304
    -0
      pizzavolus/models.py
  10. BIN
      pizzavolus/scripts/.apiacl.py.swp
  11. +1
    -0
      pizzavolus/scripts/__init__.py
  12. +125
    -0
      pizzavolus/scripts/config.py
  13. +112
    -0
      pizzavolus/scripts/importcsv.py
  14. +42
    -0
      pizzavolus/scripts/initializedb.py
  15. +89
    -0
      pizzavolus/scripts/log.py
  16. +87
    -0
      pizzavolus/scripts/user.py
  17. +7
    -0
      pizzavolus/static/grids-responsive-min.css
  18. +11
    -0
      pizzavolus/static/pure-min.css
  19. +239
    -0
      pizzavolus/static/style.css
  20. +112
    -0
      pizzavolus/templates/address.mako
  21. +40
    -0
      pizzavolus/templates/admin.mako
  22. +25
    -0
      pizzavolus/templates/auth.mako
  23. +107
    -0
      pizzavolus/templates/domain.mako
  24. +76
    -0
      pizzavolus/templates/layout.mako
  25. +25
    -0
      pizzavolus/templates/reset.mako
  26. +104
    -0
      pizzavolus/templates/root.mako
  27. +52
    -0
      pizzavolus/templates/user.mako
  28. +60
    -0
      pizzavolus/tests/__init__.py
  29. +89
    -0
      pizzavolus/tests/models.py
  30. +116
    -0
      pizzavolus/tests/scripts.py
  31. +155
    -0
      pizzavolus/tests/views.py
  32. +370
    -0
      pizzavolus/views.py
  33. +96
    -0
      production.ini
  34. +40
    -0
      setup.py

+ 62
- 0
.gitignore View File

@@ -0,0 +1,62 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

# C extensions
*.so

# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover

# Translations
*.mo
*.pot

# Django stuff:
*.log

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# Local .ini
local.ini
local.*.ini
*.local.ini

+ 22
- 0
LICENSE View File

@@ -0,0 +1,22 @@
The MIT License (MIT)

Copyright (c) 2015 Alice

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


+ 106
- 0
README.md View File

@@ -0,0 +1,106 @@
PizzaVolus
==========

PizzaVolus is a web interface to manage virtual mail domains, boxes and aliases
for postfix and dovecot.

It can be used to let people register an email address on open domains, and
owners of addresses to set or not a redirect and change
their own password.

A public instance is hosted by CCrypto : https://mail.ccrypto.org/

## Installation

Copy a .ini file and edit it. **Only PostgreSQL databases are supported for now.**
```ini
sqlalchemy.url = postgresql+psycopg2://pizza:C1tad3l@127.0.0.1/pizza
```

Then create the database structure with `pv-initdb local.ini`
and start a dev server with `pserve local.ini`

The default admin user is Admin:root, users can be added/modified with pv-user.

## Postfix & Dovecot

For Debian using postfix's default "Internet Site" configuration

*Optional*: Create a limited access PostgreSQL role and copy an .ini file to use it:

```sql
CREATE ROLE pizza_ro WITH PASSWORD '...';
GRANT SELECT ON addresses, domains TO pizza_ro;
```

pv-config will make files with SQL details and queries for each server:
```sh
pv-config local.ini postfix_domains > /etc/postfix/pg_domains.cf
pv-config local.ini postfix_aliases > /etc/postfix/pg_aliases.cf
pv-config local.ini postfix_mailboxes > /etc/postfix/pg_mailboxes.cf
pv-config local.ini dovecot > /etc/dovecot/dovecot-sql.conf.ext
```

You also need an user for virtual mail accounts:
```sh
groupadd vmail --gid 500
useradd vmail --uid 500 --gid 500 --create-home
```

Make postfix use that, add in main.cf:
```
virtual_mailbox_domains = pgsql:/etc/postfix/pg_domains.cf
virtual_alias_maps = pgsql:/etc/postfix/pg_aliases.cf
virtual_mailbox_maps = pgsql:/etc/postfix/pg_mailboxes.cf
virtual_transport = dovecot

virtual_mailbox_base = /home/vmail/
virtual_gid_maps = static:500
virtual_uid_maps = static:500

smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
```
Make sure your virtual domains are **not** listed in `mydestination`.

And in master.cf:
```
dovecot unix - n n - - pipe
flags=DRhu user=vmail:vmail argv=/usr/lib/dovecot/deliver -f ${sender} -d ${user}@${nexthop}
```

For dovecot, enable SQL auth in /etc/dovecot/conf.d/auth-sql.conf.ext:
```
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = static
args = uid=vmail gid=vmail home=/home/vmail/%u
}
```

/etc/dovecot/conf.d/10-auth.conf, uncomment:
```
!include auth-sql.conf.ext
```

/etc/dovecot/conf.d/10-master.conf, find and uncomment this block for SASL auth:
```
# Postfix smtp-auth
unix_listener /var/spool/postfix/private/auth {
mode = 0666
}
```


## CSV Import

A previous database can be imported as CSV with pv-importcsv:

- Local part
- crypt()'d password
- Redirect, if any
- Domain name

+ 63
- 0
alembic/env.py View File

@@ -0,0 +1,63 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig

from pizzavolus.models import Base

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)

target_metadata = Base.metadata


def run_migrations_offline():
"""Run migrations in 'offline' mode.

This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.

Calls to context.execute() here emit the given string to the
script output.

"""
url = config.get_main_option('sqlalchemy.url', 'app:main')
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True)

with context.begin_transaction():
context.run_migrations()


def run_migrations_online():
"""Run migrations in 'online' mode.

In this scenario we need to create an Engine
and associate a connection with the context.

"""
connectable = engine_from_config(
config.get_section('app:main'),
prefix='sqlalchemy.',
poolclass=pool.NullPool)

with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
)

with context.begin_transaction():
context.run_migrations()

if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

+ 24
- 0
alembic/script.py.mako View File

@@ -0,0 +1,24 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

def upgrade():
${upgrades if upgrades else "pass"}


def downgrade():
${downgrades if downgrades else "pass"}

+ 24
- 0
alembic/versions/2584e227ce1_initial.py View File

@@ -0,0 +1,24 @@
"""Initial

Revision ID: 2584e227ce1
Revises:
Create Date: 2015-09-16 02:03:26.097459

"""

# revision identifiers, used by Alembic.
revision = '2584e227ce1'
down_revision = None
branch_labels = None
depends_on = None

from alembic import op
import sqlalchemy as sa


def upgrade():
pass


def downgrade():
pass

+ 102
- 0
development.ini View File

@@ -0,0 +1,102 @@
###
# app configuration
# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/environment.html
###

[app:main]
use = egg:pizzavolus

pyramid.reload_templates = true
pyramid.debug_authorization = false
pyramid.debug_notfound = false
pyramid.debug_routematch = false
pyramid.includes = pyramid_debugtoolbar

sqlalchemy.url = sqlite:///%(here)s/pizzavolus.sqlite

# By default, the toolbar only appears for clients from IP addresses
# '127.0.0.1' and '::1'.
# debugtoolbar.hosts = 127.0.0.1 ::1


# Site name
#app.sitename = PizzaVolus

# Footer text, next to github link
#app.footer_text =

# Default local part regexp
#app.lname_pattern = [a-zA-Z0-9_.-]{3,64}

# Log IP/user-agent for users/addresses
#app.log_user_auth = false
#app.log_address_auth = false

# Log entries displayed on users/addresses pages
#app.user_log_limit = 10
#app.address_log_limit = 5

# Recaptcha site key and secret key, to use recaptcha on open domains
#app.recaptcha_site =
#app.recaptcha_secret =

# Required access level to see logs.
# "address" < "user" < other values (disable log access)
#app.address_log_access = user
#app.user_log_access = user

# URL to use for the webmail link. Not shown if empty.
#app.webmail_link = /rc2/

session.type = file
session.data_dir = %(here)s/.tmp/session_data
session.lock_dir = %(here)s/.tmp/session_lock
session.key = session

[server:main]
use = egg:waitress#main
host = 0.0.0.0
port = 6543

[alembic]
script_location = ./alembic

[loggers]
keys = root, pizzavolus, sqlalchemy

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = INFO
handlers = console

[logger_pizzavolus]
level = DEBUG
handlers =
qualname = pizzavolus

[logger_sqlalchemy]
level = INFO
handlers =
qualname = sqlalchemy.engine
# "level = INFO" logs SQL queries.
# "level = DEBUG" logs SQL queries and results.
# "level = WARN" logs neither. (Recommended for production systems.)

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s

+ 186
- 0
pizzavolus/__init__.py View File

@@ -0,0 +1,186 @@
from sqlalchemy import engine_from_config
from pyramid.config import Configurator
from pyramid_beaker import session_factory_from_settings
from pyramid.settings import asbool
from datetime import datetime
from sqlalchemy.orm.exc import NoResultFound
import user_agents

from .models import DBSession, Base, Address, User
from . import views


class AuthenticationPolicy(object):
def authenticated_userid(self, request):
return request.user

def unauthenticated_userid(self, request):
return None

def effective_principals(self, request):
if not request.user or not request.user.enabled:
return ['guest']
return ['auth'] + request.user.get_permissions()


class AuthorizationPolicy(object):
def permits(self, context, principals, permission):
return permission in principals


class NoEscape(str):
def __html__(self):
return str(self)

def __str__(self):
s = super().__str__()
return s.replace('&nbsp;', ' ')

def __repr__(self):
return str(self)

@classmethod
def decorator(cls, fn):
def dfn(*args, **kwargs):
return cls(fn(*args, **kwargs))
return dfn


@NoEscape.decorator
def date_fmt(d, fmt='%Y-%m-%d&nbsp;%H:%M:%S'):
""" Format datetime """
if not d:
return ''
if not isinstance(d, datetime):
raise ValueError()
return d.strftime(fmt)


def get_user(request):
try:
type = request.session['auth_type']
id = request.session['auth_id']
if type == 'User':
return User.query(id=id).one()
elif type == 'Address':
return Address.query(id=id).one()
except NoResultFound:
pass
except KeyError:
pass


def get_ua(request):
return user_agents.parse(request.user_agent)


class Messages(object):
""" Simple class that manages session flash messages """
def __init__(self, request):
self.request = request
self.session = request.session

def add(self, level, text):
self.session.flash((level, text))

def error(self, text):
return self.add('error', text)

def info(self, text):
return self.add('info', text)


class AppConfig:
def __init__(self, settings, prefix):
self.load(settings, prefix)

def load(self, settings, prefix):
d = {key[len(prefix):]: value
for key, value in settings.items()
if key.startswith(prefix)}

self.sitename = d.get('sitename', 'PizzaVolus')

footer_file = d.get('footer_file')
if footer_file:
with open(footer_file) as f:
self.footer_text = f.read()
else:
self.footer_text = d.get('footer_text')

self.log_user_auth = asbool(d.get('log_user_auth', True))
self.log_address_auth = asbool(d.get('log_address_auth', False))
self.user_log_limit = int(d.get('user_log_limit', 10))
self.address_log_limit = int(d.get('address_log_limit', 5))

default_recaptcha_api = 'https://www.google.com/recaptcha/api/siteverify'

self.recaptcha_site = d.get('recaptcha_site')
self.recaptcha_secret = d.get('recaptcha_secret')
self.recaptcha_api = d.get('recaptcha_api', default_recaptcha_api)
self.use_recaptcha = self.recaptcha_secret and self.recaptcha_site

# Required level to see logs.
# "address" < "user" < other values (disable log access)
self.address_log_access = d.get('address_log_access', 'user')
self.user_log_access = d.get('user_log_access', 'user')

# Default local name regexp
self.lname_pattern = d.get('lname_pattern', '^[a-zA-Z0-9_.-]{3,64}$')

self.webmail_link = d.get('webmail_link')


def setup_routes(config):
config.add_static_view('static', 'static', cache_max_age=3600)

a = config.add_route

a('root', '/')
a('auth', '/auth')
a('logout', '/logout')

a('user', '/user')
a('address', '/address')

a('reset', '/reset/{token}')

a('admin_root', '/admin')
a('admin_domain', '/admin/{domain_name}')
a('admin_address', '/admin/{domain_name}/{local_part}')


def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""

engine = engine_from_config(settings, 'sqlalchemy.')
DBSession.configure(bind=engine)
Base.metadata.bind = engine

settings.setdefault('mako.directories', 'pizzavolus:templates/')
settings.setdefault('mako.imports', '''
from pizzavolus import date_fmt
from datetime import datetime
''')

appconfig = AppConfig(settings, 'app.')

config = Configurator(settings=settings)
config.set_authentication_policy(AuthenticationPolicy())
config.set_authorization_policy(AuthorizationPolicy())
config.set_session_factory(session_factory_from_settings(settings))

config.include('pyramid_mako')
config.include('pyramid_beaker')
config.include('pyramid_tm')

config.add_request_method(lambda r: appconfig, 'appconfig', property=True)
config.add_request_method(get_user, 'user', reify=True, property=True)
config.add_request_method(get_ua, 'parsed_user_agent', reify=True, property=True)
config.add_request_method(Messages, 'messages', reify=True, property=True)

setup_routes(config)

config.scan(views)
return config.make_wsgi_app()

+ 304
- 0
pizzavolus/models.py View File

@@ -0,0 +1,304 @@
import crypt
import random
from datetime import datetime

from sqlalchemy import func
from sqlalchemy import Column, ForeignKey, UniqueConstraint, CheckConstraint, Index
from sqlalchemy import Integer, BigInteger, DateTime, Boolean, String
from sqlalchemy.orm import scoped_session, sessionmaker, relationship, backref
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.dialects.postgresql import JSONB
from zope.sqlalchemy import ZopeTransactionExtension

ext = ZopeTransactionExtension(keep_session=False)
DBSession = scoped_session(sessionmaker(extension=ext))
prng = random.SystemRandom()


def random_token(length):
charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
return ''.join(prng.choice(charset) for _ in range(length))


class Base(object):
@classmethod
def query(cls, **kwargs):
return DBSession.query(cls).filter_by(**kwargs)

@classmethod
def count(cls, **kwargs):
return DBSession.query(func.count(cls.id)).filter_by(**kwargs)

Base = declarative_base(cls=Base)


class AuthBase(object):
""" Base class for User and Address, who can log in and has a password """
password = Column(String, nullable=True)

user = False
address = False

def set_password(self, clear):
self.password = crypt.crypt(clear, crypt.mksalt())

def check_password(self, clear):
if not self.password:
return False
return crypt.crypt(clear, self.password) == self.password

def get_permissions(self):
raise NotImplementedError()


class User(Base, AuthBase):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
username = Column(String(32), nullable=False, unique=True, index=True)
enabled = Column(Boolean, nullable=False, default=True)

user = True

log_entries = relationship('Log', backref='user', lazy='dynamic')

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def get_permissions(self):
return ['admin']

def __repr__(self):
return "User(%r)" % self.username


class Address(Base, AuthBase):
__tablename__ = 'addresses'
__table_args__ = (
UniqueConstraint('local_part', 'domain_id', name='addresses_addr_unique'),
Index('addresses_addr_index', 'domain_id', 'local_part'),
)
id = Column(Integer, primary_key=True)
domain_id = Column(ForeignKey('domains.id'), index=True)
local_part = Column(String(255), index=True)
redirect = Column(String, nullable=True, index=True)
enabled = Column(Boolean, nullable=False, default=True)
quota_mbytes = Column(Integer, nullable=True)
add_date = Column(DateTime, nullable=False, default=datetime.now)
expire_date = Column(DateTime, nullable=True)
last_change = Column(DateTime, nullable=True)

address = True

log_entries = relationship('Log', backref='address', lazy='dynamic')

pwresettokens = relationship('PwResetToken', backref='address')

def get_permissions(self):
return ['address']

@hybrid_property
def address(self):
return self.local_part + '@' + self.domain.name

@address.expression
def address(cls):
return cls.local_part + '@' + Domain.name

def __repr__(self):
if self.domain:
return "Address(%r)" % self.address
else:
return "Address(local_part=%r)" % self.local_part


class Log(Base):
""" Even log
user_id/address_id is the target, who will be able see the log entry.
Events are made of a type and data (JSON).
"""

__tablename__ = 'log'
__table_args__ = (
CheckConstraint('user_id IS NULL OR address_id IS NULL'),
)
id = Column(BigInteger, primary_key=True)
date = Column(DateTime, default=datetime.now, nullable=False)
user_id = Column(ForeignKey('users.id'), nullable=True)
address_id = Column(ForeignKey('addresses.id'), nullable=True)
type = Column(String, nullable=False)
data = Column(JSONB, nullable=True)

def __init__(self, type, target=None, data=None):
assert isinstance(type, str)

self.type = type
self.target = target

# Ignore by_user if it's the same as the target
by_user = data.get('by_user')
if by_user and isinstance(target, User) and by_user == target.id:
del data['by_user']

# We don't want to end up with useless str/repr in logs;
# ORM objects should be explicitly converted before calling log()
for k, v in data.items():
if isinstance(v, Base):
raise ValueError()

self.data = data


def get_text(self):
""" Returns a human-readable text representation of type and data
"""

def lookup(model, field, id_format='#{}', format='#{0.id}'):
try:
id = self.data[field]
item = model.query(id=id).one()
return format.format(item)
except KeyError:
return ''
except NoResultFound:
return id_format.format(id)

def user_lookup(field):
return lookup(User, field, ' by #{}', ' by {0.username} (#{0.id})')

if self.type == 'auth':
remote_addr = self.data.get('remote_addr')
user_agent = self.data.get('user_agent')
return 'Auth from %s (%s)' % (remote_addr, user_agent)

if self.type == 'set_password':
by_user = user_lookup('by_user')
return 'Password changed' + by_user

if self.type == 'set_redirect':
value = self.data.get('to')
by_user = user_lookup('by_user')
if value:
return 'Redirection changed to {}'.format(value) + by_user
else:
return 'Redirection disabled' + by_user

if self.type == 'use_reset_token':
token = self.data.get('token')
return 'Used password reset token #{}'.format(token)

if self.type == 'add_domain':
domain = lookup(Domain, 'domain', '#{}', '{0.name} (#{0.id})')
by_user = user_lookup('by_user')
return 'Domain {} created'.format(domain) + by_user

if self.type == 'add_address':
addr = lookup(Address, 'address', '#{}', '{0.address} (#{0.id})')
by_user = user_lookup('by_user')
return 'Address {} created'.format(addr) + by_user

if self.type == 'add_user':
user = lookup(User, 'user', '#{}', '{0.username} (#{0.id})')
return 'User {} created'.format(user)

if self.type == 'disable':
return 'User disabled'

if self.type == 'enable':
return 'User enabled'

if self.type == 'register':
addr = lookup(Address, 'address', '#{}', '{0.address} (#{0.id})')
return 'Address {} registered'.format(addr)

return '%s %r' % (self.type, self.data)

@property
def target(self):
return self.user or self.address

@target.setter
def target(self, target):
if target is None:
pass
elif isinstance(target, User):
self.user_id = target.id
elif isinstance(target, Address):
self.address_id = target.id
else:
raise ValueError('Log target should be User or Address')

def __str__(self):
return self.get_text()

def __repr__(self):
return "Log(%r, target=%r, data=%r)" % (self.type, self.target, self.data)


class Domain(Base):
__tablename__ = 'domains'
id = Column(Integer, primary_key=True)
name = Column(String, index=True, unique=True)
catchall = Column(String, nullable=True)
register_open = Column(Boolean, nullable=False, default=False)
register_regexp = Column(String, nullable=True)
default_quota = Column(Integer, nullable=True)
add_date = Column(DateTime, nullable=False, default=datetime.now)

addresses = relationship('Address', lazy='dynamic',
backref=backref('domain', lazy='joined'))

def __repr__(self):
return "Domain(%r)" % self.name


class PwResetToken(Base):
__tablename__ = 'pwresettokens'
id = Column(Integer, primary_key=True)
address_id = Column(ForeignKey('addresses.id'), nullable=False, index=True)
token = Column(String(32), index=True, nullable=False)
create_date = Column(DateTime, default=datetime.now, nullable=False)

def __init__(self, **kwargs):
kwargs.setdefault('token', random_token(32))
super().__init__(**kwargs)

def __repr__(self):
return "PwResetToken(%r)" % self.token


def log(type, target=None, **data):
""" Save a Log entry """

l = Log(type=type, target=target, data=data)
DBSession.add(l)
return l


def get_user_log(request, user=None):
user = user or request.user
access = request.appconfig.user_log_access
if request.user.user and access not in ('address', 'user'):
return None

return Log.query(user_id=user.id) \
.order_by(Log.id.desc()) \
.limit(request.appconfig.user_log_limit) \
.all()


def get_address_log(request, address=None):
address = address or request.user
access = request.appconfig.address_log_access
if request.user.address and access != 'address':
return None
if request.user.user and access not in ('address', 'user'):
return None

return Log.query(address_id=address.id) \
.order_by(Log.id.desc()) \
.limit(request.appconfig.address_log_limit) \
.all()



BIN
pizzavolus/scripts/.apiacl.py.swp View File


+ 1
- 0
pizzavolus/scripts/__init__.py View File

@@ -0,0 +1 @@
# package

+ 125
- 0
pizzavolus/scripts/config.py View File

@@ -0,0 +1,125 @@
"""

Mail server configuration generator

"""

import sys
import re
from argparse import ArgumentParser

from sqlalchemy import engine_from_config
from pyramid.paster import get_appsettings, setup_logging

from pizzavolus.models import Address, Domain


def format_sql(query, **kwargs):
query = re.sub('[ \n]+', ' ', query)
query = query.format(addresses_t=Address.__tablename__,
domains_t=Domain.__tablename__,
**kwargs)
return query


dovecot_query = format_sql("""
SELECT '%u' AS userid, password
FROM {addresses_t} AS at, {domains_t} AS dt
WHERE dt.id=at.domain_id
AND at.local_part='%n' AND dt.name='%d'
AND at.password IS NOT NULL
AND at.enabled
""")

postfix_domains_query = format_sql("""
SELECT '%s' AS domain
FROM {domains_t} AS dt
WHERE dt.name='%s'
""")

postfix_aliases_query = format_sql("""
SELECT COALESCE((
SELECT at.redirect
FROM {addresses_t} AS at, {domains_t} AS dt
WHERE dt.id=at.domain_id AND dt.name='%d'
AND at.local_part='%u' AND enabled AND redirect IS NOT NULL
), (
SELECT dt.catchall
FROM {domains_t} AS dt
WHERE dt.name='%d' AND dt.catchall IS NOT NULL
AND NOT EXISTS (
SELECT id
FROM {addresses_t} AS at
WHERE at.local_part='%u' AND at.domain_id=dt.id
)
)) as redirect
""")

postfix_mailboxes_query = format_sql("""
SELECT '%d/%u'
FROM {addresses_t} AS at, {domains_t} AS dt
WHERE dt.id=at.domain_id
AND dt.name='%d' AND at.local_part='%u'
AND at.enabled
AND at.redirect IS NULL
""")


def gen_dovecot(url):
print("driver = pgsql")
print("default_pass_scheme = SHA512-CRYPT")

connect = "host={url.host} dbname={url.database} user={url.username}"
if url.password:
connect += " password={url.password}"
print("connect = " + connect.format(url=url))

print("password_query = " + dovecot_query)


def postfix_common(url):
print('hosts = ' + url.host)
print('dbname = ' + url.database)
print('user = ' + url.username)
if url.password:
print('password = ' + url.password)


def gen_postfix_domains(url):
postfix_common(url)
print("query = " + postfix_domains_query)


def gen_postfix_aliases(url):
postfix_common(url)
print("query = " + postfix_aliases_query)


def gen_postfix_mailboxes(url):
postfix_common(url)
print("query = " + postfix_mailboxes_query)


def main(argv=sys.argv):
servers = dict(
postfix_domains=gen_postfix_domains,
postfix_aliases=gen_postfix_aliases,
postfix_mailboxes=gen_postfix_mailboxes,
dovecot=gen_dovecot,
)

parser = ArgumentParser(description=__doc__)
parser.add_argument('config')

parser.add_argument('server', type=str, action='store',
choices=servers.keys())

args = parser.parse_args()

config_uri = args.config
setup_logging(config_uri)
settings = get_appsettings(config_uri)
engine = engine_from_config(settings, 'sqlalchemy.')

servers[args.server](engine.url)


+ 112
- 0
pizzavolus/scripts/importcsv.py View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3

"""

Import CSV files (local_part, password, redirect, domain_name).

"""

import os
import sys
import csv
from argparse import ArgumentParser

from sqlalchemy import engine_from_config
from sqlalchemy.orm.exc import NoResultFound
from pyramid.paster import get_appsettings, setup_logging
import transaction

from pizzavolus.models import DBSession, Address, Domain


def usage(argv, out=sys.stdout):
cmd = os.path.basename(argv[0])
out.write('usage: %s <config_uri> <csv_file>\n'
'(example: "%s development.ini")\n' % (cmd, cmd))
sys.exit(1)


def main(argv=sys.argv):
parser = ArgumentParser(description=__doc__)
parser.add_argument('config')
parser.add_argument('csvfile', type=str, action='store',
help="CSV file")
parser.add_argument('-q', dest='quiet', action='store_true',
help="Dont print every address")

commit = parser.add_mutually_exclusive_group()
commit.add_argument('-n', dest='commit_never', action='store_true',
help="Dont commit changes and dont ask")
commit.add_argument('-y', dest='commit_always', action='store_true',
help="Commit changes without asking")

args = parser.parse_args()

config_uri = args.config
setup_logging(config_uri)
settings = get_appsettings(config_uri)
engine = engine_from_config(settings, 'sqlalchemy.')
DBSession.configure(bind=engine)

transaction.begin()

known_domains = {}
counter = 0

with open(args.csvfile, 'r') as csvfile:
reader = csv.reader(csvfile)
for row in reader:
local, password, redirect, dname = row

if dname in known_domains:
domain = known_domains[dname]
else:
try:
domain = Domain.query(name=dname).one()
except NoResultFound:
domain = Domain(name=dname)
DBSession.add(domain)
known_domains[domain.name] = domain

if not local:
if not redirect:
print('Invalid address: catch-all without redirect')
return 1
domain.catchall = redirect
print('*@%s -> %s' % (domain.name, domain.catchall))
continue

address = Address()
address.local_part = local
address.password = password or None
address.redirect = redirect or None
address.domain = domain
address.domain_id = domain.id

DBSession.add(address)

if not args.quiet:
print(address.address, end='')
if address.password:
print(' [...%s]' % address.password[-8:], end='')
if address.redirect:
print(' -> %s' % address.redirect, end='')
print()

counter += 1

if args.commit_always:
transaction.commit()
print('saved.')
elif args.commit_never:
transaction.abort()
print('aborted.')
else:
r = input('Commit %d addresses? ' % counter)
if r.strip().lower() == 'y':
transaction.commit()
print('saved.')
else:
transaction.abort()
print('aborted.')


+ 42
- 0
pizzavolus/scripts/initializedb.py View File

@@ -0,0 +1,42 @@
import os
import sys

from sqlalchemy import engine_from_config
from pyramid.paster import get_appsettings, setup_logging
import transaction
from alembic.config import Config
from alembic import command

from pizzavolus.models import DBSession, Base, User


def usage(argv, out=sys.stdout):
cmd = os.path.basename(argv[0])
out.write('usage: %s <config_uri>\n'
'(example: "%s development.ini")\n' % (cmd, cmd))
sys.exit(1)


def initialize_db():
Base.metadata.create_all(DBSession.bind.engine)
with transaction.manager:
admin = User(username='Admin')
admin.set_password('root')
DBSession.add(admin)
DBSession.flush()


def main(argv=sys.argv):
if len(argv) != 2:
usage(argv)
config_uri = argv[1]
setup_logging(config_uri)
settings = get_appsettings(config_uri)
engine = engine_from_config(settings, 'sqlalchemy.')
DBSession.configure(bind=engine)

initialize_db()

alembic = Config(config_uri)
command.stamp(alembic, 'head')


+ 89
- 0
pizzavolus/scripts/log.py View File

@@ -0,0 +1,89 @@
"""

App log query tool.
Log entries can be filtered by address or user, and/or by type name.
They are sorted by date, oldest entries first without -r.

"""

import sys
from argparse import ArgumentParser

from sqlalchemy import engine_from_config
from pyramid.paster import get_appsettings, setup_logging

from pizzavolus.models import DBSession, Log, User, Address, Domain


def main(argv=sys.argv):
parser = ArgumentParser(description=__doc__)
parser.add_argument('config')
parser.add_argument('-u', '--userid', type=int, action='store',
help='Filter by user ID')
parser.add_argument('-a', '--addressid', type=int, action='store',
help='Filter by address ID')
parser.add_argument('-U', '--user', type=str, action='store',
help='Filter by username')
parser.add_argument('-A', '--address', type=str, action='store',
help='Filter by address')
parser.add_argument('-s', '--site', action='store_true',
help='Get only site entries (no user or address)')
parser.add_argument('-t', '--type', action='store',
help='Filter by entry type')
parser.add_argument('-r', '--reverse', action='store_true',
help='Latest entries first')
parser.add_argument('-n', '--lines', type=int, action='store',
help='Limit number of entries')

args = parser.parse_args()

config_uri = args.config
setup_logging(config_uri)
settings = get_appsettings(config_uri)
engine = engine_from_config(settings, 'sqlalchemy.')
DBSession.configure(bind=engine)

q = DBSession.query(Log)

filters = ('userid', 'addressid', 'user', 'address', 'site')
if sum(1 for f in filters if getattr(args, f)) > 1:
print('Only use one of ' + ', '.join('--'+f for f in filters))
return 1

if args.userid:
q = q.filter(Log.user_id == args.userid)
elif args.addressid:
q = q.filter(Log.address_id == args.addressid)
elif args.user:
q = q.filter(User.username == args.user)
q = q.filter(Log.user_id == User.id)
elif args.address:
q = q.filter(Address.address == args.address)
q = q.filter(Log.address_id == Address.id)
q = q.filter(Address.domain_id == Domain.id)
elif args.site:
q = q.filter(Log.user_id == None)
q = q.filter(Log.address_id == None)

if args.type:
q = q.filter(Log.type == args.type)

if args.reverse:
q = q.order_by(Log.date)
else:
q = q.order_by(Log.date.desc())

if args.lines:
q = q.limit(args.lines)

for entry in reversed(q.all()):
date = entry.date.strftime('%Y-%m-%d %H:%M:%S')
if entry.user_id:
id = 'user#{}'.format(entry.user_id)
elif entry.address_id:
id = 'addr#{}'.format(entry.address_id)
else:
id = 'site'
print('{date} {id}: {text}'
.format(date=date, id=id, text=entry.get_text()))


+ 87
- 0
pizzavolus/scripts/user.py View File

@@ -0,0 +1,87 @@
"""

User management.

"""

import sys
from argparse import ArgumentParser
import getpass

from sqlalchemy import engine_from_config
from pyramid.paster import get_appsettings, setup_logging
import transaction

from pizzavolus.models import DBSession, User, log


def main(argv=sys.argv):
parser = ArgumentParser(description=__doc__)
parser.add_argument('config')

parser.add_argument('username', type=str, action='store',
help="Username")

command = parser.add_mutually_exclusive_group()
command.add_argument('-P', '--set-password', action='store_true',
help="Change user's password (prompt)")
command.add_argument('-L', '--lock', action='store_true',
help="Lock account")
command.add_argument('-U', '--unlock', action='store_true',
help="Unlock account")
command.add_argument('-I', '--info', action='store_true',
help="Account details (default)")
command.add_argument('-A', '--add', action='store_true',
help="Add account")

args = parser.parse_args()

config_uri = args.config
setup_logging(config_uri)
settings = get_appsettings(config_uri)
engine = engine_from_config(settings, 'sqlalchemy.')
DBSession.configure(bind=engine)

with transaction.manager:
if args.add:
u = User(username=args.username)
p = getpass.getpass()
if not p:
print("No password supplied, user not created.")
return 1
u.set_password(p)
DBSession.add(u)
DBSession.flush()

log('add_user', user=u.id)

else:
user = User.query(username=args.username).first()
if not user:
print("User not found.")
return 1

if args.set_password:
p = getpass.getpass()
if not p:
print("No password supplied, user not created.")
return 1
user.set_password(p)
log('set_password', target=user)
print("Password changed")

elif args.lock:
user.enabled = False
log('disable', target=user)
print("Account locked")

elif args.unlock:
user.enabled = True
log('enable', target=user)
print("Account unlocked")

else:
print("Account #%d" % user.id)
print("Username: %s" % user.username)
print("Status: %s" % 'enabled' if user.enabled else 'disabled')


+ 7
- 0
pizzavolus/static/grids-responsive-min.css
File diff suppressed because it is too large
View File


+ 11
- 0
pizzavolus/static/pure-min.css
File diff suppressed because it is too large
View File


+ 239
- 0
pizzavolus/static/style.css View File

@@ -0,0 +1,239 @@
* {
box-sizing: border-box;
}

body {
background: #111;
font-family: "Open Sans", "DejaVu Sans", Helvetica, Arial, sans-serif;
}

.align-left { text-align: left; }
.align-right { text-align: right; }

.pure-g > div {
margin: 0 auto;
margin-bottom: 3em;
}

a {
color: #bbb;
}
a:visited {
color: #999;
}

body {
color: #ccc;
}

#header {
margin: 0 4em 0 4em;
}

#header h1 {
font-size: 2.5em;
display: inline-block;
}
#header h1 a {
text-transform: uppercase;
color: #32A1F0;
font-weight: 300;
text-decoration: none;
}
#header p {
display: inline-block;
float: right;
text-align: right;
border-bottom: 2px solid #2A79A2;
}
#header p a {
font-weight: 300;
font-size: 1.3em;
text-transform: uppercase;
text-decoration: none;
padding: 0 .5em 0 .5em;
color: #4CC2FF;
color: #2092E3;
}
#header p a:hover {
border-bottom: 2px solid #3da4da;
}



.pure-form input[type=submit],
.pure-button {
background: #0F3047;
border: 1px solid #133E5C;
color: #ddd;
}
.pure-form input[type=text],
.pure-form input[type=password],
.pure-form input[type=email],
.pure-form textarea,
.pure-form select {
background: #111;
border: 1px solid #2a79a2;
box-shadow: none;
height: 2.5em;
margin: 1em;
color: #fff;
}
.pure-form input[type=text],
.pure-form input[type=password],
.pure-form input[type=email],
.pure-form textarea {
}
.pure-form input[type=text]:focus,
.pure-form input[type=password]:focus,
.pure-form input[type=email]:focus,
.pure-form textarea:focus {
border: 1px solid #3DA4DA;
}
.pure-form label {
color: #ddd;
margin-top: 0.50em;
}
.pure-form select {
}
.input-like-thing {
margin: 1em;
padding: 0.5em 0.6em;
display: inline-block;
border: 1px solid transparent;
text-align: left;
color: #999;
}
.pure-form input[type=checkbox] {
margin: 1em;
padding: 0.5em 0.6em;
border: 1px solid #2a79a2;

width: 1em;
height: 1em;
}

.pure-form input.half-left,
.pure-form select.half-left {
margin-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.pure-form input.half-right,
.pure-form select.half-right {
margin-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 0;
}

.status-enabled { color: #00B104; }
.status-disabled { color: #B00051; }

.pure-form legend,
h3 {
margin: 0 0 1em 0;
border-bottom: 1px solid #2092e3;
color: #2092e3;
font-size: 1.2em;
font-weight: 300;
text-transform: uppercase;
padding: 0.3em 0px;
}


h2 {
text-align: center;
color: #ddd;
font-weight: 300;
font-size: 2em;
letter-spacing: 0.08em;
}

.message {
margin: 0;
padding: 0.25em;
text-align: center;
}
.message--info {
background: #004371;
color: #eee;
}
.message--error {
background: #710000;
color: #ccc;
}




#footer {
background: #111;
padding: 2em 0.5em 1em 0.5em;
margin-top: 6em;
text-align: center;
}
#footer p,
#footer a {
color: #999;
}
#footer a.impact {
margin: 0.3em;
display: inline-block;
text-transform: uppercase;
font-size: 1.7em;
font-weight: 300;
color: #a00000;
text-decoration: none;
border: 1px solid #a00000;
}








table {
width: 100%;
}
table td {
padding: 0.5em;
text-align: center;
}
table thead tr td {
color: #2092e3;
}

table tr:nth-child(even) { background: #181818; }
table tr:nth-child(odd) { background: #1f1f1f; }
table thead tr:nth-child(odd) { background: transparent; }



div.g-recaptcha div div {
margin: 1em auto;
}



#page {
text-align: center;
}



@media (min-width: 48em) {
.content {
padding-right: 2em;
}
}

@media (max-width: 48em) {
form.pure-form-stacked input.pure-input-1 {
margin-left: 0;
}
}



+ 112
- 0
pizzavolus/templates/address.mako View File

@@ -0,0 +1,112 @@
<%! title = 'Address' %>
<%inherit file="layout.mako" />

% if admin:
<h2>
<a href="/admin/${address.domain.name}">${address.domain.name}</a>
&gt;
${address.address}
</h2>
% else:
<h2>Address: ${address.address}</h2>
% endif


<div id="page" class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
<form class="pure-form pure-form-aligned" action="" method="post">
<fieldset>
<legend>Change Password</legend>

<div class="pure-control-group">
<label for="password">New Password</label>
<input type="password" id="password" name="password" required
class="pure-input-1-2" />
</div>

<div class="pure-control-group">
<label for="password2">Repeat</label>
<input type="password" id="password2" name="password2" required
class="pure-input-1-2" />
</div>

<div class="pure-controls">
<input type="hidden" name="update" value="password" />
<input type="submit" class="pure-button pure-button-primary"
value="Change Password" />
</div>
</fieldset>
</form>
% if admin:
<form class="pure-form pure-form-aligned" action="" method="post">
<div class="pure-controls">
<input type="hidden" name="update" value="new_reset_token" />
<input type="submit" class="pure-button pure-button-primary"
value="Create Reset Token" />
</div>
</form>
% endif
</div>
<div class="pure-u-1 pure-u-md-1-2">
<form id="redirect-form" class="pure-form pure-form-aligned" action="" method="post">
<fieldset>
<legend>Redirection</legend>

<% redirect_enabled = 'enabled' if address.redirect else 'disabled' %>

<div class="pure-control-group">
<label for="redirect">Status</label>
<p class="input-like-thing pure-input-1-2">
<span class="status-${redirect_enabled}">&#x2b24;</span>
${redirect_enabled.title()}
</p>
</div>

<div class="pure-control-group">
<label for="redirect">Destination</label>
<input type="text" id="redirect" name="redirect" pattern="(.+@.+)?"
value="${address.redirect or ''}" class="pure-input-1-2" />
</div>

<div class="pure-controls">
<input type="hidden" name="update" value="redirect" />
% if address.redirect:
<button class="pure-button pure-button-primary" id="redirect-disable">
Disable
</button>
% endif
<input type="submit" class="pure-button pure-button-primary"
value="Set Redirection" />
</div>
</fieldset>
</form>
</div>
% if log:
<div class="pure-u-1">
<h3>Log</h3>
<table>
<thead>
<tr><td>date</td> <td>event</td></tr>
</thead>
% for entry in log:
<tr>
<td>${date_fmt(entry.date)}</td>
<td class="align-left">${entry.get_text()}
</tr>
% endfor
</table>
</div>
% endif
</div>

<script type="text/javascript">
(function() {
form = document.getElementById("redirect-form");
dest = document.getElementById("redirect");
button = document.getElementById("redirect-disable");
button.onclick = function() {
dest.value = "";
form.submit();
};
})();
</script>

+ 40
- 0
pizzavolus/templates/admin.mako View File

@@ -0,0 +1,40 @@
<%! title = 'Domain' %>
<%inherit file="layout.mako" />

<h2>Admin</h2>

<div id="page" class="pure-g">
<div class="pure-u-1">
<h3>Add Domain</h3>
<form class="pure-form pure-form-aligned pure-u-lg-1-2" action="" method="post">
<fieldset>
<div class="pure-control-group">
<label for="name">Domain Name</label>
<input type="text" id="name" name="name" required
class="pure-input-1-2" />
</div>

<div class="pure-controls">
<input type="hidden" name="update" value="new_domain" />
<input type="submit" class="pure-button pure-button-primary"
value="Add Domain" />
</div>
</fieldset>
</form>
</div>
<div class="pure-u-1">
<h3>Domains (${len(domains)})</h3>
<table>
<thead>
<tr><td>name</td> <td>open</td> <td>addresses</td> </tr>
</thead>
% for domain in domains:
<tr>
<td><a href="/admin/${domain.name}">${domain.name}</a></td>
<td>${domain.register_open or ''}</td>
<td>${domain.addresses.count()}</td>
</tr>
% endfor
</table>
</div>
</div>

+ 25
- 0
pizzavolus/templates/auth.mako View File

@@ -0,0 +1,25 @@
<%! title = 'Auth' %>
<%inherit file="layout.mako" />

<div id="page" class="pure-g">
<div class="pure-u-1">
<h3>Log in</h3>
<form class="pure-form pure-form-stacked pure-u-md-1-2" action="?" method="post">
<fieldset>
<label for="username">Username / E-Mail</label>
<input type="text" id="username" name="username" required
value="${username or ''}" class="pure-input-1"
pattern=".{2,}(@[a-zA-Z0-9_.-]{2,})?"
placeholder="Username" autofocus/>

<label for="password">Password</label>
<input type="password" id="password" name="password" required class="pure-input-1"
value="${password or ''}" placeholder="Password" />

<input type="submit" class="pure-button pure-button-primary"
value="Log in" />
</fieldset>
</form>
</div>
</div><!-- div#page -->


+ 107
- 0
pizzavolus/templates/domain.mako View File

@@ -0,0 +1,107 @@
<%! title = 'Domain' %>
<%inherit file="layout.mako" />

<h2>Domain: ${domain.name}</h2>

<div id="page" class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
<form class="pure-form pure-form-aligned" action="" method="post">
<fieldset>
<legend>Add Address</legend>

<div class="pure-control-group">
<label for="local_part">Local Name</label>
<input type="text" id="local_part" name="local_part" required
class="pure-input-1-2"
placeholder="[local name]@${domain.name}" />
</div>

<div class="pure-control-group">
<label for="password">Password</label>
<input type="password" id="password" name="password"
class="pure-input-1-2" placeholder="Keep empty to get a reset link" />
</div>

<div class="pure-control-group">
<label for="redirect">Redirect?</label>
<input type="text" id="redirect" name="redirect" pattern="(.+@.+)?"
class="pure-input-1-2" />
</div>

<div class="pure-controls">
<input type="hidden" name="update" value="new_addr" />
<input type="submit" class="pure-button pure-button-primary"
value="Add Address" />
</div>
</fieldset>
</form>
</div>
<div class="pure-u-1 pure-u-md-1-2">
<form class="pure-form pure-form-aligned" action="" method="post" id="settings_form">
<fieldset>
<legend>Settings</legend>

<label for="reg_open" class="pure-checkbox">
<input type="checkbox" id="reg_open" name="reg_open"
${'checked' if domain.register_open else ''} />
Open registrations
</label>

<div class="pure-control-group">
<label for="reg_regexp">Local part regex</label>
<input type="text" id="reg_regexp" name="reg_regexp"
class="pure-input-1-2" value="${domain.register_regexp or ''}"
placeholder="Keep empty to use the default RE" />
</div>

<div class="pure-control-group">
<label for="catchall">Catch all?</label>
<input type="text" id="catchall" name="catchall" pattern="(.+@.+,?)*"
class="pure-input-1-2" value="${domain.catchall or ''}" />
</div>

<div class="pure-control-group">
<label for="default_quota">Default quota (MB)</label>
<input type="text" id="default_quota" name="default_quota" pattern="[0-9]+"
class="pure-input-1-2" value="${domain.default_quota or 0}" />
</div>

<div class="pure-controls">
<input type="hidden" name="update" value="settings" />
<input type="submit" class="pure-button pure-button-primary"
value="Update" />
</div>
<script>
(function(){
var form = document.getElementById("settings_form");
var ro = form.elements.namedItem("reg_open");
var rr = form.elements.namedItem("reg_regex");
function update() {
rr.parentElement.style.display = ro.checked ? "block" : "none";
}
ro.onchange = update;
update();
})();
</script>
</fieldset>
</form>
</div>
<div class="pure-u-1">
<h3>Addresses (${len(addresses)})</h3>
<table>
<thead>
<tr><td>name</td> <td>redirect</td></tr>
</thead>
% for address in addresses:
<tr>
<td class="align-right">
<a href="/admin/${address.domain.name}/${address.local_part}">
${address.address}
</a>
</td>
<td>${address.redirect or ''}</td>
</tr>
% endfor
</table>
</div>
</div>

+ 76
- 0
pizzavolus/templates/layout.mako View File

@@ -0,0 +1,76 @@
<%
sitename = request.appconfig.sitename

footer_text = request.appconfig.footer_text

if title:
page_title = title + ' - ' + sitename
elif hasattr(self.attr, 'title') and self.attr.title:
page_title = self.attr.title + ' - ' + sitename
else:
page_title = sitename

webmail = request.appconfig.webmail_link
%>

<%def name="csrf_input()">
<input type="hidden" name="csrf_token"
value="${request.session.get_csrf_token()}" />
</%def>

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="${request.locale_name}">
<head>
<title>${page_title}</title>
<meta charset="UTF-8" />
<%block name="headers"></%block>
<link rel="stylesheet" href="/static/pure-min.css">
<link rel="stylesheet" href="/static/grids-responsive-min.css" media="screen" />
<link rel="stylesheet" href="/static/style.css" media="screen" />
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400,300,600&subset=latin,greek' rel='stylesheet' type='text/css' />
</head>
<body>
<div id="header">
<h1><a href="/">${sitename}</a></h1>
<p>
% if request.user:
% if webmail:
<a href="${webmail}">Webmail</a>
% endif
% if request.user.user:
<a href="/admin">Admin</a>
<a href="/user">Account</a>
% else:
<a href="/address">Address</a>
% endif
<a href="/logout">Logout</a>
% else:
% if webmail:
<a href="${webmail}">Webmail</a>
% endif
<a href="/auth">Log in</a>
% endif
</p>
<div style="clear: both"></div>
</div>

<div class="messages">
% for packed in request.session.pop_flash():
<% t, m = packed if len(packed) == 2 else ('info', packed) %>
<p class="message message--${t}">${m}</p>
% endfor
</div>
${next.body()}

<div id="footer">
<p>
<a href="https://git.ccrypto.org/CCrypto/PizzaVolus">PizzaVolus on CCrypto Git</a>
% if footer_text:
- ${footer_text | n}
% endif
</p>
</div>
</body>
</html>


+ 25
- 0
pizzavolus/templates/reset.mako View File

@@ -0,0 +1,25 @@
<%! title = 'Password Reset' %>
<%inherit file="layout.mako" />

<h2>${address.address}</h2>

<div id="page" class="pure-g">
<div class="pure-u-1">
<h3>Password Reset</h3>

<form class="pure-form pure-form-stacked pure-u-md-1-2" action="?" method="post">
<fieldset>
<label for="password">Password</label>
<input type="password" id="password" name="password" required
class="pure-input-1" autofocus />

<label for="password2">Repeat</label>
<input type="password" id="password2" name="password2" required
class="pure-input-1" />

<input type="submit" class="pure-button pure-button-primary"
value="Set password" />
</fieldset>
</form>
</div>
</div>

+ 104
- 0
pizzavolus/templates/root.mako View File

@@ -0,0 +1,104 @@
<%inherit file="layout.mako" />

<div id="page" class="pure-g">
<div class="pure-u-1">
<h3>Register an Address</h3>
<form class="pure-form pure-form-aligned pure-u-lg-1-2" action="" method="post">
<fieldset>
% if domains_reg:
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<div class="pure-control-group">
<label for="local_part">Name</label>
<input type="text" id="local_part" name="local_part" required
pattern="${lname_pattern}"
class="pure-input-1-4 half-left" /><!--
--><select class="pure-input-1-4 half-right" name="domain" id="domain">
% for d in domains_reg:
<option value="${d.id}" data-pattern="${d.register_regexp}">@${d.name}</option>
% endfor
</select>
</div>

<div class="pure-control-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required
class="pure-input-1-2" />
</div>

<div class="pure-control-group">
<label for="password2">Repeat</label>
<input type="password" id="password2" name="password2" required
class="pure-input-1-2" />
</div>

% if recaptcha_site:
<div class="g-recaptcha"
data-theme="dark"
data-sitekey="${recaptcha_site}"></div>
<noscript>
<div style="width: 302px; height: 422px;">
<div style="width: 302px; height: 422px; position: relative;">
<div style="width: 302px; height: 422px;">
<iframe src="https://www.google.com/recaptcha/api/fallback?k=${recaptcha_site}"
frameborder="0" scrolling="no"
style="width: 302px; height:422px; border-style: none;">
</iframe>
</div>
<div style="width: 300px; height: 60px; border-style: none;
bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;
background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;">
<textarea id="g-recaptcha-response" name="g-recaptcha-response"
class="g-recaptcha-response"
style="width: 250px; height: 40px; border: 1px solid #c1c1c1;
margin: 10px 25px; padding: 0px; resize: none;" >
</textarea>
</div>
</div>
</div>
</noscript>
% endif

<div class="pure-controls">
<input type="hidden" name="update" value="new_addr" />
<input type="submit" class="pure-button pure-button-primary"
value="Add Address" />
</div>
% else:
<p>No domain open for registrations for now.</p>
% endif
</fieldset>
</form>
</div>
</div><!-- div#page -->

<script>
(function(){
var local_part = document.getElementById("local_part");
var domain_select = document.getElementById("domain");
var pw1 = document.getElementById("password");
var pw2 = document.getElementById("password2");
function update() {
var item = domain_select.options[domain_select.selectedIndex];
var pattern = item.getAttribute("data-pattern");
if (pattern != null && pattern != "") {
local_part.setAttribute("pattern", pattern);
} else {
local_part.setAttribute("pattern", "${lname_pattern}");
}
}
function onpwupdate() {
if (pw2.value != pw1.value) {
pw2.setCustomValidity("Passwords does not match");
} else {
pw2.setCustomValidity("");
}
}
domain_select.onchange = update;
update();
pw1.onkeyup = onpwupdate;
pw2.onkeyup = onpwupdate;
onpwupdate();
})();
</script>


+ 52
- 0
pizzavolus/templates/user.mako View File

@@ -0,0 +1,52 @@