ign8mail 2.0.0__py3-none-any.whl
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.
- ign8mail/__init__.py +1 -0
- ign8mail/aerc.py +129 -0
- ign8mail/cli.py +2305 -0
- ign8mail/config.py +28 -0
- ign8mail/dns/__init__.py +0 -0
- ign8mail/dns/cloudflare.py +131 -0
- ign8mail/infra/__init__.py +0 -0
- ign8mail/infra/hetzner.py +113 -0
- ign8mail/keys.py +58 -0
- ign8mail/mailserver/__init__.py +0 -0
- ign8mail/mailserver/setup.py +976 -0
- ign8mail/thunderbird.py +281 -0
- ign8mail/vault.py +54 -0
- ign8mail/webmail/__init__.py +1 -0
- ign8mail/webmail/apps.py +6 -0
- ign8mail/webmail/imap_client.py +213 -0
- ign8mail/webmail/runner.py +55 -0
- ign8mail/webmail/templates/webmail/base.html +208 -0
- ign8mail/webmail/templates/webmail/index.html +81 -0
- ign8mail/webmail/templates/webmail/login.html +106 -0
- ign8mail/webmail/templates/webmail/mailbox.html +93 -0
- ign8mail/webmail/templates/webmail/message.html +86 -0
- ign8mail/webmail/urls.py +11 -0
- ign8mail/webmail/views.py +237 -0
- ign8mail-2.0.0.dist-info/METADATA +79 -0
- ign8mail-2.0.0.dist-info/RECORD +28 -0
- ign8mail-2.0.0.dist-info/WHEEL +4 -0
- ign8mail-2.0.0.dist-info/entry_points.txt +2 -0
ign8mail/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
ign8mail/aerc.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Configure aerc accounts for the provisioned mail server."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def find_config() -> Path | None:
|
|
9
|
+
"""Return the aerc config directory, or None if not found."""
|
|
10
|
+
xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
11
|
+
p = xdg / "aerc"
|
|
12
|
+
if p.is_dir():
|
|
13
|
+
return p
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_config() -> Path:
|
|
18
|
+
"""Return the aerc config directory, creating it if needed."""
|
|
19
|
+
xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
20
|
+
p = xdg / "aerc"
|
|
21
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return p
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _section_name(local_user: str, domain: str) -> str:
|
|
26
|
+
"""Return the accounts.conf section name for an account."""
|
|
27
|
+
return local_user
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _url_encode_password(pw: str) -> str:
|
|
31
|
+
"""Percent-encode characters that are not safe in a URL userinfo."""
|
|
32
|
+
safe = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=")
|
|
33
|
+
return "".join(c if c in safe else f"%{ord(c):02X}" for c in pw)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def configure_accounts(
|
|
37
|
+
config_dir: Path,
|
|
38
|
+
domain: str,
|
|
39
|
+
mail_host: str,
|
|
40
|
+
credentials: dict[str, str],
|
|
41
|
+
full_names: dict[str, str] | None = None,
|
|
42
|
+
) -> int:
|
|
43
|
+
"""Write or update accounts in aerc's accounts.conf.
|
|
44
|
+
|
|
45
|
+
credentials: mapping of local_user → plaintext password.
|
|
46
|
+
full_names: optional mapping of local_user → display name.
|
|
47
|
+
Returns the number of accounts written.
|
|
48
|
+
"""
|
|
49
|
+
accounts_path = config_dir / "accounts.conf"
|
|
50
|
+
existing = _parse_accounts(accounts_path) if accounts_path.exists() else {}
|
|
51
|
+
|
|
52
|
+
for local_user, password in credentials.items():
|
|
53
|
+
enc_pw = _url_encode_password(password)
|
|
54
|
+
display = (full_names or {}).get(local_user, local_user.capitalize())
|
|
55
|
+
email = f"{local_user}@{domain}"
|
|
56
|
+
section = _section_name(local_user, domain)
|
|
57
|
+
|
|
58
|
+
# Remove any legacy section name variants for this user (e.g. user@domain)
|
|
59
|
+
for old_key in (f"{local_user}@{domain}",):
|
|
60
|
+
existing.pop(old_key, None)
|
|
61
|
+
|
|
62
|
+
existing[section] = {
|
|
63
|
+
# aerc schemes: imaps = implicit TLS, smtp = STARTTLS, smtps = implicit TLS
|
|
64
|
+
"source": f"imaps://{local_user}:{enc_pw}@{mail_host}:993",
|
|
65
|
+
"outgoing": f"smtp+plain://{local_user}:{enc_pw}@{mail_host}:587",
|
|
66
|
+
"from": f"{display} <{email}>",
|
|
67
|
+
"default": "INBOX",
|
|
68
|
+
"copy-to": "Sent",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_write_accounts(accounts_path, existing)
|
|
72
|
+
accounts_path.chmod(0o600)
|
|
73
|
+
return len(credentials)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def remove_account(config_dir: Path, local_user: str, domain: str) -> bool:
|
|
77
|
+
"""Remove an account section from accounts.conf. Returns True if it was present."""
|
|
78
|
+
accounts_path = config_dir / "accounts.conf"
|
|
79
|
+
if not accounts_path.exists():
|
|
80
|
+
return False
|
|
81
|
+
existing = _parse_accounts(accounts_path)
|
|
82
|
+
section = _section_name(local_user, domain)
|
|
83
|
+
if section not in existing:
|
|
84
|
+
return False
|
|
85
|
+
del existing[section]
|
|
86
|
+
_write_accounts(accounts_path, existing)
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_accounts(config_dir: Path) -> list[str]:
|
|
91
|
+
"""Return section names from accounts.conf."""
|
|
92
|
+
accounts_path = config_dir / "accounts.conf"
|
|
93
|
+
if not accounts_path.exists():
|
|
94
|
+
return []
|
|
95
|
+
return list(_parse_accounts(accounts_path).keys())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Internal helpers — simple INI parser that preserves insertion order and
|
|
100
|
+
# does not add spaces around '=' (aerc requires 'key = value' format but
|
|
101
|
+
# the parser is lenient, so we use 'key = value' for readability).
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _parse_accounts(path: Path) -> dict[str, dict[str, str]]:
|
|
105
|
+
"""Parse accounts.conf into {section: {key: value}} preserving order."""
|
|
106
|
+
result: dict[str, dict[str, str]] = {}
|
|
107
|
+
current: str | None = None
|
|
108
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
109
|
+
line = raw.strip()
|
|
110
|
+
if not line or line.startswith("#"):
|
|
111
|
+
continue
|
|
112
|
+
if line.startswith("[") and line.endswith("]"):
|
|
113
|
+
current = line[1:-1]
|
|
114
|
+
result.setdefault(current, {})
|
|
115
|
+
elif "=" in line and current is not None:
|
|
116
|
+
key, _, val = line.partition("=")
|
|
117
|
+
result[current][key.strip()] = val.strip()
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _write_accounts(path: Path, sections: dict[str, dict[str, str]]) -> None:
|
|
122
|
+
"""Write sections back to accounts.conf using aerc's key=value format."""
|
|
123
|
+
lines: list[str] = []
|
|
124
|
+
for section, kv in sections.items():
|
|
125
|
+
lines.append(f"[{section}]")
|
|
126
|
+
for key, val in kv.items():
|
|
127
|
+
lines.append(f"{key}={val}")
|
|
128
|
+
lines.append("")
|
|
129
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|