initial commit
This commit is contained in:
parent
52145d19fe
commit
a1ea6e4609
6 changed files with 316 additions and 160 deletions
25
config.yml.example
Normal file
25
config.yml.example
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
#!/bin/env python
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Read environment variables (fail if missing)
|
|
||||||
EINTOPF_URL = os.environ["EINTOPF_URL"]
|
|
||||||
RADAR_GROUP_ID = os.environ["RADAR_GROUP_ID"]
|
|
||||||
EINTOPF_AUTHORIZATION_TOKEN = os.environ["EINTOPF_AUTHORIZATION_TOKEN"]
|
|
||||||
|
|
||||||
def strip_html_tags(text):
|
|
||||||
soup = BeautifulSoup(text, "html.parser")
|
|
||||||
return soup.get_text()
|
|
||||||
|
|
||||||
def query_categories():
|
|
||||||
response = requests.get(EINTOPF_URL + "/api/v1/categories/?filters=%7B%7D", headers={
|
|
||||||
"Authorization": EINTOPF_AUTHORIZATION_TOKEN,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
return data
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def eintopf_create_category(name):
|
|
||||||
payload = {
|
|
||||||
"description": name,
|
|
||||||
"headline": name,
|
|
||||||
"name": name
|
|
||||||
}
|
|
||||||
response = requests.post(EINTOPF_URL + "/api/v1/categories/", json=payload, headers={
|
|
||||||
"Authorization": EINTOPF_AUTHORIZATION_TOKEN,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
return data['id']
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def ensure_and_get_categoryid(category):
|
|
||||||
available_categories = query_categories()
|
|
||||||
|
|
||||||
for entry in available_categories:
|
|
||||||
if entry["name"].lower() == category:
|
|
||||||
# Return the matching category ID
|
|
||||||
return entry["id"]
|
|
||||||
|
|
||||||
# If no matching category is found, create a new one
|
|
||||||
category_id = eintopf_create_category(category)
|
|
||||||
return category_id
|
|
||||||
|
|
||||||
def eintopf_post_event(event: dict):
|
|
||||||
payload = {
|
|
||||||
"address": "Karlsruhe",
|
|
||||||
"category": event['category_id'],
|
|
||||||
"description": strip_html_tags(event['description']),
|
|
||||||
"image": "",
|
|
||||||
"involved": [
|
|
||||||
{
|
|
||||||
"description": "Anonymous",
|
|
||||||
"name": "Anonymous"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lat": 0,
|
|
||||||
"lng": 0,
|
|
||||||
"location": event['location'],
|
|
||||||
"name": event['title'],
|
|
||||||
"organizers": [ event['organizer'] ],
|
|
||||||
"ownedBy": [ event['organizer'] ],
|
|
||||||
"published": True,
|
|
||||||
"start": event['time_start'],
|
|
||||||
"end": event['time_end'],
|
|
||||||
"tags": ["karlsruhe"],
|
|
||||||
"topic": event['topic_id'],
|
|
||||||
}
|
|
||||||
response = requests.post(EINTOPF_URL + "/api/v1/events/", json=payload, headers={
|
|
||||||
"Authorization": EINTOPF_AUTHORIZATION_TOKEN,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
print("Beginning scraping Radar api ...")
|
|
||||||
|
|
||||||
response = requests.get("https://radar.squat.net/api/1.2/search/events.json", params={
|
|
||||||
"fields": "title,offline,date_time,body,category,uuid,og_group_ref",
|
|
||||||
"facets[group][]=": RADAR_GROUP_ID
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
events = data["result"]
|
|
||||||
|
|
||||||
radar_events = []
|
|
||||||
|
|
||||||
for event in events:
|
|
||||||
|
|
||||||
event = events[event]
|
|
||||||
categories = [cat["name"] for cat in event.get("category", [])]
|
|
||||||
category_id = ensure_and_get_categoryid(categories[0])
|
|
||||||
new_event = {
|
|
||||||
'title': event["title"],
|
|
||||||
'time_start': event["date_time"][0]["time_start"],
|
|
||||||
'time_end': event["date_time"][0]["time_end"],
|
|
||||||
'location': event["offline"][0]['title'],
|
|
||||||
'description': event["body"]['value'],
|
|
||||||
'category_id': category_id,
|
|
||||||
'topic_id': "003387f0-9f28-44e4-ab41-808007bc6586",
|
|
||||||
'uuid': event["uuid"],
|
|
||||||
'organizer': event["og_group_ref"][0]["title"]
|
|
||||||
}
|
|
||||||
|
|
||||||
if eintopf_post_event(new_event):
|
|
||||||
print("Event successfully added:")
|
|
||||||
print(f"Title: {new_event['title']}")
|
|
||||||
print(f"Time Start: {new_event['time_start']}")
|
|
||||||
print(f"Location: {new_event['location']}")
|
|
||||||
print(f"Category: {categories[0]} ({new_event['category_id']})")
|
|
||||||
print(f"Topic: Sonstiges ({new_event['topic_id']})")
|
|
||||||
print(f"UUID: {new_event['uuid']}")
|
|
||||||
print(f"Organizer: {new_event['organizer']}")
|
|
||||||
print('-' * 40)
|
|
||||||
else:
|
|
||||||
print("Submitting event failed")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print(f"Failed to retrieve data. Status code: {response.status_code}")
|
|
||||||
|
|
||||||
8
flake.lock
generated
8
flake.lock
generated
|
|
@ -2,16 +2,16 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1741600792,
|
"lastModified": 1754563854,
|
||||||
"narHash": "sha256-yfDy6chHcM7pXpMF4wycuuV+ILSTG486Z/vLx/Bdi6Y=",
|
"narHash": "sha256-YzNTExe3kMY9lYs23mZR7jsVHe5TWnpwNrsPOpFs/b8=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "ebe2788eafd539477f83775ef93c3c7e244421d3",
|
"rev": "e728d7ae4bb6394bbd19eec52b7358526a44c414",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"id": "nixpkgs",
|
"id": "nixpkgs",
|
||||||
"ref": "nixos-24.11",
|
"ref": "nixos-25.05",
|
||||||
"type": "indirect"
|
"type": "indirect"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
22
flake.nix
22
flake.nix
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
description = "eintopf-radar-sync package and service";
|
description = "mail-quota-warning package and service";
|
||||||
|
|
||||||
inputs.nixpkgs.url = "nixpkgs/nixos-24.11";
|
inputs.nixpkgs.url = "nixpkgs/nixos-25.05";
|
||||||
|
|
||||||
outputs = { self, nixpkgs }:
|
outputs = { self, nixpkgs }:
|
||||||
let
|
let
|
||||||
|
|
@ -16,8 +16,8 @@
|
||||||
);
|
);
|
||||||
in {
|
in {
|
||||||
overlay = final: prev: {
|
overlay = final: prev: {
|
||||||
eintopf-radar-sync = with final; python3Packages.buildPythonApplication {
|
mail-quota-warning = with final; python3Packages.buildPythonApplication {
|
||||||
pname = "eintopf-radar-sync";
|
pname = "mail-quota-warning";
|
||||||
version = "0.0.1";
|
version = "0.0.1";
|
||||||
format = "other";
|
format = "other";
|
||||||
|
|
||||||
|
|
@ -25,23 +25,23 @@
|
||||||
|
|
||||||
dependencies = with python3Packages; [
|
dependencies = with python3Packages; [
|
||||||
python
|
python
|
||||||
requests
|
pyyaml
|
||||||
beautifulsoup4
|
imaplib2
|
||||||
];
|
];
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
install -Dm755 ${./eintopf-radar-sync.py} $out/bin/eintopf-radar-sync
|
install -Dm755 ${./mail-quota-warning.py} $out/bin/mail-quota-warning
|
||||||
'';
|
'';
|
||||||
|
|
||||||
meta.mainProgram = "eintopf-radar-sync";
|
meta.mainProgram = "mail-quota-warning";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
packages = forAllSystems (system: {
|
packages = forAllSystems (system: {
|
||||||
inherit (nixpkgsFor.${system}) eintopf-radar-sync;
|
inherit (nixpkgsFor.${system}) mail-quota-warning;
|
||||||
});
|
});
|
||||||
|
|
||||||
defaultPackage = forAllSystems (system: self.packages.${system}.eintopf-radar-sync);
|
defaultPackage = forAllSystems (system: self.packages.${system}.mail-quota-warning);
|
||||||
|
|
||||||
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 +53,7 @@
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
# eintopf-radar-sync service module
|
# mail-quota-warning service module
|
||||||
nixosModule = (import ./module.nix);
|
nixosModule = (import ./module.nix);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
269
mail-quota-warning.py
Normal file
269
mail-quota-warning.py
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import imaplib2
|
||||||
|
import smtplib
|
||||||
|
import yaml
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
CONFIG_FILE = "config.yml"
|
||||||
|
STATE_FILE = "quota_state.json"
|
||||||
|
|
||||||
|
# TODO:
|
||||||
|
# - make working dir configurable for state file
|
||||||
|
# - optional read config.yml from command line argument
|
||||||
|
# - note in warning mail that it exceeded XX% quota threashold
|
||||||
|
# - fix list summary mark all accounts which are critical
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_FILE, "r") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
if os.path.exists(STATE_FILE):
|
||||||
|
with open(STATE_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_state(state):
|
||||||
|
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):
|
||||||
|
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("The following mailboxes have exceeded the quota 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():
|
||||||
|
config = load_config()
|
||||||
|
state = load_state()
|
||||||
|
interval_days = config.get("check_interval_days", 7)
|
||||||
|
threshold = config.get("quota_warning_threshold_percent", 80)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
14
module.nix
14
module.nix
|
|
@ -1,19 +1,19 @@
|
||||||
{config, lib, pkgs, ...}:
|
{config, lib, pkgs, ...}:
|
||||||
let
|
let
|
||||||
|
|
||||||
cfg = config.services.eintopf-radar-sync;
|
cfg = config.services.mail-quota-warning;
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|
||||||
options = {
|
options = {
|
||||||
services.eintopf-radar-sync = {
|
services.mail-quota-warning = {
|
||||||
|
|
||||||
enable = lib.mkOption {
|
enable = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
description = ''
|
description = ''
|
||||||
Enable eintopf-radar-sync daemon.
|
Enable mail-quota-warning daemon.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -74,8 +74,8 @@ in
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
|
|
||||||
systemd.services."eintopf-radar-sync" = {
|
systemd.services."mail-quota-warning" = {
|
||||||
description = "eintopf-radar-sync script";
|
description = "mail-quota-warning script";
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
environment = {
|
environment = {
|
||||||
|
|
@ -83,7 +83,7 @@ in
|
||||||
} // cfg.settings;
|
} // cfg.settings;
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "simple";
|
Type = "simple";
|
||||||
ExecStart = lib.getExe pkgs.eintopf-radar-sync;
|
ExecStart = lib.getExe pkgs.mail-quota-warning;
|
||||||
EnvironmentFile = [ cfg.secrets ];
|
EnvironmentFile = [ cfg.secrets ];
|
||||||
|
|
||||||
# hardening
|
# hardening
|
||||||
|
|
@ -118,7 +118,7 @@ in
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.timers.eintopf-radar-sync = {
|
systemd.timers.mail-quota-warning = {
|
||||||
timerConfig = {
|
timerConfig = {
|
||||||
OnCalendar = [
|
OnCalendar = [
|
||||||
""
|
""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue