granny-devops 0.13.0__tar.gz → 0.14.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.
- {granny_devops-0.13.0 → granny_devops-0.14.0}/PKG-INFO +1 -1
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/__init__.py +19 -19
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/dns.py +9 -6
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/email.py +119 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/credentials/secrets.py +6 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/bunny.py +57 -5
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/cloudflare.py +62 -5
- granny_devops-0.14.0/granny/email/__init__.py +14 -0
- granny_devops-0.14.0/granny/email/stalwart.py +322 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/pyproject.toml +1 -1
- granny_devops-0.13.0/granny/email/__init__.py +0 -7
- {granny_devops-0.13.0 → granny_devops-0.14.0}/.gitignore +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/LICENSE +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/README.md +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/costs.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/credits.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/authentik/client.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/authentik/provision.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/aws/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/aws/quota.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/_client.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/account.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/deployment.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/openai.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/quota.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/role.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/sku.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/vm.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/webapp.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/analyze.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/authentik.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/aws.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/azure.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/create.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/docker.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/elk.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/indexing.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/main.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/serverless.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/zitadel.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/base.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/factory.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/inwx.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/records.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/elk/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/elk/client.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/email/mailjet.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/indexing/google.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/report.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/storage/bunny.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/storage/hetzner.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/zitadel/__init__.py +0 -0
- {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/zitadel/client.py +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
"""Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
|
|
2
|
-
|
|
3
|
-
__version__ = "0.
|
|
4
|
-
__all__ = [
|
|
5
|
-
"get_secret",
|
|
6
|
-
"load_secrets_into_env",
|
|
7
|
-
]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def __getattr__(name: str):
|
|
11
|
-
if name == "get_secret":
|
|
12
|
-
from granny.credentials.secrets import get_secret
|
|
13
|
-
|
|
14
|
-
return get_secret
|
|
15
|
-
if name == "load_secrets_into_env":
|
|
16
|
-
from granny.credentials.secrets import load_secrets_into_env
|
|
17
|
-
|
|
18
|
-
return load_secrets_into_env
|
|
19
|
-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
1
|
+
"""Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.14.0"
|
|
4
|
+
__all__ = [
|
|
5
|
+
"get_secret",
|
|
6
|
+
"load_secrets_into_env",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def __getattr__(name: str):
|
|
11
|
+
if name == "get_secret":
|
|
12
|
+
from granny.credentials.secrets import get_secret
|
|
13
|
+
|
|
14
|
+
return get_secret
|
|
15
|
+
if name == "load_secrets_into_env":
|
|
16
|
+
from granny.credentials.secrets import load_secrets_into_env
|
|
17
|
+
|
|
18
|
+
return load_secrets_into_env
|
|
19
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -43,16 +43,19 @@ def _split_fqdn(value: str, domain: str | None) -> tuple[str, str]:
|
|
|
43
43
|
|
|
44
44
|
if "." in value:
|
|
45
45
|
value_lower = value.lower()
|
|
46
|
-
if domain and value_lower != domain and not value_lower.endswith(f".{domain}"):
|
|
47
|
-
raise click.UsageError(
|
|
48
|
-
f"{value!r} is not under domain {domain!r}"
|
|
49
|
-
)
|
|
50
46
|
if domain is None:
|
|
51
47
|
return ("@", value_lower)
|
|
52
48
|
if value_lower == domain:
|
|
53
49
|
return ("@", domain)
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
if value_lower.endswith(f".{domain}"):
|
|
51
|
+
short = value_lower[: -(len(domain) + 1)]
|
|
52
|
+
return (short, domain)
|
|
53
|
+
# A dotted name that's neither the zone apex nor a FQDN under it.
|
|
54
|
+
# With an explicit --domain this is a multi-label *relative* subname
|
|
55
|
+
# (e.g. ``_smtp._tls`` or ``v1-rsa-20260629._domainkey``), so treat it
|
|
56
|
+
# as relative to the zone rather than rejecting it. The resulting FQDN
|
|
57
|
+
# is echoed back, so a genuine wrong-domain typo stays visible.
|
|
58
|
+
return (value_lower, domain)
|
|
56
59
|
|
|
57
60
|
# Bare label — needs a domain.
|
|
58
61
|
if not domain:
|
|
@@ -330,3 +330,122 @@ def workmail_reset_password(
|
|
|
330
330
|
raise click.ClickException(f"User not found: {user_email}")
|
|
331
331
|
wm.reset_password(org_id, user["Id"], password)
|
|
332
332
|
click.echo(f"Password reset for {user_email}.")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# -- Stalwart subgroup --------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@email.group("stalwart")
|
|
339
|
+
def stalwart() -> None:
|
|
340
|
+
"""Stalwart mail server admin (domains, mailboxes, DNS, delivery test)."""
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@stalwart.command("list-domains")
|
|
344
|
+
def stalwart_list_domains() -> None:
|
|
345
|
+
"""List configured local domains."""
|
|
346
|
+
from granny.email.stalwart import StalwartClient
|
|
347
|
+
|
|
348
|
+
c = StalwartClient.from_environment()
|
|
349
|
+
domains = c.list_domains()
|
|
350
|
+
if not domains:
|
|
351
|
+
click.echo("No domains configured.")
|
|
352
|
+
return
|
|
353
|
+
for d in domains:
|
|
354
|
+
dkim = (d.get("dkimManagement") or {}).get("@type", "?")
|
|
355
|
+
click.echo(
|
|
356
|
+
f" {d.get('id', '?'):<5} {d.get('name', '?'):<30} "
|
|
357
|
+
f"enabled={d.get('isEnabled')} dkim={dkim}"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@stalwart.command("add-domain")
|
|
362
|
+
@click.argument("name")
|
|
363
|
+
def stalwart_add_domain(name: str) -> None:
|
|
364
|
+
"""Create a new local domain (Manual cert, Automatic DKIM, Manual DNS)."""
|
|
365
|
+
from granny.email.stalwart import StalwartClient
|
|
366
|
+
|
|
367
|
+
c = StalwartClient.from_environment()
|
|
368
|
+
created = c.add_domain(name)
|
|
369
|
+
click.echo(f"Domain {name} ready (id={created.get('id')})")
|
|
370
|
+
click.echo(f"Next: granny email stalwart show-dns {name}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@stalwart.command("list-mailboxes")
|
|
374
|
+
def stalwart_list_mailboxes() -> None:
|
|
375
|
+
"""List individual mailboxes."""
|
|
376
|
+
from granny.email.stalwart import StalwartClient
|
|
377
|
+
|
|
378
|
+
c = StalwartClient.from_environment()
|
|
379
|
+
mailboxes = c.list_mailboxes()
|
|
380
|
+
if not mailboxes:
|
|
381
|
+
click.echo("No mailboxes configured.")
|
|
382
|
+
return
|
|
383
|
+
for a in mailboxes:
|
|
384
|
+
role = (a.get("roles") or {}).get("@type", "?")
|
|
385
|
+
click.echo(
|
|
386
|
+
f" {a.get('id', '?'):<5} {a.get('emailAddress', '?'):<40} role={role}"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@stalwart.command("add-mailbox")
|
|
391
|
+
@click.argument("email_address")
|
|
392
|
+
@click.option("--description", default=None, help="Free-text mailbox description.")
|
|
393
|
+
@click.option(
|
|
394
|
+
"--password",
|
|
395
|
+
default=None,
|
|
396
|
+
help="Use this password instead of generating one (NOT recommended).",
|
|
397
|
+
)
|
|
398
|
+
def stalwart_add_mailbox(
|
|
399
|
+
email_address: str, description: str | None, password: str | None,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Create a mailbox under an existing local domain.
|
|
402
|
+
|
|
403
|
+
The generated password is shown ONCE on stdout. Save it immediately
|
|
404
|
+
(e.g. into Vaultwarden) — Stalwart hashes it server-side.
|
|
405
|
+
"""
|
|
406
|
+
from granny.email.stalwart import StalwartClient
|
|
407
|
+
|
|
408
|
+
c = StalwartClient.from_environment()
|
|
409
|
+
record, generated = c.add_mailbox(
|
|
410
|
+
email_address, password=password, description=description,
|
|
411
|
+
)
|
|
412
|
+
click.echo(f"Mailbox created (id={record.get('id')})")
|
|
413
|
+
click.echo("")
|
|
414
|
+
click.echo(f" Email: {email_address}")
|
|
415
|
+
click.echo(f" Password: {generated}")
|
|
416
|
+
click.echo("")
|
|
417
|
+
base = c.base_url.removeprefix("https://").removeprefix("http://")
|
|
418
|
+
click.echo(f" SMTP submission: {base}:587 (STARTTLS) or :465 (implicit TLS)")
|
|
419
|
+
click.echo(f" IMAP: {base}:993")
|
|
420
|
+
click.echo(f" JMAP / WebDAV: {c.base_url}/")
|
|
421
|
+
click.echo("")
|
|
422
|
+
click.echo(" This password is shown ONCE — save it now.")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@stalwart.command("show-dns")
|
|
426
|
+
@click.argument("name")
|
|
427
|
+
def stalwart_show_dns(name: str) -> None:
|
|
428
|
+
"""Print Stalwart's generated DNS zone for a domain.
|
|
429
|
+
|
|
430
|
+
Output is in BIND zone-file syntax (SPF, MX, DKIM, DMARC, MTA-STS,
|
|
431
|
+
TLS-RPT, SRV autoconfig). Publish at the domain's registrar.
|
|
432
|
+
"""
|
|
433
|
+
from granny.email.stalwart import StalwartClient
|
|
434
|
+
|
|
435
|
+
c = StalwartClient.from_environment()
|
|
436
|
+
click.echo(c.get_dns_zone(name))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@stalwart.command("delivery-test")
|
|
440
|
+
@click.argument("recipient")
|
|
441
|
+
def stalwart_delivery_test(recipient: str) -> None:
|
|
442
|
+
"""Stream Stalwart's live deliverability report for one recipient.
|
|
443
|
+
|
|
444
|
+
Mirrors the admin UI's *Live Delivery Test* page (MX lookup,
|
|
445
|
+
MTA-STS, TLS-RPT, banner, STARTTLS, etc.).
|
|
446
|
+
"""
|
|
447
|
+
from granny.email.stalwart import StalwartClient
|
|
448
|
+
|
|
449
|
+
c = StalwartClient.from_environment()
|
|
450
|
+
for line in c.stream_delivery_test(recipient):
|
|
451
|
+
click.echo(line)
|
|
@@ -182,6 +182,12 @@ SECRET_MAP: dict[str, str] = {
|
|
|
182
182
|
"ELASTICSEARCH_API_KEY": "elasticsearch-api-key",
|
|
183
183
|
"ELASTICSEARCH_USERNAME": "elasticsearch-username",
|
|
184
184
|
"ELASTICSEARCH_PASSWORD": "elasticsearch-password",
|
|
185
|
+
# Stalwart mail server admin (Basic auth on JMAP / REST). The username is
|
|
186
|
+
# not secret but is registered alongside the password so both can come from
|
|
187
|
+
# the same vault item. Base URL defaults to https://mail.pseekoo.io in
|
|
188
|
+
# StalwartClient.from_environment() and is overridable via STALWART_URL.
|
|
189
|
+
"STALWART_ADMIN_USER": "stalwart-admin-user",
|
|
190
|
+
"STALWART_ADMIN_PASSWORD": "stalwart-admin-password",
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
# Module-level cache so vault is only contacted once per process
|
|
@@ -107,7 +107,7 @@ class BunnyDNSProvider(DNSProvider):
|
|
|
107
107
|
DNSRecord(
|
|
108
108
|
name=rec.get("Name", "") or "",
|
|
109
109
|
type=RECORD_TYPE_NAMES.get(rec.get("Type", -1), "?"),
|
|
110
|
-
value=
|
|
110
|
+
value=self._record_value(rec),
|
|
111
111
|
ttl=int(rec.get("Ttl", 300)),
|
|
112
112
|
id=str(rec.get("Id")),
|
|
113
113
|
provider_data=rec,
|
|
@@ -124,27 +124,79 @@ class BunnyDNSProvider(DNSProvider):
|
|
|
124
124
|
ttl: int = 300,
|
|
125
125
|
proxied: bool = False,
|
|
126
126
|
) -> DNSRecord:
|
|
127
|
-
|
|
127
|
+
rtype = record_type.upper()
|
|
128
|
+
type_num = RECORD_TYPES.get(rtype)
|
|
128
129
|
if type_num is None:
|
|
129
130
|
raise ValueError(f"Unknown record type: {record_type}")
|
|
130
131
|
short_name = "" if name in ("@", None) else name
|
|
132
|
+
record_value = value.rstrip(".") if rtype == "CNAME" else value
|
|
133
|
+
priority: int | None = None
|
|
134
|
+
extra: dict[str, int] = {}
|
|
135
|
+
if rtype == "MX":
|
|
136
|
+
# BIND-style "10 mail.example.com" → Priority + target.
|
|
137
|
+
head, _, tail = value.strip().partition(" ")
|
|
138
|
+
if head.isdigit() and tail:
|
|
139
|
+
priority = int(head)
|
|
140
|
+
record_value = tail.rstrip(".")
|
|
141
|
+
elif rtype == "SRV":
|
|
142
|
+
# BIND-style "priority weight port target" → Bunny's separate
|
|
143
|
+
# Priority/Weight/Port fields plus Value=target.
|
|
144
|
+
parts = value.split()
|
|
145
|
+
if len(parts) != 4:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"SRV value must be 'priority weight port target', got {value!r}"
|
|
148
|
+
)
|
|
149
|
+
prio, weight, port, target = parts
|
|
150
|
+
priority = int(prio)
|
|
151
|
+
extra = {"Weight": int(weight), "Port": int(port)}
|
|
152
|
+
record_value = target.rstrip(".")
|
|
131
153
|
payload = {
|
|
132
154
|
"Type": type_num,
|
|
133
155
|
"Name": short_name,
|
|
134
|
-
"Value":
|
|
156
|
+
"Value": record_value,
|
|
135
157
|
"Ttl": ttl,
|
|
136
158
|
}
|
|
159
|
+
if priority is not None:
|
|
160
|
+
payload["Priority"] = priority
|
|
161
|
+
payload.update(extra)
|
|
137
162
|
result = self._request("PUT", f"/dnszone/{zone_id}/records", payload)
|
|
138
163
|
logger.info("Bunny DNS: created %s %s -> %s", record_type, short_name, value)
|
|
164
|
+
# Build the display value from what we sent — Bunny's create response
|
|
165
|
+
# doesn't reliably echo Priority/Weight/Port back.
|
|
166
|
+
if rtype == "MX" and priority is not None:
|
|
167
|
+
display_value = f"{priority} {record_value}"
|
|
168
|
+
elif rtype == "SRV":
|
|
169
|
+
display_value = f"{priority} {extra['Weight']} {extra['Port']} {record_value}"
|
|
170
|
+
else:
|
|
171
|
+
display_value = record_value
|
|
139
172
|
return DNSRecord(
|
|
140
173
|
name=short_name,
|
|
141
|
-
type=
|
|
142
|
-
value=
|
|
174
|
+
type=rtype,
|
|
175
|
+
value=display_value,
|
|
143
176
|
ttl=ttl,
|
|
144
177
|
id=str(result.get("Id")) if isinstance(result, dict) else None,
|
|
145
178
|
provider_data=result if isinstance(result, dict) else {},
|
|
146
179
|
)
|
|
147
180
|
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _record_value(rec: dict) -> str:
|
|
183
|
+
"""Reconstruct a BIND-style value from a Bunny record.
|
|
184
|
+
|
|
185
|
+
MX prepends ``Priority``; SRV expands to ``priority weight port
|
|
186
|
+
target``. Keeps round-tripped values in the same shape callers pass,
|
|
187
|
+
so ``upsert_record`` compares equal and stays idempotent.
|
|
188
|
+
"""
|
|
189
|
+
value = rec.get("Value", "") or ""
|
|
190
|
+
rtype = rec.get("Type")
|
|
191
|
+
if rtype == RECORD_TYPES["MX"]:
|
|
192
|
+
return f"{rec.get('Priority', 0)} {value}".strip()
|
|
193
|
+
if rtype == RECORD_TYPES["SRV"]:
|
|
194
|
+
return (
|
|
195
|
+
f"{rec.get('Priority', 0)} {rec.get('Weight', 0)} "
|
|
196
|
+
f"{rec.get('Port', 0)} {value}"
|
|
197
|
+
)
|
|
198
|
+
return value
|
|
199
|
+
|
|
148
200
|
def delete_record(self, zone_id: str, record_id: str) -> None:
|
|
149
201
|
self._request("DELETE", f"/dnszone/{zone_id}/records/{record_id}")
|
|
150
202
|
logger.info("Bunny DNS: deleted record %s", record_id)
|
|
@@ -101,7 +101,7 @@ class CloudflareDNSProvider(DNSProvider):
|
|
|
101
101
|
DNSRecord(
|
|
102
102
|
name=rec.get("name", ""),
|
|
103
103
|
type=rec.get("type", ""),
|
|
104
|
-
value=
|
|
104
|
+
value=self._record_value(rec),
|
|
105
105
|
ttl=int(rec.get("ttl", 300)),
|
|
106
106
|
id=rec.get("id"),
|
|
107
107
|
fqdn=rec.get("name"),
|
|
@@ -110,6 +110,61 @@ class CloudflareDNSProvider(DNSProvider):
|
|
|
110
110
|
)
|
|
111
111
|
return out
|
|
112
112
|
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _record_value(rec: dict) -> str:
|
|
115
|
+
"""Reconstruct a BIND-style value string from a Cloudflare record.
|
|
116
|
+
|
|
117
|
+
MX prepends its ``priority``; SRV expands its ``data`` object to
|
|
118
|
+
``priority weight port target``. Everything else uses ``content``.
|
|
119
|
+
Keeping the round-tripped value in this shape lets ``upsert_record``
|
|
120
|
+
compare against the value callers pass and stay idempotent.
|
|
121
|
+
"""
|
|
122
|
+
rtype = rec.get("type", "")
|
|
123
|
+
content = rec.get("content", "") or ""
|
|
124
|
+
if rtype == "MX" and rec.get("priority") is not None:
|
|
125
|
+
return f"{rec['priority']} {content}".strip()
|
|
126
|
+
if rtype == "SRV":
|
|
127
|
+
data = rec.get("data") or {}
|
|
128
|
+
if data:
|
|
129
|
+
return (
|
|
130
|
+
f"{data.get('priority', 0)} {data.get('weight', 0)} "
|
|
131
|
+
f"{data.get('port', 0)} {str(data.get('target', '')).rstrip('.')}"
|
|
132
|
+
)
|
|
133
|
+
return content
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _type_payload(record_type: str, value: str) -> dict:
|
|
137
|
+
"""Build the type-specific Cloudflare payload fields.
|
|
138
|
+
|
|
139
|
+
MX and SRV can't be expressed with a bare ``content`` string —
|
|
140
|
+
Cloudflare requires a separate ``priority`` (MX) or a structured
|
|
141
|
+
``data`` object (SRV). Both accept the BIND-style value granny passes
|
|
142
|
+
around: ``"10 mail.example.com"`` for MX and
|
|
143
|
+
``"0 1 443 mail.example.com"`` (priority weight port target) for SRV.
|
|
144
|
+
A bare MX target defaults to priority 10.
|
|
145
|
+
"""
|
|
146
|
+
rtype = record_type.upper()
|
|
147
|
+
parts = value.split()
|
|
148
|
+
if rtype == "MX":
|
|
149
|
+
if len(parts) == 2 and parts[0].isdigit():
|
|
150
|
+
return {"content": parts[1].rstrip("."), "priority": int(parts[0])}
|
|
151
|
+
return {"content": value.rstrip("."), "priority": 10}
|
|
152
|
+
if rtype == "SRV":
|
|
153
|
+
if len(parts) != 4:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"SRV value must be 'priority weight port target', got {value!r}"
|
|
156
|
+
)
|
|
157
|
+
prio, weight, port, target = parts
|
|
158
|
+
return {
|
|
159
|
+
"data": {
|
|
160
|
+
"priority": int(prio),
|
|
161
|
+
"weight": int(weight),
|
|
162
|
+
"port": int(port),
|
|
163
|
+
"target": target.rstrip("."),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {"content": value}
|
|
167
|
+
|
|
113
168
|
def get_record(
|
|
114
169
|
self, zone_id: str, name: str, record_type: str
|
|
115
170
|
) -> DNSRecord | None:
|
|
@@ -134,20 +189,22 @@ class CloudflareDNSProvider(DNSProvider):
|
|
|
134
189
|
payload = {
|
|
135
190
|
"type": record_type.upper(),
|
|
136
191
|
"name": fqdn,
|
|
137
|
-
"content": value,
|
|
138
192
|
# Cloudflare uses ttl=1 as the sentinel for "automatic". Anything
|
|
139
193
|
# else must be >= 60. Match manage-dns.sh's behavior: if caller
|
|
140
194
|
# passes ttl<=1, use automatic; otherwise send the value.
|
|
141
195
|
"ttl": 1 if ttl <= 1 else ttl,
|
|
142
196
|
"proxied": proxied,
|
|
143
197
|
}
|
|
198
|
+
# MX needs a separate `priority`, SRV a structured `data` object;
|
|
199
|
+
# everything else is a plain `content` string.
|
|
200
|
+
payload.update(self._type_payload(record_type, value))
|
|
144
201
|
body = self._request("POST", f"/zones/{zone_id}/dns_records", json=payload)
|
|
145
202
|
rec = body.get("result") or {}
|
|
146
203
|
logger.info("Cloudflare: created %s %s -> %s", record_type, fqdn, value)
|
|
147
204
|
return DNSRecord(
|
|
148
205
|
name=rec.get("name", fqdn),
|
|
149
206
|
type=rec.get("type", record_type.upper()),
|
|
150
|
-
value=
|
|
207
|
+
value=self._record_value(rec) or value,
|
|
151
208
|
ttl=int(rec.get("ttl", ttl)),
|
|
152
209
|
id=rec.get("id"),
|
|
153
210
|
fqdn=rec.get("name", fqdn),
|
|
@@ -168,10 +225,10 @@ class CloudflareDNSProvider(DNSProvider):
|
|
|
168
225
|
payload = {
|
|
169
226
|
"type": record_type.upper(),
|
|
170
227
|
"name": fqdn,
|
|
171
|
-
"content": value,
|
|
172
228
|
"ttl": 1 if ttl <= 1 else ttl,
|
|
173
229
|
"proxied": proxied,
|
|
174
230
|
}
|
|
231
|
+
payload.update(self._type_payload(record_type, value))
|
|
175
232
|
body = self._request(
|
|
176
233
|
"PUT", f"/zones/{zone_id}/dns_records/{record_id}", json=payload
|
|
177
234
|
)
|
|
@@ -180,7 +237,7 @@ class CloudflareDNSProvider(DNSProvider):
|
|
|
180
237
|
return DNSRecord(
|
|
181
238
|
name=rec.get("name", fqdn),
|
|
182
239
|
type=rec.get("type", record_type.upper()),
|
|
183
|
-
value=
|
|
240
|
+
value=self._record_value(rec) or value,
|
|
184
241
|
ttl=int(rec.get("ttl", ttl)),
|
|
185
242
|
id=rec.get("id", record_id),
|
|
186
243
|
fqdn=rec.get("name", fqdn),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Email management — Mailjet, AWS WorkMail, Stalwart."""
|
|
2
|
+
|
|
3
|
+
from granny.email.mailjet import MailjetClient
|
|
4
|
+
from granny.email.mailjet_contacts import MailjetContactClient
|
|
5
|
+
from granny.email.stalwart import StalwartClient, StalwartError
|
|
6
|
+
from granny.email.workmail import WorkMailManager
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"MailjetClient",
|
|
10
|
+
"MailjetContactClient",
|
|
11
|
+
"StalwartClient",
|
|
12
|
+
"StalwartError",
|
|
13
|
+
"WorkMailManager",
|
|
14
|
+
]
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Stalwart mail server admin client.
|
|
2
|
+
|
|
3
|
+
Wraps Stalwart's JMAP management endpoint (POST ``/jmap``) and the small
|
|
4
|
+
REST surface granny needs. The web admin UI uses the
|
|
5
|
+
``urn:stalwart:jmap`` capability with ``x:Domain/*`` and ``x:Account/*``
|
|
6
|
+
methods; there is no REST equivalent for domain/mailbox CRUD, so we use
|
|
7
|
+
the same JMAP calls.
|
|
8
|
+
|
|
9
|
+
Auth is HTTP Basic with the recovery (or post-bootstrap) admin
|
|
10
|
+
credentials. URLs and credentials resolve via the standard granny
|
|
11
|
+
env-var + Vaultwarden pipeline.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import secrets as _secrets
|
|
19
|
+
import string
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from granny.credentials import get_secret
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Stalwart's web admin uses a fixed accountId "d333333" for all domain /
|
|
30
|
+
# account management calls. It identifies the directory-management
|
|
31
|
+
# principal, not a user account.
|
|
32
|
+
_DIRECTORY_ACCOUNT_ID = "d333333"
|
|
33
|
+
|
|
34
|
+
_JMAP_CAPS = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StalwartError(RuntimeError):
|
|
38
|
+
"""Raised on non-2xx responses from Stalwart or JMAP-level errors."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, status: int, body: str) -> None:
|
|
41
|
+
super().__init__(f"HTTP {status}: {body[:400]}")
|
|
42
|
+
self.status = status
|
|
43
|
+
self.body = body
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StalwartClient:
|
|
47
|
+
"""Authenticated client for Stalwart's JMAP management API.
|
|
48
|
+
|
|
49
|
+
Construct directly when you already have the credentials, or use
|
|
50
|
+
:meth:`from_environment` to resolve them via granny's normal env /
|
|
51
|
+
vault pipeline.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
base_url: str,
|
|
57
|
+
user: str,
|
|
58
|
+
password: str,
|
|
59
|
+
*,
|
|
60
|
+
timeout: float = 30.0,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.base_url = base_url.rstrip("/")
|
|
63
|
+
self._timeout = timeout
|
|
64
|
+
self._session = requests.Session()
|
|
65
|
+
self._session.auth = (user, password)
|
|
66
|
+
self._session.headers.update({"Accept": "application/json"})
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_environment(cls) -> "StalwartClient":
|
|
70
|
+
"""Build a client from env vars + Vaultwarden fallback.
|
|
71
|
+
|
|
72
|
+
Resolution:
|
|
73
|
+
|
|
74
|
+
- ``STALWART_URL`` (env only; default ``https://mail.pseekoo.io``)
|
|
75
|
+
- ``STALWART_ADMIN_USER`` and ``STALWART_ADMIN_PASSWORD`` via the
|
|
76
|
+
normal :func:`granny.credentials.get_secret` chain (env wins,
|
|
77
|
+
Vaultwarden under ``granny/infra/stalwart-admin-*`` as fallback).
|
|
78
|
+
"""
|
|
79
|
+
base_url = os.environ.get("STALWART_URL") or "https://mail.pseekoo.io"
|
|
80
|
+
user = get_secret("STALWART_ADMIN_USER")
|
|
81
|
+
password = get_secret("STALWART_ADMIN_PASSWORD")
|
|
82
|
+
if not user or not password:
|
|
83
|
+
raise StalwartError(
|
|
84
|
+
0,
|
|
85
|
+
"STALWART_ADMIN_USER / STALWART_ADMIN_PASSWORD not set "
|
|
86
|
+
"(env or vault). Set them in .env / .deploy.env, or push "
|
|
87
|
+
"them to Vaultwarden at granny/infra/stalwart-admin-user "
|
|
88
|
+
"and granny/infra/stalwart-admin-password.",
|
|
89
|
+
)
|
|
90
|
+
return cls(base_url=base_url, user=user, password=password)
|
|
91
|
+
|
|
92
|
+
# -- HTTP plumbing --------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _request(
|
|
95
|
+
self,
|
|
96
|
+
method: str,
|
|
97
|
+
path: str,
|
|
98
|
+
*,
|
|
99
|
+
json: Any = None,
|
|
100
|
+
) -> Any:
|
|
101
|
+
if not path.startswith("/"):
|
|
102
|
+
path = "/" + path
|
|
103
|
+
url = self.base_url + path
|
|
104
|
+
resp = self._session.request(
|
|
105
|
+
method=method.upper(), url=url, json=json, timeout=self._timeout,
|
|
106
|
+
)
|
|
107
|
+
if resp.status_code >= 400:
|
|
108
|
+
raise StalwartError(resp.status_code, resp.text)
|
|
109
|
+
if not resp.content:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
return resp.json()
|
|
113
|
+
except ValueError:
|
|
114
|
+
return resp.text
|
|
115
|
+
|
|
116
|
+
def _jmap(self, method: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
"""Execute one JMAP method call and return its response args.
|
|
118
|
+
|
|
119
|
+
``method`` is e.g. ``"x:Domain/get"`` or ``"x:Account/set"``;
|
|
120
|
+
``args`` is the per-method argument dict. The accountId is filled
|
|
121
|
+
in automatically.
|
|
122
|
+
"""
|
|
123
|
+
body = {
|
|
124
|
+
"using": _JMAP_CAPS,
|
|
125
|
+
"methodCalls": [[method, {"accountId": _DIRECTORY_ACCOUNT_ID, **args}, "a"]],
|
|
126
|
+
}
|
|
127
|
+
resp = self._request("POST", "/jmap", json=body)
|
|
128
|
+
if not resp or "methodResponses" not in resp:
|
|
129
|
+
raise StalwartError(0, f"unexpected JMAP response: {resp!r}")
|
|
130
|
+
mc = resp["methodResponses"][0]
|
|
131
|
+
# JMAP-level errors come back as ["error", {type: ..., description: ...}, "a"]
|
|
132
|
+
if mc[0] == "error":
|
|
133
|
+
err = mc[1] or {}
|
|
134
|
+
desc = err.get("description") or err.get("type") or "JMAP error"
|
|
135
|
+
raise StalwartError(0, f"{method}: {desc}")
|
|
136
|
+
return mc[1] or {}
|
|
137
|
+
|
|
138
|
+
# -- Domains --------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def list_domains(self) -> list[dict[str, Any]]:
|
|
141
|
+
"""Return all configured local domains."""
|
|
142
|
+
out = self._jmap("x:Domain/get", {})
|
|
143
|
+
return list(out.get("list") or [])
|
|
144
|
+
|
|
145
|
+
def get_domain(self, name: str) -> dict[str, Any] | None:
|
|
146
|
+
"""Find a domain by its DNS name (e.g. ``"m3rp.ai"``)."""
|
|
147
|
+
for d in self.list_domains():
|
|
148
|
+
if d.get("name") == name:
|
|
149
|
+
return d
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def add_domain(
|
|
153
|
+
self,
|
|
154
|
+
name: str,
|
|
155
|
+
*,
|
|
156
|
+
certificate: str = "manual",
|
|
157
|
+
dkim_algorithms: tuple[str, ...] = ("Dkim1RsaSha256", "Dkim1Ed25519Sha256"),
|
|
158
|
+
dkim_rotate_after_ms: int = 7_776_000_000,
|
|
159
|
+
dkim_retire_after_ms: int = 604_800_000,
|
|
160
|
+
dkim_delete_after_ms: int = 2_592_000_000,
|
|
161
|
+
dkim_selector_template: str = "v{version}-{algorithm}-{date-%Y%m%d}",
|
|
162
|
+
dns_management: str = "manual",
|
|
163
|
+
allow_relaying: bool = False,
|
|
164
|
+
report_address_uri: str = "mailto:postmaster",
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""Create a new local domain.
|
|
167
|
+
|
|
168
|
+
Mirrors the production setup (Manual cert because the MX host
|
|
169
|
+
already has a Let's Encrypt cert via Caddy, Automatic DKIM
|
|
170
|
+
rotation, Manual DNS because records are published at the
|
|
171
|
+
domain's registrar).
|
|
172
|
+
"""
|
|
173
|
+
existing = self.get_domain(name)
|
|
174
|
+
if existing is not None:
|
|
175
|
+
logger.info("Domain %s already exists (id=%s)", name, existing["id"])
|
|
176
|
+
return existing
|
|
177
|
+
|
|
178
|
+
dkim = {
|
|
179
|
+
"@type": "Automatic",
|
|
180
|
+
"algorithms": {algo: True for algo in dkim_algorithms},
|
|
181
|
+
"selectorTemplate": dkim_selector_template,
|
|
182
|
+
"rotateAfter": dkim_rotate_after_ms,
|
|
183
|
+
"retireAfter": dkim_retire_after_ms,
|
|
184
|
+
"deleteAfter": dkim_delete_after_ms,
|
|
185
|
+
}
|
|
186
|
+
record = {
|
|
187
|
+
"name": name,
|
|
188
|
+
"isEnabled": True,
|
|
189
|
+
"certificateManagement": {"@type": certificate.capitalize()},
|
|
190
|
+
"dkimManagement": dkim,
|
|
191
|
+
"dnsManagement": {"@type": dns_management.capitalize()},
|
|
192
|
+
"subAddressing": {"@type": "Enabled"},
|
|
193
|
+
"allowRelaying": allow_relaying,
|
|
194
|
+
"reportAddressUri": report_address_uri,
|
|
195
|
+
}
|
|
196
|
+
out = self._jmap("x:Domain/set", {"create": {"new": record}})
|
|
197
|
+
not_created = out.get("notCreated") or {}
|
|
198
|
+
if not_created:
|
|
199
|
+
raise StalwartError(0, f"Domain/set notCreated: {not_created!r}")
|
|
200
|
+
created = (out.get("created") or {}).get("new") or {}
|
|
201
|
+
if not created:
|
|
202
|
+
raise StalwartError(0, f"Domain/set returned no created entry: {out!r}")
|
|
203
|
+
return created
|
|
204
|
+
|
|
205
|
+
def get_dns_zone(self, name: str) -> str:
|
|
206
|
+
"""Return Stalwart's generated DNS zone file for a domain.
|
|
207
|
+
|
|
208
|
+
Includes SPF, MX, DKIM (one per active key), DMARC, MTA-STS,
|
|
209
|
+
TLS-RPT, and the autoconfig/SRV records.
|
|
210
|
+
"""
|
|
211
|
+
dom = self.get_domain(name)
|
|
212
|
+
if dom is None:
|
|
213
|
+
raise StalwartError(0, f"Domain not found: {name}")
|
|
214
|
+
zone = dom.get("dnsZoneFile")
|
|
215
|
+
if zone is None:
|
|
216
|
+
raise StalwartError(0, f"Domain {name} has no dnsZoneFile field")
|
|
217
|
+
return zone
|
|
218
|
+
|
|
219
|
+
# -- Mailboxes (accounts) -------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def list_mailboxes(self) -> list[dict[str, Any]]:
|
|
222
|
+
"""Return all individual user / mailbox accounts."""
|
|
223
|
+
out = self._jmap("x:Account/get", {})
|
|
224
|
+
return list(out.get("list") or [])
|
|
225
|
+
|
|
226
|
+
def get_mailbox(self, email: str) -> dict[str, Any] | None:
|
|
227
|
+
"""Find a mailbox by its primary email address."""
|
|
228
|
+
for a in self.list_mailboxes():
|
|
229
|
+
if a.get("emailAddress") == email:
|
|
230
|
+
return a
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def add_mailbox(
|
|
234
|
+
self,
|
|
235
|
+
email: str,
|
|
236
|
+
*,
|
|
237
|
+
password: str | None = None,
|
|
238
|
+
description: str | None = None,
|
|
239
|
+
encryption_at_rest: bool = False,
|
|
240
|
+
) -> tuple[dict[str, Any], str]:
|
|
241
|
+
"""Create a mailbox under an existing local domain.
|
|
242
|
+
|
|
243
|
+
Returns ``(record, password)`` so the caller can show the
|
|
244
|
+
generated password to the operator. When ``password`` is None,
|
|
245
|
+
a 24-character random password is generated.
|
|
246
|
+
"""
|
|
247
|
+
if "@" not in email:
|
|
248
|
+
raise ValueError("email must be 'local@domain' form")
|
|
249
|
+
local_part, domain = email.split("@", 1)
|
|
250
|
+
dom = self.get_domain(domain)
|
|
251
|
+
if dom is None:
|
|
252
|
+
raise StalwartError(
|
|
253
|
+
0,
|
|
254
|
+
f"Domain '{domain}' is not configured in Stalwart. "
|
|
255
|
+
f"Add it first with add_domain('{domain}').",
|
|
256
|
+
)
|
|
257
|
+
if password is None:
|
|
258
|
+
password = _generate_password()
|
|
259
|
+
|
|
260
|
+
record = {
|
|
261
|
+
"name": local_part,
|
|
262
|
+
"domainId": dom["id"],
|
|
263
|
+
"@type": "User",
|
|
264
|
+
"description": description or f"Mailbox {email}",
|
|
265
|
+
"roles": {"@type": "User"},
|
|
266
|
+
"permissions": {"@type": "Inherit"},
|
|
267
|
+
"encryptionAtRest": {
|
|
268
|
+
"@type": "Enabled" if encryption_at_rest else "Disabled",
|
|
269
|
+
},
|
|
270
|
+
"credentials": {"0": {"@type": "Password", "secret": password}},
|
|
271
|
+
}
|
|
272
|
+
out = self._jmap("x:Account/set", {"create": {"new": record}})
|
|
273
|
+
not_created = out.get("notCreated") or {}
|
|
274
|
+
if not_created:
|
|
275
|
+
raise StalwartError(0, f"Account/set notCreated: {not_created!r}")
|
|
276
|
+
created = (out.get("created") or {}).get("new") or {}
|
|
277
|
+
if not created:
|
|
278
|
+
raise StalwartError(0, f"Account/set returned no created entry: {out!r}")
|
|
279
|
+
return created, password
|
|
280
|
+
|
|
281
|
+
# -- Delivery / health ----------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def get_delivery_token(self) -> str:
|
|
284
|
+
"""Mint a short-lived token for the live delivery test SSE stream."""
|
|
285
|
+
resp = self._request("GET", "/api/token/delivery")
|
|
286
|
+
if not isinstance(resp, str) or not resp.strip():
|
|
287
|
+
raise StalwartError(0, f"unexpected delivery token response: {resp!r}")
|
|
288
|
+
return resp.strip()
|
|
289
|
+
|
|
290
|
+
def stream_delivery_test(self, recipient: str) -> Iterator[str]:
|
|
291
|
+
"""Yield raw SSE lines from Stalwart's live delivery test.
|
|
292
|
+
|
|
293
|
+
Mirrors the admin UI's *Live Delivery Test* page (MX lookup,
|
|
294
|
+
MTA-STS, TLS-RPT, banner, STARTTLS, etc.). Useful from CLI
|
|
295
|
+
because the browser EventSource flow returns 401 on this build.
|
|
296
|
+
"""
|
|
297
|
+
token = self.get_delivery_token()
|
|
298
|
+
url = f"{self.base_url}/api/live/delivery/{recipient}?token={token}"
|
|
299
|
+
with self._session.get(
|
|
300
|
+
url,
|
|
301
|
+
headers={"Accept": "text/event-stream"},
|
|
302
|
+
stream=True,
|
|
303
|
+
timeout=self._timeout,
|
|
304
|
+
) as resp:
|
|
305
|
+
if resp.status_code >= 400:
|
|
306
|
+
raise StalwartError(resp.status_code, resp.text)
|
|
307
|
+
for line in resp.iter_lines(decode_unicode=True):
|
|
308
|
+
if line is not None:
|
|
309
|
+
yield line
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# -- helpers ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _generate_password(length: int = 24) -> str:
|
|
316
|
+
"""Generate an URL-safe random password without padding characters.
|
|
317
|
+
|
|
318
|
+
Matches the strength of ``openssl rand -base64 24`` and strips the
|
|
319
|
+
same noisy characters (``/+=``) as the bash helper.
|
|
320
|
+
"""
|
|
321
|
+
alphabet = string.ascii_letters + string.digits
|
|
322
|
+
return "".join(_secrets.choice(alphabet) for _ in range(length))
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
"""Email management — Mailjet + AWS WorkMail."""
|
|
2
|
-
|
|
3
|
-
from granny.email.mailjet import MailjetClient
|
|
4
|
-
from granny.email.mailjet_contacts import MailjetContactClient
|
|
5
|
-
from granny.email.workmail import WorkMailManager
|
|
6
|
-
|
|
7
|
-
__all__ = ["MailjetClient", "MailjetContactClient", "WorkMailManager"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|