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.
Files changed (105) hide show
  1. castmail2list-0.1.0/PKG-INFO +79 -0
  2. castmail2list-0.1.0/README.md +38 -0
  3. castmail2list-0.1.0/castmail2list/__init__.py +5 -0
  4. castmail2list-0.1.0/castmail2list/app.py +289 -0
  5. castmail2list-0.1.0/castmail2list/babel.cfg +2 -0
  6. castmail2list-0.1.0/castmail2list/config.py +130 -0
  7. castmail2list-0.1.0/castmail2list/forms.py +118 -0
  8. castmail2list-0.1.0/castmail2list/gunicorn.conf.py +19 -0
  9. castmail2list-0.1.0/castmail2list/imap_worker.py +358 -0
  10. castmail2list-0.1.0/castmail2list/mailer.py +286 -0
  11. castmail2list-0.1.0/castmail2list/migrations/README +1 -0
  12. castmail2list-0.1.0/castmail2list/migrations/alembic.ini +50 -0
  13. castmail2list-0.1.0/castmail2list/migrations/env.py +113 -0
  14. castmail2list-0.1.0/castmail2list/migrations/script.py.mako +24 -0
  15. castmail2list-0.1.0/castmail2list/migrations/versions/000000000000_initial_schema.py +81 -0
  16. castmail2list-0.1.0/castmail2list/models.py +159 -0
  17. castmail2list-0.1.0/castmail2list/seed_default.json +52 -0
  18. castmail2list-0.1.0/castmail2list/seeder.py +124 -0
  19. castmail2list-0.1.0/castmail2list/static/css/fontawesome-all.min.css +9 -0
  20. castmail2list-0.1.0/castmail2list/static/js/themeToggle.js +73 -0
  21. castmail2list-0.1.0/castmail2list/static/scss/main.scss +106 -0
  22. castmail2list-0.1.0/castmail2list/static/scss/pico/_index.scss +49 -0
  23. castmail2list-0.1.0/castmail2list/static/scss/pico/_settings.scss +143 -0
  24. castmail2list-0.1.0/castmail2list/static/scss/pico/colors/_index.scss +886 -0
  25. castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_background-colors.scss +74 -0
  26. castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_colors.scss +51 -0
  27. castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_css-vars.scss +55 -0
  28. castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_index.scss +8 -0
  29. castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_settings.scss +106 -0
  30. castmail2list-0.1.0/castmail2list/static/scss/pico/colors/utilities/_utils.scss +60 -0
  31. castmail2list-0.1.0/castmail2list/static/scss/pico/components/_accordion.scss +116 -0
  32. castmail2list-0.1.0/castmail2list/static/scss/pico/components/_card.scss +46 -0
  33. castmail2list-0.1.0/castmail2list/static/scss/pico/components/_dropdown.scss +280 -0
  34. castmail2list-0.1.0/castmail2list/static/scss/pico/components/_group.scss +120 -0
  35. castmail2list-0.1.0/castmail2list/static/scss/pico/components/_loading.scss +46 -0
  36. castmail2list-0.1.0/castmail2list/static/scss/pico/components/_modal.scss +177 -0
  37. castmail2list-0.1.0/castmail2list/static/scss/pico/components/_nav.scss +160 -0
  38. castmail2list-0.1.0/castmail2list/static/scss/pico/components/_progress.scss +102 -0
  39. castmail2list-0.1.0/castmail2list/static/scss/pico/components/_tooltip.scss +215 -0
  40. castmail2list-0.1.0/castmail2list/static/scss/pico/content/_button.scss +209 -0
  41. castmail2list-0.1.0/castmail2list/static/scss/pico/content/_code.scss +77 -0
  42. castmail2list-0.1.0/castmail2list/static/scss/pico/content/_embedded.scss +54 -0
  43. castmail2list-0.1.0/castmail2list/static/scss/pico/content/_figure.scss +19 -0
  44. castmail2list-0.1.0/castmail2list/static/scss/pico/content/_link.scss +69 -0
  45. castmail2list-0.1.0/castmail2list/static/scss/pico/content/_misc.scss +38 -0
  46. castmail2list-0.1.0/castmail2list/static/scss/pico/content/_table.scss +62 -0
  47. castmail2list-0.1.0/castmail2list/static/scss/pico/content/_typography.scss +182 -0
  48. castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_basics.scss +471 -0
  49. castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_checkbox-radio-switch.scss +177 -0
  50. castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-color.scss +37 -0
  51. castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-date.scss +60 -0
  52. castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-file.scss +41 -0
  53. castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-range.scss +100 -0
  54. castmail2list-0.1.0/castmail2list/static/scss/pico/forms/_input-search.scss +60 -0
  55. castmail2list-0.1.0/castmail2list/static/scss/pico/helpers/_copyright.scss +4 -0
  56. castmail2list-0.1.0/castmail2list/static/scss/pico/helpers/_functions.scss +55 -0
  57. castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_container.scss +33 -0
  58. castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_document.scss +53 -0
  59. castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_grid.scss +26 -0
  60. castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_landmarks.scss +62 -0
  61. castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_overflow-auto.scss +12 -0
  62. castmail2list-0.1.0/castmail2list/static/scss/pico/layout/_section.scss +12 -0
  63. castmail2list-0.1.0/castmail2list/static/scss/pico/pico.classless.scss +5 -0
  64. castmail2list-0.1.0/castmail2list/static/scss/pico/pico.colors.scss +2 -0
  65. castmail2list-0.1.0/castmail2list/static/scss/pico/pico.conditional.scss +4 -0
  66. castmail2list-0.1.0/castmail2list/static/scss/pico/pico.fluid.classless.scss +6 -0
  67. castmail2list-0.1.0/castmail2list/static/scss/pico/pico.scss +2 -0
  68. castmail2list-0.1.0/castmail2list/static/scss/pico/postcss.config.js +9 -0
  69. castmail2list-0.1.0/castmail2list/static/scss/pico/themes/_default.scss +5 -0
  70. castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_dark.scss +261 -0
  71. castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_light.scss +221 -0
  72. castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_schemes.scss +41 -0
  73. castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_styles.scss +435 -0
  74. castmail2list-0.1.0/castmail2list/static/scss/pico/themes/default/_theme-colors.scss +491 -0
  75. castmail2list-0.1.0/castmail2list/static/scss/pico/utilities/_accessibility.scss +57 -0
  76. castmail2list-0.1.0/castmail2list/static/scss/pico/utilities/_reduce-motion.scss +30 -0
  77. castmail2list-0.1.0/castmail2list/static/webfonts/fa-brands-400.woff2 +0 -0
  78. castmail2list-0.1.0/castmail2list/static/webfonts/fa-regular-400.woff2 +0 -0
  79. castmail2list-0.1.0/castmail2list/static/webfonts/fa-solid-900.woff2 +0 -0
  80. castmail2list-0.1.0/castmail2list/static/webfonts/fa-v4compatibility.woff2 +0 -0
  81. castmail2list-0.1.0/castmail2list/templates/base.html +48 -0
  82. castmail2list-0.1.0/castmail2list/templates/flash.html +11 -0
  83. castmail2list-0.1.0/castmail2list/templates/index.html +10 -0
  84. castmail2list-0.1.0/castmail2list/templates/lists/add.html +13 -0
  85. castmail2list-0.1.0/castmail2list/templates/lists/edit.html +13 -0
  86. castmail2list-0.1.0/castmail2list/templates/lists/index.html +51 -0
  87. castmail2list-0.1.0/castmail2list/templates/lists/macros.html +79 -0
  88. castmail2list-0.1.0/castmail2list/templates/lists/subscriber_edit.html +47 -0
  89. castmail2list-0.1.0/castmail2list/templates/lists/subscribers_manage.html +57 -0
  90. castmail2list-0.1.0/castmail2list/templates/login.html +21 -0
  91. castmail2list-0.1.0/castmail2list/templates/messages/bounces.html +29 -0
  92. castmail2list-0.1.0/castmail2list/templates/messages/detail.html +38 -0
  93. castmail2list-0.1.0/castmail2list/templates/messages/index.html +32 -0
  94. castmail2list-0.1.0/castmail2list/templates/settings.html +10 -0
  95. castmail2list-0.1.0/castmail2list/templates/subscriber.html +40 -0
  96. castmail2list-0.1.0/castmail2list/translations/de/LC_MESSAGES/messages.mo +0 -0
  97. castmail2list-0.1.0/castmail2list/translations/de/LC_MESSAGES/messages.po +277 -0
  98. castmail2list-0.1.0/castmail2list/utils.py +443 -0
  99. castmail2list-0.1.0/castmail2list/views/__init__.py +0 -0
  100. castmail2list-0.1.0/castmail2list/views/auth.py +44 -0
  101. castmail2list-0.1.0/castmail2list/views/general.py +48 -0
  102. castmail2list-0.1.0/castmail2list/views/lists.py +292 -0
  103. castmail2list-0.1.0/castmail2list/views/messages.py +36 -0
  104. castmail2list-0.1.0/castmail2list/wsgi.py +98 -0
  105. 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,5 @@
1
+ """Castmail2List"""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("castmail2list")
@@ -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,2 @@
1
+ [python: **.py]
2
+ [jinja2: **/templates/**.html]
@@ -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"