castmail2list 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- castmail2list-0.1.0/PKG-INFO +79 -0
- castmail2list-0.1.0/README.md +38 -0
- castmail2list-0.1.0/castmail2list/__init__.py +5 -0
- castmail2list-0.1.0/castmail2list/app.py +289 -0
- castmail2list-0.1.0/castmail2list/babel.cfg +2 -0
- castmail2list-0.1.0/castmail2list/config.py +130 -0
- castmail2list-0.1.0/castmail2list/forms.py +118 -0
- castmail2list-0.1.0/castmail2list/gunicorn.conf.py +19 -0
- castmail2list-0.1.0/castmail2list/imap_worker.py +358 -0
- castmail2list-0.1.0/castmail2list/mailer.py +286 -0
- castmail2list-0.1.0/castmail2list/migrations/README +1 -0
- castmail2list-0.1.0/castmail2list/migrations/alembic.ini +50 -0
- castmail2list-0.1.0/castmail2list/migrations/env.py +113 -0
- castmail2list-0.1.0/castmail2list/migrations/script.py.mako +24 -0
- castmail2list-0.1.0/castmail2list/migrations/versions/000000000000_initial_schema.py +81 -0
- castmail2list-0.1.0/castmail2list/models.py +159 -0
- castmail2list-0.1.0/castmail2list/seed_default.json +52 -0
- castmail2list-0.1.0/castmail2list/seeder.py +124 -0
- castmail2list-0.1.0/castmail2list/static/css/fontawesome-all.min.css +9 -0
- castmail2list-0.1.0/castmail2list/static/js/themeToggle.js +73 -0
- castmail2list-0.1.0/castmail2list/static/scss/main.scss +106 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/_index.scss +49 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/_settings.scss +143 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/colors/_index.scss +886 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_background-colors.scss +74 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_colors.scss +51 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_css-vars.scss +55 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_index.scss +8 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_settings.scss +106 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_utils.scss +60 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/components/_accordion.scss +116 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/components/_card.scss +46 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/components/_dropdown.scss +280 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/components/_group.scss +120 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/components/_loading.scss +46 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/components/_modal.scss +177 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/components/_nav.scss +160 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/components/_progress.scss +102 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/components/_tooltip.scss +215 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/content/_button.scss +209 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/content/_code.scss +77 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/content/_embedded.scss +54 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/content/_figure.scss +19 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/content/_link.scss +69 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/content/_misc.scss +38 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/content/_table.scss +62 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/content/_typography.scss +182 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_basics.scss +471 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_checkbox-radio-switch.scss +177 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-color.scss +37 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-date.scss +60 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-file.scss +41 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-range.scss +100 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-search.scss +60 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/helpers/_copyright.scss +4 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/helpers/_functions.scss +55 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_container.scss +33 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_document.scss +53 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_grid.scss +26 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_landmarks.scss +62 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_overflow-auto.scss +12 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_section.scss +12 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/pico.classless.scss +5 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/pico.colors.scss +2 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/pico.conditional.scss +4 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/pico.fluid.classless.scss +6 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/pico.scss +2 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/postcss.config.js +9 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/themes/_default.scss +5 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_dark.scss +261 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_light.scss +221 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_schemes.scss +41 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_styles.scss +435 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_theme-colors.scss +491 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/utilities/_accessibility.scss +57 -0
- castmail2list-0.1.0/castmail2list/static/scss/pico/utilities/_reduce-motion.scss +30 -0
- castmail2list-0.1.0/castmail2list/static/webfonts/fa-brands-400.woff2 +0 -0
- castmail2list-0.1.0/castmail2list/static/webfonts/fa-regular-400.woff2 +0 -0
- castmail2list-0.1.0/castmail2list/static/webfonts/fa-solid-900.woff2 +0 -0
- castmail2list-0.1.0/castmail2list/static/webfonts/fa-v4compatibility.woff2 +0 -0
- castmail2list-0.1.0/castmail2list/templates/base.html +48 -0
- castmail2list-0.1.0/castmail2list/templates/flash.html +11 -0
- castmail2list-0.1.0/castmail2list/templates/index.html +10 -0
- castmail2list-0.1.0/castmail2list/templates/lists/add.html +13 -0
- castmail2list-0.1.0/castmail2list/templates/lists/edit.html +13 -0
- castmail2list-0.1.0/castmail2list/templates/lists/index.html +51 -0
- castmail2list-0.1.0/castmail2list/templates/lists/macros.html +79 -0
- castmail2list-0.1.0/castmail2list/templates/lists/subscriber_edit.html +47 -0
- castmail2list-0.1.0/castmail2list/templates/lists/subscribers_manage.html +57 -0
- castmail2list-0.1.0/castmail2list/templates/login.html +21 -0
- castmail2list-0.1.0/castmail2list/templates/messages/bounces.html +29 -0
- castmail2list-0.1.0/castmail2list/templates/messages/detail.html +38 -0
- castmail2list-0.1.0/castmail2list/templates/messages/index.html +32 -0
- castmail2list-0.1.0/castmail2list/templates/settings.html +10 -0
- castmail2list-0.1.0/castmail2list/templates/subscriber.html +40 -0
- castmail2list-0.1.0/castmail2list/translations/de/LC_MESSAGES/messages.mo +0 -0
- castmail2list-0.1.0/castmail2list/translations/de/LC_MESSAGES/messages.po +277 -0
- castmail2list-0.1.0/castmail2list/utils.py +443 -0
- castmail2list-0.1.0/castmail2list/views/__init__.py +0 -0
- castmail2list-0.1.0/castmail2list/views/auth.py +44 -0
- castmail2list-0.1.0/castmail2list/views/general.py +48 -0
- castmail2list-0.1.0/castmail2list/views/lists.py +292 -0
- castmail2list-0.1.0/castmail2list/views/messages.py +36 -0
- castmail2list-0.1.0/castmail2list/wsgi.py +98 -0
- castmail2list-0.1.0/pyproject.toml +91 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: castmail2list
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple mailing list software with IMAP mailbox backend, web interface, and multiple list modes.
|
|
5
|
+
License: GPL-3.0-only
|
|
6
|
+
Author: Max Mehl
|
|
7
|
+
Author-email: mail@mehl.mx
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: Framework :: Flask
|
|
10
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Communications :: Email
|
|
19
|
+
Classifier: Topic :: Communications :: Email :: Mailing List Servers
|
|
20
|
+
Classifier: Topic :: Communications :: Email :: Post-Office :: IMAP
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
23
|
+
Requires-Dist: email-validator (>=2.3.0,<3.0.0)
|
|
24
|
+
Requires-Dist: flask (>=3.1.2,<4.0.0)
|
|
25
|
+
Requires-Dist: flask-babel (>=4.0.0,<5.0.0)
|
|
26
|
+
Requires-Dist: flask-limiter (>=4.0.0,<5.0.0)
|
|
27
|
+
Requires-Dist: flask-login (>=0.6.3,<0.7.0)
|
|
28
|
+
Requires-Dist: flask-migrate (>=4.1.0,<5.0.0)
|
|
29
|
+
Requires-Dist: flask-sqlalchemy (>=3.1.1,<4.0.0)
|
|
30
|
+
Requires-Dist: flask-wtf (>=1.2.2,<2.0.0)
|
|
31
|
+
Requires-Dist: flufl-bounce (>=4.0,<5.0)
|
|
32
|
+
Requires-Dist: gunicorn (>=23.0.0,<24.0.0)
|
|
33
|
+
Requires-Dist: imap-tools (>=1.11.0,<2.0.0)
|
|
34
|
+
Requires-Dist: jsonschema (>=4.25.1,<5.0.0)
|
|
35
|
+
Requires-Dist: platformdirs (>=4.5.0,<5.0.0)
|
|
36
|
+
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
|
|
37
|
+
Requires-Dist: wtforms[email] (>=3.2.1,<4.0.0)
|
|
38
|
+
Project-URL: Repository, https://github.com/mxmehl/castmail2list
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# CastMail2List
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
For production use (using gunicorn as WSGI):
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
castmail2list --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For local development and administrative commands (using Flask directly):
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
castmail2list-cli --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
CastMail2List supports loading configuration from YAML files. There are some defaults and some required configuration keys.
|
|
61
|
+
|
|
62
|
+
### Using YAML Configuration
|
|
63
|
+
|
|
64
|
+
1. Copy the example configuration file:
|
|
65
|
+
```bash
|
|
66
|
+
cp config.example.yaml config.yaml
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
2. Edit `config.yaml` with your settings
|
|
70
|
+
|
|
71
|
+
3. Run the application with the `--config` flag:
|
|
72
|
+
```bash
|
|
73
|
+
castmail2list --config config.yaml
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Example Configuration File
|
|
77
|
+
|
|
78
|
+
See `config.example.yaml` for a complete example with all available configuration options.
|
|
79
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# CastMail2List
|
|
2
|
+
|
|
3
|
+
## Usage
|
|
4
|
+
|
|
5
|
+
For production use (using gunicorn as WSGI):
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
castmail2list --help
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For local development and administrative commands (using Flask directly):
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
castmail2list-cli --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## Configuration
|
|
19
|
+
|
|
20
|
+
CastMail2List supports loading configuration from YAML files. There are some defaults and some required configuration keys.
|
|
21
|
+
|
|
22
|
+
### Using YAML Configuration
|
|
23
|
+
|
|
24
|
+
1. Copy the example configuration file:
|
|
25
|
+
```bash
|
|
26
|
+
cp config.example.yaml config.yaml
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
2. Edit `config.yaml` with your settings
|
|
30
|
+
|
|
31
|
+
3. Run the application with the `--config` flag:
|
|
32
|
+
```bash
|
|
33
|
+
castmail2list --config config.yaml
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Example Configuration File
|
|
37
|
+
|
|
38
|
+
See `config.example.yaml` for a complete example with all available configuration options.
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Flask app and CLI for CastMail2List"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
from logging.config import dictConfig
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from flask import Flask
|
|
9
|
+
from flask_babel import Babel
|
|
10
|
+
from flask_limiter import Limiter
|
|
11
|
+
from flask_limiter.util import get_remote_address
|
|
12
|
+
from flask_login import LoginManager
|
|
13
|
+
from flask_migrate import Migrate, check, downgrade, upgrade
|
|
14
|
+
from flask_wtf import CSRFProtect
|
|
15
|
+
from sqlalchemy.exc import OperationalError
|
|
16
|
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
17
|
+
from werkzeug.security import generate_password_hash
|
|
18
|
+
|
|
19
|
+
from .config import AppConfig
|
|
20
|
+
from .imap_worker import initialize_imap_polling
|
|
21
|
+
from .models import AlembicVersion, User, db
|
|
22
|
+
from .seeder import seed_database
|
|
23
|
+
from .utils import (
|
|
24
|
+
compile_scss_on_startup,
|
|
25
|
+
get_app_bin_dir,
|
|
26
|
+
get_user_config_path,
|
|
27
|
+
get_version_info,
|
|
28
|
+
)
|
|
29
|
+
from .views.auth import auth
|
|
30
|
+
from .views.general import general
|
|
31
|
+
from .views.lists import lists
|
|
32
|
+
from .views.messages import messages
|
|
33
|
+
|
|
34
|
+
SCSS_FILES = [("static/scss/main.scss", "static/css/main.scss.css")]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configure_logging(debug: bool) -> None:
|
|
38
|
+
"""Configure logging"""
|
|
39
|
+
dictConfig(
|
|
40
|
+
{
|
|
41
|
+
"version": 1,
|
|
42
|
+
"formatters": {
|
|
43
|
+
"default": {
|
|
44
|
+
"format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s",
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"handlers": {
|
|
48
|
+
"wsgi": {
|
|
49
|
+
"class": "logging.StreamHandler",
|
|
50
|
+
"stream": "ext://flask.logging.wsgi_errors_stream",
|
|
51
|
+
"formatter": "default",
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"root": {"level": "DEBUG" if debug else "INFO", "handlers": ["wsgi"]},
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_app(
|
|
60
|
+
config_overrides: dict | None = None,
|
|
61
|
+
yaml_config_path: str | None = None,
|
|
62
|
+
one_off_call: bool = False,
|
|
63
|
+
) -> Flask:
|
|
64
|
+
"""Create Flask app
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
config_overrides (dict): optional dict to update app.config before DB init (e.g. for tests)
|
|
68
|
+
yaml_config_path (str): optional path to YAML configuration file
|
|
69
|
+
one_off_call (bool): if True, indicates this is a one-off call (e.g. for CLI commands)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Flask: the Flask application
|
|
73
|
+
"""
|
|
74
|
+
app = Flask(__name__)
|
|
75
|
+
logging.debug("Executable bin path: %s", get_app_bin_dir())
|
|
76
|
+
|
|
77
|
+
# Load config from YAML, if provided
|
|
78
|
+
if yaml_config_path:
|
|
79
|
+
appconfig = AppConfig.from_yaml_and_env(yaml_config_path)
|
|
80
|
+
else:
|
|
81
|
+
appconfig = AppConfig() # default config
|
|
82
|
+
|
|
83
|
+
app.config.from_object(appconfig)
|
|
84
|
+
|
|
85
|
+
# apply overrides early so DB and other setup use them
|
|
86
|
+
if config_overrides:
|
|
87
|
+
app.config.update(config_overrides)
|
|
88
|
+
|
|
89
|
+
# Translations
|
|
90
|
+
Babel(app, default_locale=app.config.get("LANGUAGE", "en"))
|
|
91
|
+
logging.info("Language set to: %s", app.config.get("LANGUAGE", "en"))
|
|
92
|
+
app.jinja_env.globals["current_language"] = app.config.get("LANGUAGE", "en")
|
|
93
|
+
|
|
94
|
+
# Database
|
|
95
|
+
# default to SQLite in config dir if no DATABASE_URI set
|
|
96
|
+
if not app.config.get("DATABASE_URI"):
|
|
97
|
+
app.config["DATABASE_URI"] = "sqlite:///" + get_user_config_path(file="castmail2list.db")
|
|
98
|
+
app.config["SQLALCHEMY_DATABASE_URI"] = app.config["DATABASE_URI"]
|
|
99
|
+
logging.info("Using database at %s", app.config["SQLALCHEMY_DATABASE_URI"])
|
|
100
|
+
# Initialize the database
|
|
101
|
+
migrations_dir = str(Path(__file__).parent.resolve() / "migrations")
|
|
102
|
+
db.init_app(app)
|
|
103
|
+
Migrate(app=app, db=db, directory=migrations_dir)
|
|
104
|
+
|
|
105
|
+
# Trust headers from reverse proxy (1 layer by default)
|
|
106
|
+
app.wsgi_app = ProxyFix( # type: ignore[method-assign]
|
|
107
|
+
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Secure session cookie config
|
|
111
|
+
app.config.update(
|
|
112
|
+
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Enable CSRF protection
|
|
116
|
+
CSRFProtect(app)
|
|
117
|
+
|
|
118
|
+
# Configure Flask-Login
|
|
119
|
+
login_manager = LoginManager()
|
|
120
|
+
login_manager.login_view = "auth.login"
|
|
121
|
+
login_manager.init_app(app)
|
|
122
|
+
|
|
123
|
+
# User loader function for Flask-Login
|
|
124
|
+
@login_manager.user_loader
|
|
125
|
+
def load_user(user_id):
|
|
126
|
+
return User.query.get(int(user_id))
|
|
127
|
+
|
|
128
|
+
# Register views and routes
|
|
129
|
+
app.register_blueprint(auth)
|
|
130
|
+
app.register_blueprint(general)
|
|
131
|
+
app.register_blueprint(messages)
|
|
132
|
+
app.register_blueprint(lists)
|
|
133
|
+
|
|
134
|
+
# Inject variables into templates
|
|
135
|
+
@app.context_processor
|
|
136
|
+
def inject_vars():
|
|
137
|
+
return {
|
|
138
|
+
"version_info": get_version_info(debug=app.debug),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# ---------------
|
|
142
|
+
# From here on, only for permanently running app, not one-off calls
|
|
143
|
+
if one_off_call:
|
|
144
|
+
return app
|
|
145
|
+
|
|
146
|
+
# Set up rate limiting
|
|
147
|
+
app.config.setdefault("RATE_LIMIT_DEFAULT", "20 per 1 minute")
|
|
148
|
+
app.config.setdefault("RATE_LIMIT_LOGIN", "2 per 10 seconds")
|
|
149
|
+
app.config.setdefault("RATELIMIT_STORAGE_URI", "memory://")
|
|
150
|
+
limiter = Limiter(
|
|
151
|
+
get_remote_address,
|
|
152
|
+
default_limits=[app.config.get("RATE_LIMIT_DEFAULT", "")],
|
|
153
|
+
storage_uri=app.config.get("RATE_LIMIT_STORAGE_URI"),
|
|
154
|
+
)
|
|
155
|
+
limiter.init_app(app)
|
|
156
|
+
|
|
157
|
+
if app.config.get("RATE_LIMIT_STORAGE_URI") == "memory://" and not app.debug:
|
|
158
|
+
logging.warning(
|
|
159
|
+
"Rate limiting is using in-memory storage. Limits may not work with multiple processes."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Start background IMAP thread unless in testing
|
|
163
|
+
initialize_imap_polling(app)
|
|
164
|
+
|
|
165
|
+
# Compile SCSS files on startup
|
|
166
|
+
app.config["SCSS_FILES"] = compile_scss_on_startup(scss_files=SCSS_FILES)
|
|
167
|
+
|
|
168
|
+
# Debug logging of config
|
|
169
|
+
logging.debug("App configuration:\n%s", app.config)
|
|
170
|
+
|
|
171
|
+
return app
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_app_wrapper(app_config_path: str, debug: bool, one_off: bool) -> Flask:
|
|
175
|
+
"""Wrapper to create app from arguments. Both for direct Flask app and WSGI (gunicorn)"""
|
|
176
|
+
# Configure logging
|
|
177
|
+
configure_logging(debug)
|
|
178
|
+
|
|
179
|
+
# Create Flask app
|
|
180
|
+
app = create_app(yaml_config_path=app_config_path, one_off_call=one_off)
|
|
181
|
+
|
|
182
|
+
return app
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main():
|
|
186
|
+
"""Run the app"""
|
|
187
|
+
parser = argparse.ArgumentParser(
|
|
188
|
+
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
189
|
+
)
|
|
190
|
+
parser.add_argument("-H", "--host", type=str, help="Host of Flask app", default="127.0.0.1")
|
|
191
|
+
parser.add_argument("-p", "--port", type=int, help="Port of Flask app", default=2278)
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"-c",
|
|
194
|
+
"--app-config",
|
|
195
|
+
type=str,
|
|
196
|
+
help="Path to YAML configuration file",
|
|
197
|
+
default=get_user_config_path(file="config.yaml"),
|
|
198
|
+
)
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"--create-admin",
|
|
201
|
+
nargs=2,
|
|
202
|
+
metavar=("USERNAME", "PASSWORD"),
|
|
203
|
+
help="Create an admin user and exit (usage: --create-admin admin secret)",
|
|
204
|
+
)
|
|
205
|
+
# DB Commands
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--db",
|
|
208
|
+
choices=["check", "upgrade", "downgrade", "init"],
|
|
209
|
+
help="Database commands, e.g. for migrations",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--db-seed",
|
|
213
|
+
type=str,
|
|
214
|
+
metavar="SEED_FILE",
|
|
215
|
+
help="Seed the database with a seed file and exit",
|
|
216
|
+
)
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"--debug", action="store_true", help="Run in debug mode (may leak sensitive information)"
|
|
219
|
+
)
|
|
220
|
+
args = parser.parse_args()
|
|
221
|
+
|
|
222
|
+
# Create the Flask application
|
|
223
|
+
one_off = False
|
|
224
|
+
if args.db or args.create_admin or args.db_seed:
|
|
225
|
+
# one-off call for most CLI commands
|
|
226
|
+
one_off = True
|
|
227
|
+
app = create_app_wrapper(app_config_path=args.app_config, debug=args.debug, one_off=one_off)
|
|
228
|
+
|
|
229
|
+
# Create admin user if requested
|
|
230
|
+
if args.create_admin:
|
|
231
|
+
username, password = args.create_admin
|
|
232
|
+
# run inside app context to access DB
|
|
233
|
+
with app.app_context():
|
|
234
|
+
existing = User.query.filter_by(username=username).first()
|
|
235
|
+
if existing:
|
|
236
|
+
logging.error("Error: user '%s' already exists", username)
|
|
237
|
+
return
|
|
238
|
+
new_user = User(
|
|
239
|
+
username=username, password=generate_password_hash(password), role="admin"
|
|
240
|
+
)
|
|
241
|
+
db.session.add(new_user)
|
|
242
|
+
db.session.commit()
|
|
243
|
+
logging.info("Admin user '%s' created", username)
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# Handle DB commands if provided
|
|
247
|
+
if args.db is not None:
|
|
248
|
+
with app.app_context():
|
|
249
|
+
if args.db == "check":
|
|
250
|
+
check()
|
|
251
|
+
elif args.db in ("init", "upgrade"):
|
|
252
|
+
upgrade()
|
|
253
|
+
elif args.db == "downgrade":
|
|
254
|
+
downgrade()
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Seed database if requested
|
|
258
|
+
if args.db_seed:
|
|
259
|
+
seed_database(app, seed_file=args.db_seed)
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
# Identify and abort if database seems to be empty
|
|
263
|
+
with app.app_context():
|
|
264
|
+
try:
|
|
265
|
+
# get alembic version, first entry, version column
|
|
266
|
+
alembic_version = db.session.query(AlembicVersion).first()
|
|
267
|
+
logging.debug(
|
|
268
|
+
"Database revision: %s", alembic_version.version_num if alembic_version else None
|
|
269
|
+
)
|
|
270
|
+
except OperationalError as e:
|
|
271
|
+
logging.info("Database error: %s", e)
|
|
272
|
+
logging.critical(
|
|
273
|
+
"Database does not seem to be initialized. Run with --db init to initialize."
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# Run the Flask app
|
|
278
|
+
app.run(
|
|
279
|
+
host=args.host,
|
|
280
|
+
port=args.port,
|
|
281
|
+
debug=args.debug,
|
|
282
|
+
extra_files=[
|
|
283
|
+
bundle[0] for bundle in app.config["SCSS_FILES"]
|
|
284
|
+
], # watch SCSS files for changes
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Configuration for CastMail2List"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from jsonschema import FormatChecker, validate
|
|
9
|
+
from jsonschema.exceptions import ValidationError
|
|
10
|
+
|
|
11
|
+
CONFIG_SCHEMA = {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"properties": {
|
|
14
|
+
"DATABASE_URI": {"type": "string"},
|
|
15
|
+
"SECRET_KEY": {"type": "string"},
|
|
16
|
+
"LANGUAGE": {"type": "string"},
|
|
17
|
+
"DOMAIN": {"type": "string"},
|
|
18
|
+
"HOST_TYPE": {"type": "string"},
|
|
19
|
+
"CREATE_LISTS_AUTOMATICALLY": {"type": "boolean"},
|
|
20
|
+
"POLL_INTERVAL_SECONDS": {"type": "integer"},
|
|
21
|
+
"IMAP_DEFAULT_HOST": {"type": "string"},
|
|
22
|
+
"IMAP_DEFAULT_PORT": {"type": "integer"},
|
|
23
|
+
"IMAP_DEFAULT_USER_DOMAIN": {"type": "string"},
|
|
24
|
+
"IMAP_DEFAULT_PASS": {"type": "string"},
|
|
25
|
+
"IMAP_FOLDER_INBOX": {"type": "string"},
|
|
26
|
+
"IMAP_FOLDER_PROCESSED": {"type": "string"},
|
|
27
|
+
"IMAP_FOLDER_SENT": {"type": "string"},
|
|
28
|
+
"IMAP_FOLDER_BOUNCES": {"type": "string"},
|
|
29
|
+
"IMAP_FOLDER_DENIED": {"type": "string"},
|
|
30
|
+
"IMAP_FOLDER_DUPLICATE": {"type": "string"},
|
|
31
|
+
"SMTP_HOST": {"type": "string"},
|
|
32
|
+
"SMTP_PORT": {"type": "integer"},
|
|
33
|
+
"SMTP_USER": {"type": "string"},
|
|
34
|
+
"SMTP_PASS": {"type": "string"},
|
|
35
|
+
"SMTP_STARTTLS": {"type": "boolean"},
|
|
36
|
+
},
|
|
37
|
+
"required": ["SECRET_KEY", "DOMAIN", "HOST_TYPE", "SMTP_HOST"],
|
|
38
|
+
"additionalProperties": False,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AppConfig: # pylint: disable=too-few-public-methods
|
|
43
|
+
"""Flask configuration from YAML file with some defaults"""
|
|
44
|
+
|
|
45
|
+
# App settings
|
|
46
|
+
DATABASE_URI: str = ""
|
|
47
|
+
SECRET_KEY: str = ""
|
|
48
|
+
|
|
49
|
+
# General settings
|
|
50
|
+
LANGUAGE: str = "en" # Supported languages: "en", "de"
|
|
51
|
+
DOMAIN: str = ""
|
|
52
|
+
HOST_TYPE = "" # used for auto list creation. Can be: empty, uberspace7, uberspace8
|
|
53
|
+
CREATE_LISTS_AUTOMATICALLY: bool = False
|
|
54
|
+
POLL_INTERVAL_SECONDS: int = 60
|
|
55
|
+
|
|
56
|
+
# IMAP settings and defaults (used as defaults for new lists)
|
|
57
|
+
IMAP_DEFAULT_HOST: str = ""
|
|
58
|
+
IMAP_DEFAULT_PORT: int = 993
|
|
59
|
+
IMAP_DEFAULT_USER_DOMAIN: str = ""
|
|
60
|
+
IMAP_DEFAULT_PASS: str = ""
|
|
61
|
+
|
|
62
|
+
# IMAP folder names
|
|
63
|
+
IMAP_FOLDER_INBOX: str = "INBOX"
|
|
64
|
+
IMAP_FOLDER_PROCESSED: str = "Processed"
|
|
65
|
+
IMAP_FOLDER_SENT: str = "Sent"
|
|
66
|
+
IMAP_FOLDER_BOUNCES: str = "Bounces"
|
|
67
|
+
IMAP_FOLDER_DENIED: str = "Denied"
|
|
68
|
+
IMAP_FOLDER_DUPLICATE: str = "Duplicate"
|
|
69
|
+
|
|
70
|
+
# SMTP settings
|
|
71
|
+
SMTP_HOST: str = ""
|
|
72
|
+
SMTP_PORT: int = 587
|
|
73
|
+
SMTP_USER: str = ""
|
|
74
|
+
SMTP_PASS: str = ""
|
|
75
|
+
SMTP_STARTTLS = True
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def validate_config_schema(cls, cfg: dict, schema: dict) -> None:
|
|
79
|
+
"""Validate the config against a JSON schema"""
|
|
80
|
+
try:
|
|
81
|
+
validate(instance=cfg, schema=schema, format_checker=FormatChecker())
|
|
82
|
+
except ValidationError as e:
|
|
83
|
+
logging.critical("Config validation failed: %s", e.message)
|
|
84
|
+
raise ValueError(e) from None
|
|
85
|
+
logging.debug("Config validated successfully against schema.")
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def load_from_yaml(cls, yaml_path: str | Path) -> dict[str, Any]:
|
|
89
|
+
"""Load configuration from YAML file.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
yaml_path: Path to YAML configuration file
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Dictionary with configuration values
|
|
96
|
+
"""
|
|
97
|
+
logging.debug("Loading configuration from YAML file: %s", yaml_path)
|
|
98
|
+
try:
|
|
99
|
+
with open(yaml_path, encoding="utf-8") as f:
|
|
100
|
+
data = yaml.safe_load(f) or {}
|
|
101
|
+
cls.validate_config_schema(data, CONFIG_SCHEMA)
|
|
102
|
+
return data
|
|
103
|
+
except FileNotFoundError as e:
|
|
104
|
+
logging.critical("Configuration file not found: %s", yaml_path)
|
|
105
|
+
raise e
|
|
106
|
+
except yaml.YAMLError as e:
|
|
107
|
+
logging.critical("Error parsing YAML configuration file: %s", e)
|
|
108
|
+
raise e
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_yaml_and_env(cls, yaml_path: str | Path) -> "AppConfig":
|
|
112
|
+
"""Create Config instance from YAML file, overriding class defaults
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
yaml_path (str | Path): Path to YAML configuration file
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Config instance with merged configuration
|
|
119
|
+
"""
|
|
120
|
+
config = cls()
|
|
121
|
+
|
|
122
|
+
# Load from YAML if provided
|
|
123
|
+
if yaml_path:
|
|
124
|
+
yaml_config = cls.load_from_yaml(yaml_path)
|
|
125
|
+
for key, value in yaml_config.items():
|
|
126
|
+
if hasattr(config, key.upper()):
|
|
127
|
+
setattr(config, key.upper(), value)
|
|
128
|
+
|
|
129
|
+
# Environment variables override YAML (re-apply from env)
|
|
130
|
+
return config
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Flask-WTF forms for castmail2list application"""
|
|
2
|
+
|
|
3
|
+
from email.utils import parseaddr
|
|
4
|
+
|
|
5
|
+
from flask_babel import lazy_gettext as _ # Using lazy_gettext for form field labels
|
|
6
|
+
from flask_wtf import FlaskForm
|
|
7
|
+
from wtforms import (
|
|
8
|
+
BooleanField,
|
|
9
|
+
EmailField,
|
|
10
|
+
IntegerField,
|
|
11
|
+
PasswordField,
|
|
12
|
+
RadioField,
|
|
13
|
+
StringField,
|
|
14
|
+
SubmitField,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
from wtforms.validators import DataRequired, Email, Length, NumberRange, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LoginForm(FlaskForm):
|
|
21
|
+
"""
|
|
22
|
+
LoginForm is a form class used for user authentication.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
username (StringField): A field for entering the username. It is required.
|
|
26
|
+
password (PasswordField): A field for entering the password. It is required.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
username = StringField(label="Username", validators=[DataRequired()])
|
|
30
|
+
password = PasswordField(label="Password", validators=[DataRequired()])
|
|
31
|
+
submit = SubmitField(_("Login"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def email_with_opt_display_name(form, field):
|
|
35
|
+
"""Custom validator for multiple ways of providing email addresses:
|
|
36
|
+
|
|
37
|
+
foo@bar.com
|
|
38
|
+
John Doe <foo@bar.com>
|
|
39
|
+
"John P. Doe" <foo@bar.com>
|
|
40
|
+
"""
|
|
41
|
+
_, addr = parseaddr(field.data or "")
|
|
42
|
+
if not addr:
|
|
43
|
+
raise ValidationError("Invalid email format")
|
|
44
|
+
|
|
45
|
+
# Temporarily replace field.data with the bare address
|
|
46
|
+
original = field.data
|
|
47
|
+
field.data = addr
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
Email()(form, field)
|
|
51
|
+
finally:
|
|
52
|
+
field.data = original
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MailingListForm(FlaskForm):
|
|
56
|
+
"""Form for creating and editing mailing lists"""
|
|
57
|
+
|
|
58
|
+
# Basics
|
|
59
|
+
name = StringField(_("List Name"), validators=[DataRequired(), Length(min=1, max=100)])
|
|
60
|
+
address = EmailField(_("List Email Address"), validators=[DataRequired(), Email()])
|
|
61
|
+
avoid_duplicates = BooleanField(
|
|
62
|
+
_("Avoid duplicate copies of messages for subscribers"),
|
|
63
|
+
default=True,
|
|
64
|
+
)
|
|
65
|
+
# Modes
|
|
66
|
+
mode = RadioField(
|
|
67
|
+
_("Mode of the Mailing List"),
|
|
68
|
+
choices=[
|
|
69
|
+
("broadcast", _("Broadcast List: One-to-many communication, distribution list")),
|
|
70
|
+
("group", _("Group List: Many-to-many communication, discussion group")),
|
|
71
|
+
],
|
|
72
|
+
default="broadcast",
|
|
73
|
+
)
|
|
74
|
+
from_addr = StringField(
|
|
75
|
+
_("From Address"),
|
|
76
|
+
validators=[Optional(), email_with_opt_display_name],
|
|
77
|
+
description=_(
|
|
78
|
+
"Optional 'From' address for emails sent by the list. If left empty, the list address "
|
|
79
|
+
"will be used. Only relevant in Broadcast mode."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
allowed_senders = StringField(
|
|
83
|
+
_("Allowed Senders"),
|
|
84
|
+
validators=[Optional()],
|
|
85
|
+
description=_(
|
|
86
|
+
"Enter email addresses that are allowed to send emails to the list. "
|
|
87
|
+
"Separated by commas. Only relevant in Broadcast mode."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
only_subscribers_send = BooleanField(
|
|
91
|
+
_("Only allow subscribers to send messages to the list. Only relevant in Group mode."),
|
|
92
|
+
default=False,
|
|
93
|
+
)
|
|
94
|
+
sender_auth = StringField(
|
|
95
|
+
_("Sender Authentication Passwords"),
|
|
96
|
+
validators=[Optional()],
|
|
97
|
+
description=_(
|
|
98
|
+
"Comma-separated list of passwords that senders must provide to send emails "
|
|
99
|
+
"to this list. This can be passed via 'listaddress+password1@example.com'. "
|
|
100
|
+
"Leave empty to disable sender authentication."
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# IMAP Settings
|
|
105
|
+
imap_host = StringField(_("IMAP Server"), validators=[Optional(), Length(max=200)])
|
|
106
|
+
imap_port = IntegerField(_("IMAP Port"), validators=[Optional(), NumberRange(min=1, max=65535)])
|
|
107
|
+
imap_user = StringField(_("IMAP Username"), validators=[Optional(), Length(max=200)])
|
|
108
|
+
imap_pass = PasswordField(_("IMAP Password"), validators=[Optional()])
|
|
109
|
+
submit = SubmitField(_("Save List"))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SubscriberAddForm(FlaskForm):
|
|
113
|
+
"""Form for adding new subscribers"""
|
|
114
|
+
|
|
115
|
+
name = StringField(_("Name"), validators=[Optional(), Length(max=100)])
|
|
116
|
+
email = EmailField(_("Email Address"), validators=[DataRequired(), Email()])
|
|
117
|
+
comment = StringField(_("Comment"), validators=[Optional(), Length(max=100)])
|
|
118
|
+
submit = SubmitField(_("Save Subscriber"))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Gunicorn configuration file"""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=invalid-name
|
|
4
|
+
|
|
5
|
+
# Server socket
|
|
6
|
+
bind = "0.0.0.0:2278"
|
|
7
|
+
|
|
8
|
+
# Worker processes
|
|
9
|
+
workers = 1
|
|
10
|
+
worker_class = "sync"
|
|
11
|
+
timeout = 30
|
|
12
|
+
|
|
13
|
+
# Logging
|
|
14
|
+
accesslog = "-" # Log to stdout
|
|
15
|
+
errorlog = "-" # Log to stderr
|
|
16
|
+
loglevel = "info"
|
|
17
|
+
|
|
18
|
+
# Process naming
|
|
19
|
+
proc_name = "castmail2list"
|