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 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")