init project

This commit is contained in:
Jonas Heinrich 2025-08-19 08:30:34 +02:00
parent 3b4176fa0a
commit b66a9d0d2c
12 changed files with 721 additions and 492 deletions

122
README.md
View file

@ -1,18 +1,26 @@
# mail-quota-warning # Fragify
Small script to check a configured list of IMAP accounts for mailbox quota and send Eine einfache Web-Anwendung, um vorausgefüllte Links für Anfragen bei [FragDenStaat.de](https://fragdenstaat.de) zu generieren, die du an Freund:innen schicken kannst.
a warning mail in case a specific threashold is exceeded.
## Was ist Fragify?
Fragify ist ein webbasiertes Tool, das es dir ermöglicht, schnell und einfach Anfragen bei deutschen Behörden über das Informationsfreiheitsportal FragDenStaat.de zu erstellen. Du kannst:
- Nach Behörden suchen und auswählen
- Betreff und Inhalt der Anfrage vorausfüllen
- Einen fertigen Link generieren, der alle Informationen enthält
- Den Link mit anderen teilen, die dann nur noch auf "Senden" klicken müssen
## Installation ## Installation
### NixOS ### NixOS
Add the module to your `flake.nix`: Füge das Modul zu deiner `flake.nix` hinzu:
```nix ```nix
{ {
inputs = { inputs = {
mail-quota-warning.url = "git+https://git.project-insanity.org/onny/mail-quota-warning.git"; fragify.url = "git+https://git.project-insanity.org/onny/fragify.git";
[...] [...]
}; };
@ -22,12 +30,12 @@ Add the module to your `flake.nix`:
system = "x86_64-linux"; system = "x86_64-linux";
specialArgs.inputs = inputs; specialArgs.inputs = inputs;
modules = [ modules = [
inputs.mail-quota-warning.nixosModule inputs.fragify.nixosModule
({ pkgs, ... }:{ ({ pkgs, ... }:{
nixpkgs.overlays = [ nixpkgs.overlays = [
inputs.mail-quota-warning.overlay inputs.fragify.overlay
]; ];
}) })
@ -40,52 +48,78 @@ Add the module to your `flake.nix`:
} }
``` ```
Add this to your `configuration.nix` file Füge dies zu deiner `configuration.nix` hinzu:
```nix ```nix
environment.etc."mail-quota-warning-secrets.yml".text = '' services.fragify = {
accounts:
- name: Sales
imap_server: mail.example.com
imap_port: 993
username: sales@example.com
password: secret
- name: Support
imap_server: mail.example.com
imap_port: 993
username: support@example.com
password: secret
mail:
smtp_server: mail.example.com
smtp_port: 587
smtp_username: monitoring@example.com
smtp_password: secret
from_address: monitoring@example.com
recipients:
- admin1@example.com
- admin2@example.com
'';
services.mail-quota-warning = {
enable = true; enable = true;
settings = { settings = {
CHECK_INTERVAL_DAYS = 7; # Konfiguration hier
QUOTA_WARNING_THRESHOLD_PERCENT = 80;
}; };
secretFile = "/etc/mail-quota-warning-secrets.yml";
}; };
``` ```
Replace setting variables according to your setup. ### Von der Quelle
### From source ```bash
cd fragify
```
cd mail-quota-warning
nix develop nix develop
export CHECK_INTERVAL_DAYS=7
export QUOTA_WARNING_THRESHOLD_PERCENT=80
nix run nix run
``` ```
Öffne dann deinen Browser und navigiere zu: http://localhost:8000
## Verwendung
1. **Behörde auswählen**: Suche und wähle die gewünschte Behörde aus dem Dropdown-Menü
2. **Betreff eingeben**: Gib einen aussagekräftigen Betreff für deine Anfrage ein
3. **Anfrage beschreiben**: Beschreibe detailliert, welche Dokumente oder Informationen du anfragen möchtest
4. **Link generieren**: Klicke auf "Anfrage Link generieren"
5. **Link teilen**: Kopiere den generierten Link und teile ihn mit anderen
## Technische Details
- **Framework**: Falcon (Python)
- **Frontend**: Bootstrap 5 mit modernem Design
- **API**: Integration mit der FragDenStaat.de API
- **Styling**: Responsive Design mit Gradient-Hintergrund
## API-Integration
Fragify nutzt die offizielle [FragDenStaat.de API](https://fragdenstaat.de/api/) um:
- Behörden zu durchsuchen
- Links zu generieren, die das Anfrage-Formular vorausfüllen
- Die korrekte URL-Struktur von FragDenStaat.de zu verwenden
## Entwicklung
### Lokale Entwicklung
```bash
# Entwicklungsumgebung starten
nix develop
# Anwendung starten
python fragify.py
```
### Abhängigkeiten
- Python 3.8+
- Falcon (Web-Framework)
- Requests (HTTP-Client)
## Lizenz
Dieses Projekt steht unter der gleichen Lizenz wie FragDenStaat.de.
## Beitragen
Beiträge sind willkommen! Bitte erstelle einen Pull Request oder öffne ein Issue.
## Links
- [FragDenStaat.de](https://fragdenstaat.de) - Das Hauptportal
- [FragDenStaat API](https://fragdenstaat.de/api/) - API-Dokumentation
- [Informationsfreiheitsgesetz](https://fragdenstaat.de/informationsfreiheit/) - Rechtliche Grundlagen

View file

@ -1,25 +0,0 @@
check_interval_days: 7 # Minimum days between warnings for same account
quota_warning_threshold_percent: 80
accounts:
- name: Sales
imap_server: mail.example.com
imap_port: 993
username: sales@example.com
password: secret
- name: Support
imap_server: mail.example.com
imap_port: 993
username: support@example.com
password: secret
mail:
smtp_server: mail.example.com
smtp_port: 587
smtp_username: monitoring@example.com
smtp_password: secret
from_address: monitoring@example.com
recipients:
- admin1@example.com
- admin2@example.com

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1754563854, "lastModified": 1755471983,
"narHash": "sha256-YzNTExe3kMY9lYs23mZR7jsVHe5TWnpwNrsPOpFs/b8=", "narHash": "sha256-axUoWcm4cNQ36jOlnkD9D40LTfSQgk8ExfHSRm3rTtg=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e728d7ae4bb6394bbd19eec52b7358526a44c414", "rev": "48f4c982de68d966421d2b6f1ddbeb6227cc5ceb",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -1,5 +1,5 @@
{ {
description = "mail-quota-warning package and service"; description = "fragify package and service";
inputs.nixpkgs.url = "nixpkgs/nixos-25.05"; inputs.nixpkgs.url = "nixpkgs/nixos-25.05";
@ -16,8 +16,8 @@
); );
in { in {
overlay = final: prev: { overlay = final: prev: {
mail-quota-warning = with final; python3Packages.buildPythonApplication { fragify = with final; python3Packages.buildPythonApplication {
pname = "mail-quota-warning"; pname = "fragify";
version = "0.0.1"; version = "0.0.1";
format = "other"; format = "other";
@ -25,23 +25,26 @@
dependencies = with python3Packages; [ dependencies = with python3Packages; [
python python
pyyaml falcon
imaplib2 requests
jinja2
]; ];
installPhase = '' installPhase = ''
install -Dm755 ${./mail-quota-warning.py} $out/bin/mail-quota-warning install -Dm755 ${./fragify.py} $out/bin/fragify
mkdir -p $out/share/fragify
cp -r ${./templates} $out/share/fragify/
''; '';
meta.mainProgram = "mail-quota-warning"; meta.mainProgram = "fragify";
}; };
}; };
packages = forAllSystems (system: { packages = forAllSystems (system: {
inherit (nixpkgsFor.${system}) mail-quota-warning; inherit (nixpkgsFor.${system}) fragify;
}); });
defaultPackage = forAllSystems (system: self.packages.${system}.mail-quota-warning); defaultPackage = forAllSystems (system: self.packages.${system}.fragify);
devShells = forAllSystems (system: let devShells = forAllSystems (system: let
pkgs = import nixpkgs { inherit system; overlays = [ self.overlay ]; }; pkgs = import nixpkgs { inherit system; overlays = [ self.overlay ]; };
@ -53,7 +56,7 @@
]; ];
}); });
# mail-quota-warning service module # fragify service module
nixosModule = (import ./module.nix); nixosModule = (import ./module.nix);
}; };
} }

185
fragify.py Normal file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Fragify - A web application to generate prefilled FragDenStaat.de request links
"""
import falcon
import json
import requests
from urllib.parse import urlencode
import os
import sys
from jinja2 import Environment, FileSystemLoader
class BaseTemplateResource:
"""Base class for resources that need template rendering"""
def _get_template_dir(self):
"""Get the template directory path, handling both development and installed environments"""
# Get the directory where this script is located
script_dir = os.path.dirname(os.path.abspath(__file__))
# Try development templates first
dev_template_dir = os.path.join(script_dir, 'templates')
if os.path.exists(dev_template_dir):
return dev_template_dir
# Try to find templates relative to the executable
try:
# If we're running from a Nix store, look for templates in share/fragify
if '/nix/store/' in script_dir:
# Go up from bin to share/fragify/templates
share_dir = os.path.join(script_dir, '..', 'share', 'fragify', 'templates')
if os.path.exists(share_dir):
return share_dir
# Alternative: look for templates in the same store path
store_root = script_dir.split('/nix/store/')[1].split('/')[0]
store_path = f"/nix/store/{store_root}"
alt_share_dir = os.path.join(store_path, 'share', 'fragify', 'templates')
if os.path.exists(alt_share_dir):
return alt_share_dir
except Exception:
pass
# Last resort: try to find any templates directory
for root, dirs, files in os.walk('/nix/store'):
if 'templates' in dirs and 'index.html' in os.listdir(os.path.join(root, 'templates')):
return os.path.join(root, 'templates')
# Fallback to current directory
return dev_template_dir
class FragifyApp(BaseTemplateResource):
def __init__(self):
self.fragdenstaat_api = "https://fragdenstaat.de/api/v1"
# Setup Jinja2 template environment
template_dir = self._get_template_dir()
print(f"Using template directory: {template_dir}")
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
def on_get(self, req, resp):
"""Serve the main page"""
template = self.jinja_env.get_template('index.html')
resp.content_type = 'text/html; charset=utf-8'
resp.text = template.render()
def on_post(self, req, resp):
"""Handle form submission and generate link"""
try:
# Parse form data
form_data = req.get_media()
publicbody_id = form_data.get('publicbody_id', '')
subject = form_data.get('subject', '')
body = form_data.get('body', '')
# Generate FragDenStaat.de link
base_url = "https://fragdenstaat.de/anfrage-stellen/"
if publicbody_id:
base_url += f"an/{publicbody_id}/"
params = {}
if subject:
params['subject'] = subject
if body:
params['body'] = body
if params:
base_url += "?" + urlencode(params)
resp.content_type = 'application/json'
resp.text = json.dumps({
'success': True,
'link': base_url
})
except Exception as e:
resp.status = falcon.HTTP_500
resp.content_type = 'application/json'
resp.text = json.dumps({
'success': False,
'error': str(e)
})
class ImpressumResource(BaseTemplateResource):
def __init__(self):
template_dir = self._get_template_dir()
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
def on_get(self, req, resp):
"""Serve the Impressum page"""
template = self.jinja_env.get_template('impressum.html')
resp.content_type = 'text/html; charset=utf-8'
resp.text = template.render()
class DatenschutzResource(BaseTemplateResource):
def __init__(self):
template_dir = self._get_template_dir()
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
def on_get(self, req, resp):
"""Serve the Datenschutz page"""
template = self.jinja_env.get_template('datenschutz.html')
resp.content_type = 'text/html; charset=utf-8'
resp.text = template.render()
class PublicBodiesResource:
def __init__(self):
self.fragdenstaat_api = "https://fragdenstaat.de/api/v1"
def on_get(self, req, resp):
"""API endpoint to search public bodies"""
try:
search = req.get_param('search', default='')
page = req.get_param('page', default=1)
# Build API URL
url = f"{self.fragdenstaat_api}/publicbody/"
params = {
'limit': 20,
'offset': (int(page) - 1) * 20
}
if search:
params['q'] = search
# Make request to FragDenStaat API
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
resp.content_type = 'application/json'
resp.text = json.dumps(data)
except Exception as e:
resp.status = falcon.HTTP_500
resp.content_type = 'application/json'
resp.text = json.dumps({
'error': str(e),
'results': [],
'next': None
})
# Create Falcon application
app = falcon.App()
# Add routes
fragify = FragifyApp()
impressum = ImpressumResource()
datenschutz = DatenschutzResource()
publicbodies = PublicBodiesResource()
app.add_route('/', fragify)
app.add_route('/impressum', impressum)
app.add_route('/datenschutz', datenschutz)
app.add_route('/api/publicbodies', publicbodies)
if __name__ == '__main__':
import wsgiref.simple_server
print("Starting Fragify web application...")
print("Open your browser and navigate to: http://localhost:8000")
httpd = wsgiref.simple_server.make_server('localhost', 8000, app)
httpd.serve_forever()

View file

@ -1,295 +0,0 @@
#!/usr/bin/env python3
import imaplib2
import smtplib
import yaml
import json
import os
import threading
import argparse
from email.mime.text import MIMEText
from datetime import datetime, timedelta
CONFIG_FILE = "config.yml"
STATE_FILE = "quota_state.json"
# TODO
# - load config from file
# - override with env vars
def get_config_value(config, env_var, config_key, default_value, value_type=int):
"""Get configuration value from environment variable or config file, with fallback to default"""
env_value = os.environ.get(env_var)
if env_value is not None:
try:
return value_type(env_value)
except ValueError:
log(f"Invalid value for {env_var}: {env_value}, using config/default")
return config.get(config_key, default_value)
def parse_args():
parser = argparse.ArgumentParser(description="Email quota monitoring script")
parser.add_argument(
"--config",
default="config.yml",
help="Path to config.yml file (default: config.yml in current directory)"
)
return parser.parse_args()
def load_config(config_file):
if not os.path.exists(config_file):
log(f"Config file not found: {config_file}")
raise FileNotFoundError(f"Config file not found: {config_file}")
with open(config_file, "r") as f:
return yaml.safe_load(f)
def load_state():
state_file = "quota_state.json"
if os.path.exists(state_file):
with open(state_file, "r") as f:
return json.load(f)
return {}
def save_state(state):
state_file = "quota_state.json"
with open(state_file, "w") as f:
json.dump(state, f, indent=2)
def log(msg):
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}")
def format_bytes(kb):
"""Convert KB to human readable format"""
if kb >= 1024 * 1024: # GB
return f"{kb / (1024 * 1024):.1f} GB"
elif kb >= 1024: # MB
return f"{kb / 1024:.1f} MB"
else: # KB
return f"{kb} KB"
def check_quota(account):
"""
Uses IMAP QUOTA command (RFC 2087) to get mailbox usage with imaplib2.
Tries multiple approaches to get quota information.
Returns dict with quota info or None if not supported.
"""
try:
log(f"Checking quota for account: {account['name']} ({account['username']})")
# Create imaplib2 connection
mail = imaplib2.IMAP4_SSL(account['imap_server'], account['imap_port'])
# Login
typ, data = mail.login(account['username'], account['password'])
if typ != 'OK':
log(f"Login failed for {account['name']}: {data}")
return None
# Try different quota roots (quietly)
quota_roots = ["INBOX", account['username'], f"user/{account['username']}", f"user.{account['username']}"]
for quota_root in quota_roots:
try:
typ, data = mail.getquota(quota_root)
if typ == "OK" and data and data[0]:
# Parse quota response
quota_info = data[0].decode() if isinstance(data[0], bytes) else str(data[0])
# Handle different response formats
# Format 1: (STORAGE used limit) or (MESSAGE used limit)
if quota_info.startswith('('):
parts = quota_info.replace("(", "").replace(")", "").split()
if len(parts) >= 3:
resource_type = parts[0] # STORAGE or MESSAGE
used = int(parts[1])
limit = int(parts[2])
if limit > 0 and resource_type == "STORAGE":
percent_used = (used / limit) * 100
log(f"Quota usage: {percent_used:.1f}% ({format_bytes(used)} of {format_bytes(limit)})")
mail.logout()
return {
'percent_used': percent_used,
'used_kb': used,
'limit_kb': limit
}
# Format 2: Multiple resources in one response
elif "STORAGE" in quota_info:
import re
storage_match = re.search(r'STORAGE\s+(\d+)\s+(\d+)', quota_info)
if storage_match:
used = int(storage_match.group(1))
limit = int(storage_match.group(2))
if limit > 0:
percent_used = (used / limit) * 100
log(f"Quota usage: {percent_used:.1f}% ({format_bytes(used)} of {format_bytes(limit)})")
mail.logout()
return {
'percent_used': percent_used,
'used_kb': used,
'limit_kb': limit
}
except imaplib2.IMAP4.error:
# Silently try next quota root
continue
# Try GETQUOTAROOT command as alternative
try:
typ, data = mail.getquotaroot("INBOX")
if typ == "OK" and data:
# Parse quotaroot response which might give us quota info
for item in data:
item_str = item.decode() if isinstance(item, bytes) else str(item)
if "STORAGE" in item_str:
import re
storage_match = re.search(r'STORAGE\s+(\d+)\s+(\d+)', item_str)
if storage_match:
used = int(storage_match.group(1))
limit = int(storage_match.group(2))
if limit > 0:
percent_used = (used / limit) * 100
log(f"Quota usage: {percent_used:.1f}% ({format_bytes(used)} of {format_bytes(limit)})")
mail.logout()
return {
'percent_used': percent_used,
'used_kb': used,
'limit_kb': limit
}
except imaplib2.IMAP4.error:
pass
mail.logout()
log(f"No quota data available for {account['name']}")
return None
except imaplib2.IMAP4.error as e:
log(f"IMAP error checking quota for {account['name']}: {e}")
except Exception as e:
log(f"Error checking quota for {account['name']}: {e}")
return None
def should_send_warning(state, account_name, interval_days):
last_sent_str = state.get(account_name)
if not last_sent_str:
return True
last_sent = datetime.fromisoformat(last_sent_str)
return datetime.now() - last_sent >= timedelta(days=interval_days)
def send_warning(config, triggered_accounts, all_quotas, threshold):
mail_cfg = config["mail"]
# Create subject based on number of accounts
if len(triggered_accounts) == 1:
account_name, quota_info = list(triggered_accounts.items())[0]
subject = f"[Quota Warning] {account_name} mailbox usage at {quota_info['percent_used']:.1f}%"
else:
subject = f"[Quota Warning] {len(triggered_accounts)} mailbox(es) over threshold"
# Build email body
body_lines = []
if len(triggered_accounts) == 1:
account_name, quota_info = list(triggered_accounts.items())[0]
body_lines.append(f"The mailbox for account '{account_name}' has reached {quota_info['percent_used']:.1f}% of its quota.")
body_lines.append(f"Usage: {format_bytes(quota_info['used_kb'])} of {format_bytes(quota_info['limit_kb'])}")
else:
body_lines.append(f"The following mailboxes have exceeded the quota threshold ({threshold}%):")
body_lines.append("")
for account_name, quota_info in triggered_accounts.items():
body_lines.append(f"{account_name}: {quota_info['percent_used']:.1f}% ({format_bytes(quota_info['used_kb'])} of {format_bytes(quota_info['limit_kb'])})")
body_lines.append("")
body_lines.append("--- All Account Summary ---")
# Sort accounts by usage percentage (descending)
sorted_quotas = sorted(all_quotas.items(), key=lambda x: x[1]['percent_used'] if x[1] else 0, reverse=True)
for account_name, quota_info in sorted_quotas:
if quota_info:
status = "⚠️ " if account_name in triggered_accounts else ""
body_lines.append(f"{status}{account_name}: {quota_info['percent_used']:.1f}% ({format_bytes(quota_info['used_kb'])} of {format_bytes(quota_info['limit_kb'])})")
else:
body_lines.append(f"? {account_name}: Quota info unavailable")
body_lines.append("")
body_lines.append("Please take action to free up space for accounts over the threshold.")
body = "\n".join(body_lines)
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = mail_cfg["from_address"]
msg["To"] = ", ".join(mail_cfg["recipients"])
try:
server = smtplib.SMTP(mail_cfg["smtp_server"], mail_cfg["smtp_port"])
server.starttls()
server.login(mail_cfg["smtp_username"], mail_cfg["smtp_password"])
server.sendmail(mail_cfg["from_address"], mail_cfg["recipients"], msg.as_string())
server.quit()
account_names = ", ".join(triggered_accounts.keys())
log(f"Warning email sent for: {account_names}")
except Exception as e:
log(f"Error sending warning email: {e}")
def check_account_quota(account, config, state, threshold, interval_days):
"""
Check quota for a single account (can be run in thread)
"""
quota_info = check_quota(account)
if quota_info is None:
log(f"Quota info not available for {account['name']}")
return None, None
percent_used = quota_info['percent_used']
if percent_used >= threshold:
if should_send_warning(state, account["name"], interval_days):
return account["name"], quota_info
else:
log(f"Warning already sent recently for {account['name']}, skipping.")
else:
log(f"Quota usage for {account['name']} is below threshold ({percent_used:.1f}%).")
return None, quota_info
def main():
args = parse_args()
config = load_config(args.config)
state = load_state()
interval_days = get_config_value(config, "CHECK_INTERVAL_DAYS", "check_interval_days", 7, int)
threshold = get_config_value(config, "QUOTA_WARNING_THRESHOLD_PERCENT", "quota_warning_threshold_percent", 80, int)
# For thread-safe state updates
state_lock = threading.Lock()
# Track all accounts and those that need warnings
triggered_accounts = {} # account_name: quota_info
all_quotas = {} # account_name: quota_info (or None)
for account in config["accounts"]:
warning_result, quota_info = check_account_quota(account, config, state, threshold, interval_days)
# Store quota info for summary
all_quotas[account["name"]] = quota_info
# Track accounts that need warnings
if warning_result:
triggered_accounts[account["name"]] = quota_info
with state_lock:
state[account["name"]] = datetime.now().isoformat()
# Send consolidated warning email if any accounts triggered
if triggered_accounts:
send_warning(config, triggered_accounts, all_quotas, threshold)
save_state(state)
if __name__ == "__main__":
main()

View file

@ -12,71 +12,13 @@ in
{ {
options = { options = {
services.mail-quota-warning = { services.fragify = {
enable = lib.mkOption { enable = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = false; default = false;
description = '' description = ''
Enable mail-quota-warning daemon. Enable fragify web application.
'';
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = with lib.types; attrsOf anything;
options = {
CHECK_INTERVAL_DAYS = lib.mkOption {
default = 7;
type = lib.types.int;
description = ''
Interval of days in which a warning message will be
delivered.
'';
};
QUOTA_WARNING_THRESHOLD_PERCENT = lib.mkOption {
default = 80;
type = lib.types.int;
description = ''
Threshold of used mailbox space in percent after which
a warning message will be delivered.
'';
};
};
};
default = { };
description = ''
Extra options which should be used by the mailbox quota warning script.
'';
example = lib.literalExpression ''
{
CHECK_INTERVAL_DAYS = 7;
QUOTA_WARNING_THRESHOLD_PERCENT = 80;
}
'';
};
secretFile = lib.mkOption {
type = lib.types.nullOr (lib.types.pathWith {
inStore = false;
absolute = true;
});
default = null;
example = "/run/keys/mail-quota-warning-secrets";
description = ''
A YAML file containing secrets, see example config file
in the repository.
'';
};
interval = lib.mkOption {
type = lib.types.str;
default = "*:00,30:00";
description = ''
How often we run the sync. Default is half an hour.
The format is described in
{manpage}`systemd.time(7)`.
''; '';
}; };
@ -85,31 +27,32 @@ in
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
systemd.services."mail-quota-warning" = { systemd.services."fragify" = {
description = "mail-quota-warning script"; description = "fragify web application";
after = [ "network.target" ]; after = [ "network.target" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
environment = { environment = {
PYTHONUNBUFFERED = "1"; PYTHONUNBUFFERED = "1";
} };
// lib.mapAttrs (_: v: toString v) cfg.settings;
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
ExecStart = "${lib.getExe pkgs.mail-quota-warning}${lib.optionalString (cfg.secretFile != null) " --config ${cfg.secretFile}"}"; ExecStart = "${lib.getExe pkgs.fragify}";
WorkingDirectory = "%S/mail-quota-warning"; WorkingDirectory = "%S/fragify";
StateDirectory = "mail-quota-warning"; StateDirectory = "fragify";
User = "fragify";
Group = "fragify";
# hardening # hardening
AmbientCapabilities = ""; AmbientCapabilities = "";
CapabilityBoundingSet = ""; CapabilityBoundingSet = "";
DevicePolicy = "closed"; DevicePolicy = "closed";
DynamicUser = true; DynamicUser = false;
LockPersonality = true; LockPersonality = true;
MemoryDenyWriteExecute = true; MemoryDenyWriteExecute = true;
NoNewPrivileges = true; NoNewPrivileges = true;
PrivateDevices = true; PrivateDevices = true;
PrivateTmp = true; PrivateTmp = true;
PrivateUsers = true; PrivateUsers = false;
ProcSubset = "pid"; ProcSubset = "pid";
ProtectClock = true; ProtectClock = true;
ProtectControlGroups = true; ProtectControlGroups = true;
@ -137,16 +80,15 @@ in
}; };
}; };
systemd.timers.mail-quota-warning = { # Create fragify user and group
timerConfig = { users.users.fragify = {
OnCalendar = [ isSystemUser = true;
"" group = "fragify";
cfg.interval description = "fragify web application user";
];
};
wantedBy = [ "timers.target" ];
}; };
users.groups.fragify = {};
}; };
meta = { meta = {

138
templates/base.html Normal file
View file

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Fragify{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.main-container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
padding: 3rem;
margin: 2rem auto;
max-width: 800px;
}
.title {
color: #2c3e50;
font-weight: 700;
margin-bottom: 1rem;
}
.description {
color: #7f8c8d;
font-size: 1.1rem;
margin-bottom: 2.5rem;
line-height: 1.6;
}
.form-control, .form-select {
border-radius: 10px;
border: 2px solid #e9ecef;
padding: 0.75rem 1rem;
transition: all 0.3s ease;
}
.form-control:focus, .form-select:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.btn-primary {
background: linear-gradient(45deg, #667eea, #764ba2);
border: none;
border-radius: 15px;
padding: 1rem 2rem;
font-size: 1.1rem;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.result-link {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 10px;
padding: 1rem;
margin-top: 1rem;
word-break: break-all;
}
.loading {
display: none;
}
.footer {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
}
.footer-links a {
font-size: 0.9rem;
transition: color 0.3s ease;
}
.footer-links a:hover {
color: #667eea !important;
}
.footer-text {
font-size: 0.8rem;
}
.footer-text a:hover {
color: #667eea !important;
}
.legal-content {
text-align: left;
line-height: 1.6;
}
.legal-content h2 {
color: #2c3e50;
margin-top: 2rem;
margin-bottom: 1rem;
}
.legal-content p {
margin-bottom: 1rem;
}
.legal-content ul {
margin-bottom: 1rem;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="container">
<div class="main-container">
{% block content %}{% endblock %}
</div>
<!-- Footer -->
<footer class="footer mt-5 pt-4 border-top">
<div class="container">
<div class="row">
<div class="col-12 text-center">
<div class="footer-links mb-2">
<a href="/impressum" class="text-muted text-decoration-none me-3">Impressum</a>
<a href="/datenschutz" class="text-muted text-decoration-none me-3">Datenschutz</a>
<a href="https://git.project-insanity.org/onny/fragify" class="text-muted text-decoration-none" target="_blank">Source</a>
</div>
<div class="footer-text">
<small class="text-muted">
Projekt von <a href="https://project-insanity.org" class="text-muted text-decoration-none" target="_blank">Project-Insanity.org</a>,
follow us on <a href="https://social.project-insanity.org/@pi_crew" class="text-muted text-decoration-none" target="_blank">Mastodon</a> :)
</small>
</div>
</div>
</div>
</div>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

100
templates/datenschutz.html Normal file
View file

@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block title %}Datenschutz - Fragify{% endblock %}
{% block content %}
<div class="text-center">
<h1 class="title display-4">Datenschutzerklärung</h1>
</div>
<div class="legal-content">
<h2>1. Datenschutz auf einen Blick</h2>
<h3>Allgemeine Hinweise</h3>
<p>
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.
</p>
<h2>2. Datenerfassung auf dieser Website</h2>
<h3>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</h3>
<p>
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten können Sie dem Abschnitt „Hinweis zur Verantwortlichen Stelle" in dieser Datenschutzerklärung entnehmen.
</p>
<h3>Wie erfassen wir Ihre Daten?</h3>
<p>
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich z. B. um Daten handeln, die Sie in ein Kontaktformular eingeben.
</p>
<p>
Andere Daten werden automatisch oder nach Ihrer Einwilligung beim Besuch der Website durch unsere IT-Systeme erfasst. Das sind vor allem technische Daten (z. B. Internetbrowser, Betriebssystem oder Uhrzeit des Seitenaufrufs).
</p>
<h3>Wofür nutzen wir Ihre Daten?</h3>
<p>
Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden.
</p>
<h3>Welche Rechte haben Sie bezüglich Ihrer Daten?</h3>
<p>
Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die Berichtigung oder Löschung dieser Daten zu verlangen. Wenn Sie eine Einwilligung zur Datenverarbeitung erteilt haben, können Sie diese Einwilligung jederzeit für die Zukunft widerrufen. Außerdem haben Sie das Recht, unter bestimmten Umständen die Einschränkung der Verarbeitung Ihrer personenbezogenen Daten zu verlangen.
</p>
<h2>3. Hosting</h2>
<p>
Wir hosten unsere Website bei uns selbst. Es werden keine Daten an externe Hosting-Dienste weitergegeben.
</p>
<h2>4. Allgemeine Hinweise und Pflichtinformationen</h2>
<h3>Datenschutz</h3>
<p>
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend den gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.
</p>
<h2>5. Datenerfassung auf dieser Website</h2>
<h3>Cookies</h3>
<p>
<strong>Diese Website verwendet keine Cookies.</strong> Es werden keine Tracking-Cookies oder Analyse-Cookies gesetzt. Die Website funktioniert vollständig ohne Cookie-Speicherung.
</p>
<h3>Server-Log-Dateien</h3>
<p>
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
</p>
<ul>
<li>Browsertyp und Browserversion</li>
<li>verwendetes Betriebssystem</li>
<li>Referrer URL</li>
<li>Hostname des zugreifenden Rechners</li>
<li>Uhrzeit der Serveranfrage</li>
<li>IP-Adresse</li>
</ul>
<p>
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.
</p>
<h2>6. API-Nutzung</h2>
<p>
Diese Website nutzt die öffentliche API von FragDenStaat.de, um Behördeninformationen abzurufen. Bei der Nutzung der Suchfunktion werden Ihre Suchanfragen an die FragDenStaat.de API weitergeleitet. Es werden keine persönlichen Daten an FragDenStaat.de übertragen, außer den reinen Suchbegriffen.
</p>
<p>
<strong>Wichtig:</strong> Wir speichern keine Ihrer Suchanfragen oder generierten Links auf unseren Servern. Alle Daten werden nur temporär im Browser verarbeitet und nicht dauerhaft gespeichert.
</p>
<h2>7. Kontakt</h2>
<p>
Bei Fragen zur Erhebung, Verarbeitung oder Nutzung Ihrer personenbezogenen Daten wenden Sie sich bitte an:
</p>
<p>
Jonas Heinrich<br>
E-Mail: <a href="mailto:onny@project-insanity.org">onny@project-insanity.org</a>
</p>
<h2>8. Änderungen</h2>
<p>
Wir behalten uns vor, diese Datenschutzerklärung anzupassen, damit sie stets den aktuellen rechtlichen Anforderungen entspricht oder um Änderungen unserer Leistungen in der Datenschutzerklärung umzusetzen, z. B. bei der Einführung neuer Services.
</p>
</div>
{% endblock %}

47
templates/impressum.html Normal file
View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Impressum - Fragify{% endblock %}
{% block content %}
<div class="text-center">
<h1 class="title display-4">Impressum</h1>
</div>
<div class="legal-content">
<h2>Angaben gemäß § 5 TMG</h2>
<p>
<strong>Jonas Heinrich</strong><br>
Erzbergerstraße 9<br>
76133 Karlsruhe<br>
Deutschland
</p>
<h2>Kontakt</h2>
<p>
E-Mail: <a href="mailto:onny@project-insanity.org">onny@project-insanity.org</a>
</p>
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
<p>
Jonas Heinrich<br>
Erzbergerstraße 9<br>
76133 Karlsruhe
</p>
<h2>Haftungsausschluss</h2>
<h3>Haftung für Inhalte</h3>
<p>
Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen.
</p>
<h3>Haftung für Links</h3>
<p>
Unser Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.
</p>
<h3>Urheberrecht</h3>
<p>
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.
</p>
</div>
{% endblock %}

138
templates/index.html Normal file
View file

@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}Fragify{% endblock %}
{% block content %}
<div class="text-center">
<h1 class="title display-4">Fragify</h1>
<p class="description">
Erstelle einfach Links für Anfragen bei dem Portal
<a href="https://fragdenstaat.de" target="_blank" class="text-decoration-none">FragDenStaat.de</a>,
welche du vorausfüllen und an Freund:innen schicken kannst!
</p>
<form id="fragifyForm" class="text-start">
<div class="mb-4">
<label for="publicbody" class="form-label fw-bold">Behörde</label>
<select class="form-select" id="publicbody" name="publicbody_id" required>
<option value="">Behörde auswählen...</option>
</select>
</div>
<div class="mb-4">
<label for="subject" class="form-label fw-bold">Betreff</label>
<input type="text" class="form-control" id="subject" name="subject"
placeholder="Betreff der Anfrage" required>
</div>
<div class="mb-4">
<label for="body" class="form-label fw-bold">Dokumente anfragen:</label>
<textarea class="form-control" id="body" name="body" rows="5"
placeholder="Beschreibe hier, welche Dokumente oder Informationen du anfragen möchtest..." required></textarea>
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary btn-lg">
<span class="btn-text">Anfrage Link generieren</span>
<span class="loading spinner-border spinner-border-sm ms-2" role="status"></span>
</button>
</div>
</form>
<div id="result" class="mt-4" style="display: none;">
<h5 class="text-success mb-3">Link erfolgreich generiert!</h5>
<div class="result-link">
<a href="" id="generatedLink" target="_blank"></a>
</div>
<button class="btn btn-outline-primary mt-3" onclick="copyToClipboard()">
Link kopieren
</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
// Initialize Select2 for public bodies
$('#publicbody').select2({
theme: 'bootstrap-5',
placeholder: 'Behörde auswählen...',
allowClear: true,
ajax: {
url: '/api/publicbodies',
dataType: 'json',
delay: 250,
data: function(params) {
return {
search: params.term,
page: params.page || 1
};
},
processResults: function(data, params) {
params.page = params.page || 1;
return {
results: data.results.map(function(item) {
return {
id: item.id,
text: item.name + ' (' + item.jurisdiction + ')'
};
}),
pagination: {
more: data.next !== null
}
};
},
cache: true
}
});
// Handle form submission
$('#fragifyForm').on('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const submitBtn = $('button[type="submit"]');
const btnText = submitBtn.find('.btn-text');
const loading = submitBtn.find('.loading');
// Show loading state
btnText.hide();
loading.show();
submitBtn.prop('disabled', true);
fetch('/', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
$('#generatedLink').attr('href', data.link).text(data.link);
$('#result').show();
$('#fragifyForm')[0].reset();
$('#publicbody').val(null).trigger('change');
} else {
alert('Fehler: ' + data.error);
}
})
.catch(error => {
alert('Fehler beim Generieren des Links: ' + error);
})
.finally(() => {
// Hide loading state
btnText.show();
loading.hide();
submitBtn.prop('disabled', false);
});
});
});
function copyToClipboard() {
const link = document.getElementById('generatedLink').href;
navigator.clipboard.writeText(link).then(function() {
alert('Link wurde in die Zwischenablage kopiert!');
});
}
</script>
{% endblock %}

View file

@ -1,38 +0,0 @@
{ pkgs, ... }:
{
environment.etc."mail-quota-warning-secrets.yml".text = ''
accounts:
- name: Sales
imap_server: mail.example.com
imap_port: 993
username: sales@example.com
password: secret
- name: Support
imap_server: mail.example.com
imap_port: 993
username: support@example.com
password: secret
mail:
smtp_server: mail.example.com
smtp_port: 587
smtp_username: monitoring@example.com
smtp_password: secret
from_address: monitoring@example.com
recipients:
- admin1@example.com
- admin2@example.com
'';
services.mail-quota-warning = {
enable = true;
settings = {
CHECK_INTERVAL_DAYS = 7;
QUOTA_WARNING_THRESHOLD_PERCENT = 80;
};
secretFile = "/etc/mail-quota-warning-secrets.yml";
};
}