regstack 0.7.0__tar.gz → 0.8.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.
- {regstack-0.7.0 → regstack-0.8.0}/CHANGELOG.md +33 -0
- {regstack-0.7.0 → regstack-0.8.0}/PKG-INFO +2 -2
- {regstack-0.7.0 → regstack-0.8.0}/README.md +1 -1
- {regstack-0.7.0 → regstack-0.8.0}/pyproject.toml +1 -1
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/__main__.py +52 -15
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/ses.py +5 -1
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/oauth/providers/google.py +1 -1
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/sms/sns.py +1 -1
- regstack-0.8.0/src/regstack/version.py +1 -0
- regstack-0.8.0/src/regstack/wizard/ses/__init__.py +5 -0
- regstack-0.8.0/src/regstack/wizard/ses/_aws.py +315 -0
- regstack-0.8.0/src/regstack/wizard/ses/cli.py +224 -0
- regstack-0.8.0/src/regstack/wizard/ses/routes.py +367 -0
- regstack-0.8.0/src/regstack/wizard/ses/server.py +87 -0
- regstack-0.8.0/src/regstack/wizard/ses/static/wizard.css +125 -0
- regstack-0.8.0/src/regstack/wizard/ses/static/wizard.js +266 -0
- regstack-0.8.0/src/regstack/wizard/ses/templates/wizard.html +25 -0
- regstack-0.8.0/src/regstack/wizard/ses/validators.py +319 -0
- regstack-0.8.0/src/regstack/wizard/ses/window.py +59 -0
- regstack-0.8.0/src/regstack/wizard/ses/writer.py +267 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/templates/designer.html +2 -2
- regstack-0.7.0/src/regstack/version.py +0 -1
- {regstack-0.7.0 → regstack-0.8.0}/.gitignore +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/LICENSE +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/NOTICE +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/SECURITY.md +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/regstack.toml.example +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/app.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/auth/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/auth/clock.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/auth/dependencies.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/auth/jwt.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/auth/lockout.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/auth/mfa.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/auth/password.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/auth/rate_limit.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/auth/tokens.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/base.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/factory.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/backend.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/client.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/indexes.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/protocols.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/backend.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/env.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/oauth_state_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/schema.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/backends/sql/types.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/_results.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/_runtime.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/admin.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/doctor.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/init.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/migrate.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/capture.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/cli.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/http.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/logtail.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/phases/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/phases/account.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/phases/cleanup.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/phases/core_auth.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/phases/feature_discover.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/phases/oauth.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/phases/password_reset.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/phases/reachability.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/phases/sms_mfa.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/report.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/cli/validate/runner.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/config/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/config/loader.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/config/schema.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/config/secrets.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/base.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/composer.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/console.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/factory.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/smtp.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/email_change.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/email_change.subject.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/email_change.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/password_reset.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/password_reset.subject.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/password_reset.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/verification.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/verification.subject.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/email/templates/verification.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/hooks/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/hooks/events.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/models/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/models/_objectid.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/models/login_attempt.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/models/mfa_code.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/models/oauth_identity.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/models/oauth_state.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/models/pending_registration.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/models/user.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/oauth/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/oauth/base.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/oauth/errors.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/oauth/providers/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/oauth/registry.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/_helpers.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/_schemas.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/account.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/admin.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/login.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/logout.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/oauth.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/password.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/phone.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/register.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/routers/verify.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/sms/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/sms/base.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/sms/factory.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/sms/null.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/sms/twilio.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/pages.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/static/css/core.css +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/static/css/theme.css +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/static/js/regstack.js +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/forgot.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/login.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/me.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/register.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/reset.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/verify.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/ui/templates/base.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/cli.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/routes.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/server.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/validators.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/window.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/writer.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/cli.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/routes.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/server.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/validators.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/window.py +0 -0
- {regstack-0.7.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/writer.py +0 -0
|
@@ -7,6 +7,39 @@ Sphinx docs.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.8.0 — 2026-05-19
|
|
11
|
+
|
|
12
|
+
`regstack ses setup` guided wizard, plus two security fixes from
|
|
13
|
+
the 2026-05-18 daily review.
|
|
14
|
+
|
|
15
|
+
**Added: `regstack ses setup`.** A pywebview wizard for the SES
|
|
16
|
+
email backend, mirroring the existing `regstack oauth setup` flow.
|
|
17
|
+
Nine steps walk through region selection, credential source
|
|
18
|
+
(`profile` / `explicit` / `chain`), sender-domain identity
|
|
19
|
+
verification (via SES `GetIdentityVerificationAttributes`),
|
|
20
|
+
sandbox detection (via `GetAccount` with `GetSendQuota` heuristic
|
|
21
|
+
fallback for IAM-restricted policies), and a live test send.
|
|
22
|
+
Non-clobbering tomlkit + secrets.env merge. Headless
|
|
23
|
+
`--print-only` mode for CI / scripting. Gated behind the joint
|
|
24
|
+
extra: `pip install 'regstack[wizard,ses]'`.
|
|
25
|
+
|
|
26
|
+
**Fixed: theme-designer preview no longer ships well-known credentials
|
|
27
|
+
in the wheel.** `designer.html` had `alice@example.com` /
|
|
28
|
+
`hunter2hunter2` as `value=` attributes on its login-form preview;
|
|
29
|
+
flipped to `placeholder=` so the wheel doesn't carry well-known
|
|
30
|
+
example creds that could be mistaken for real fixtures.
|
|
31
|
+
(Daily security review 2026-05-18 · I-1.)
|
|
32
|
+
|
|
33
|
+
**Fixed: Google OAuth token-exchange error no longer echoes the
|
|
34
|
+
response body.** `exchange_code()` previously raised
|
|
35
|
+
`OAuthTokenExchangeError(f"... {body!r}")` on the rare 200-without-id_token
|
|
36
|
+
edge case. The body can contain a live short-lived `access_token` in
|
|
37
|
+
that path, and the OAuth router logs the exception text at WARNING.
|
|
38
|
+
Dropped `{body!r}` from the message; regression test in
|
|
39
|
+
`tests/unit/test_oauth_google.py` pins that a planted token never
|
|
40
|
+
appears in the exception's `str()` or `args`.
|
|
41
|
+
(Daily security review 2026-05-18 · I-2.)
|
|
42
|
+
|
|
10
43
|
## 0.7.0 — 2026-05-17
|
|
11
44
|
|
|
12
45
|
Two-week sprint that lands the `regstack validate` end-to-end probe,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jdrumgoole/regstack
|
|
6
6
|
Project-URL: Repository, https://github.com/jdrumgoole/regstack
|
|
@@ -151,7 +151,7 @@ result everywhere is what regstack is for.
|
|
|
151
151
|
✔ Server-rendered HTML pages, theme with one CSS file
|
|
152
152
|
✔ Pluggable email (console / SMTP / Amazon SES) and SMS (Amazon SNS / Twilio)
|
|
153
153
|
✔ Argon2 password hashing, CSP-friendly templates
|
|
154
|
-
✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview),
|
|
154
|
+
✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
|
|
155
155
|
✔ Three storage backends: SQLite, PostgreSQL, MongoDB — chosen by URL
|
|
156
156
|
```
|
|
157
157
|
|
|
@@ -72,7 +72,7 @@ result everywhere is what regstack is for.
|
|
|
72
72
|
✔ Server-rendered HTML pages, theme with one CSS file
|
|
73
73
|
✔ Pluggable email (console / SMTP / Amazon SES) and SMS (Amazon SNS / Twilio)
|
|
74
74
|
✔ Argon2 password hashing, CSP-friendly templates
|
|
75
|
-
✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview),
|
|
75
|
+
✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
|
|
76
76
|
✔ Three storage backends: SQLite, PostgreSQL, MongoDB — chosen by URL
|
|
77
77
|
```
|
|
78
78
|
|
|
@@ -9,26 +9,40 @@ from regstack.cli.migrate import migrate as migrate_cmd
|
|
|
9
9
|
from regstack.cli.validate.cli import validate as validate_cmd
|
|
10
10
|
from regstack.version import __version__
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
|
|
12
|
+
_EXTRA_BLURB = {
|
|
13
|
+
"wizard": "'wizard' extra (pywebview + tomlkit + uvicorn)",
|
|
14
|
+
"ses": "'ses' extra (aioboto3 for AWS API calls)",
|
|
15
|
+
"oauth": "'oauth' extra (pyjwt[crypto] for OAuth ID-token verification)",
|
|
16
|
+
}
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def
|
|
19
|
+
def _missing_extra(subcommand: str, *extras: str) -> click.Command:
|
|
20
20
|
"""Click command that prints a clear install hint and exits non-zero.
|
|
21
21
|
|
|
22
|
-
Used as the fallback when a wizard subcommand is invoked but
|
|
23
|
-
|
|
24
|
-
message instead of an ImportError traceback
|
|
25
|
-
wizard package.
|
|
22
|
+
Used as the fallback when a wizard subcommand is invoked but one or
|
|
23
|
+
more of the required optional extras is not installed. Gives a
|
|
24
|
+
one-line actionable message instead of an ImportError traceback
|
|
25
|
+
from deep inside the wizard package.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
subcommand: Display name of the subcommand (e.g.
|
|
29
|
+
``"regstack ses setup"``). Used in the error message.
|
|
30
|
+
*extras: One or more extra names the subcommand requires. The
|
|
31
|
+
hint instructs the operator to install all of them
|
|
32
|
+
together via a single ``pip install regstack[a,b,...]``.
|
|
26
33
|
"""
|
|
27
34
|
name = subcommand.split()[-1]
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
blurbs = ", ".join(_EXTRA_BLURB.get(x, f"'{x}' extra") for x in extras)
|
|
36
|
+
extras_arg = ",".join(extras)
|
|
37
|
+
hint = (
|
|
38
|
+
f"The `{subcommand}` command requires the {blurbs}. "
|
|
39
|
+
f"Install with `pip install 'regstack[{extras_arg}]'` "
|
|
40
|
+
f"or `uv sync {' '.join(f'--extra {x}' for x in extras)}`."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@click.command(name=name, help=f"(needs `regstack[{extras_arg}]`)")
|
|
30
44
|
def _stub() -> None:
|
|
31
|
-
click.echo(
|
|
45
|
+
click.echo(hint, err=True)
|
|
32
46
|
raise SystemExit(2)
|
|
33
47
|
|
|
34
48
|
return _stub
|
|
@@ -53,7 +67,7 @@ class _LazyOauthGroup(click.Group):
|
|
|
53
67
|
try:
|
|
54
68
|
from regstack.wizard.oauth_google.cli import setup as setup_cmd
|
|
55
69
|
except ImportError:
|
|
56
|
-
return
|
|
70
|
+
return _missing_extra("regstack oauth setup", "wizard")
|
|
57
71
|
return setup_cmd
|
|
58
72
|
|
|
59
73
|
|
|
@@ -69,10 +83,32 @@ class _LazyThemeGroup(click.Group):
|
|
|
69
83
|
try:
|
|
70
84
|
from regstack.wizard.theme_designer.cli import design as design_cmd
|
|
71
85
|
except ImportError:
|
|
72
|
-
return
|
|
86
|
+
return _missing_extra("regstack theme design", "wizard")
|
|
73
87
|
return design_cmd
|
|
74
88
|
|
|
75
89
|
|
|
90
|
+
class _LazySesGroup(click.Group):
|
|
91
|
+
"""Same lazy-import pattern for the SES setup wizard.
|
|
92
|
+
|
|
93
|
+
Needs BOTH ``wizard`` (pywebview, uvicorn, tomlkit for the SPA
|
|
94
|
+
shell) AND ``ses`` (aioboto3 for AWS API calls). The hint surfaces
|
|
95
|
+
the joint install command so an operator missing either gets a
|
|
96
|
+
single actionable fix.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
100
|
+
return ["setup"]
|
|
101
|
+
|
|
102
|
+
def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
|
|
103
|
+
if name != "setup":
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
from regstack.wizard.ses.cli import setup as setup_cmd
|
|
107
|
+
except ImportError:
|
|
108
|
+
return _missing_extra("regstack ses setup", "wizard", "ses")
|
|
109
|
+
return setup_cmd
|
|
110
|
+
|
|
111
|
+
|
|
76
112
|
@click.group(help="regstack — embeddable account registration for FastAPI apps.")
|
|
77
113
|
@click.version_option(__version__, prog_name="regstack")
|
|
78
114
|
def cli() -> None:
|
|
@@ -86,6 +122,7 @@ cli.add_command(migrate_cmd)
|
|
|
86
122
|
cli.add_command(validate_cmd)
|
|
87
123
|
cli.add_command(_LazyOauthGroup(name="oauth", help="OAuth provider setup wizards."))
|
|
88
124
|
cli.add_command(_LazyThemeGroup(name="theme", help="Theme designer for the SSR pages."))
|
|
125
|
+
cli.add_command(_LazySesGroup(name="ses", help="SES email backend setup wizard."))
|
|
89
126
|
|
|
90
127
|
|
|
91
128
|
def main() -> None:
|
|
@@ -15,7 +15,11 @@ class SesEmailService(EmailService):
|
|
|
15
15
|
|
|
16
16
|
def __init__(self, config: EmailConfig) -> None:
|
|
17
17
|
try:
|
|
18
|
-
|
|
18
|
+
# Bare `type: ignore` because aioboto3 lacks py.typed; with
|
|
19
|
+
# the ses extra installed mypy emits import-untyped, without
|
|
20
|
+
# it import-not-found. A compound code list trips
|
|
21
|
+
# unused-ignore in whichever environment doesn't match.
|
|
22
|
+
import aioboto3 # type: ignore # noqa: F401 (import-time check)
|
|
19
23
|
except ImportError as exc:
|
|
20
24
|
raise RuntimeError(
|
|
21
25
|
"The SES email backend requires the 'ses' extra. "
|
|
@@ -152,7 +152,7 @@ class GoogleProvider(OAuthProvider):
|
|
|
152
152
|
)
|
|
153
153
|
except KeyError as exc:
|
|
154
154
|
raise OAuthTokenExchangeError(
|
|
155
|
-
f"google token response missing field {exc.args[0]!r}
|
|
155
|
+
f"google token response missing field {exc.args[0]!r}"
|
|
156
156
|
) from exc
|
|
157
157
|
|
|
158
158
|
async def verify_id_token(
|
|
@@ -15,7 +15,7 @@ class SnsSmsService(SmsService):
|
|
|
15
15
|
|
|
16
16
|
def __init__(self, config: SmsConfig) -> None:
|
|
17
17
|
try:
|
|
18
|
-
import aioboto3 # type: ignore
|
|
18
|
+
import aioboto3 # type: ignore # noqa: F401
|
|
19
19
|
except ImportError as exc:
|
|
20
20
|
raise RuntimeError(
|
|
21
21
|
"The SNS SMS backend requires the 'sns' extra. "
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.0"
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Thin async wrapper around aioboto3 for the SES wizard's AWS calls.
|
|
2
|
+
|
|
3
|
+
Split into its own module so the routes layer can patch the public
|
|
4
|
+
functions in tests without touching ``aioboto3.Session`` directly.
|
|
5
|
+
|
|
6
|
+
Three calls live here:
|
|
7
|
+
|
|
8
|
+
- :func:`probe_credentials` — STS ``GetCallerIdentity``. Cheapest
|
|
9
|
+
way to confirm the chosen credential source actually authenticates;
|
|
10
|
+
doesn't require any SES permission.
|
|
11
|
+
- :func:`probe_sender_identity` — SES ``GetIdentityVerificationAttributes``
|
|
12
|
+
on both the full email and the domain. Reports per-identity status.
|
|
13
|
+
- :func:`probe_sandbox_state` — SES ``GetAccount`` (preferred) with a
|
|
14
|
+
fallback to ``GetSendQuota`` heuristic. Tolerant of ``AccessDenied``:
|
|
15
|
+
returns "unknown" rather than raising so a least-privilege IAM
|
|
16
|
+
policy doesn't block the wizard.
|
|
17
|
+
- :func:`send_test_email` — SES ``SendEmail`` for the live probe at
|
|
18
|
+
step 6.
|
|
19
|
+
|
|
20
|
+
All four return small typed result dataclasses the routes layer
|
|
21
|
+
shovels into the SPA response payload.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Literal
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import aioboto3 # type: ignore # aioboto3 lacks py.typed; CI lints without --extra ses
|
|
31
|
+
except ImportError as _exc: # pragma: no cover — extras gate
|
|
32
|
+
aioboto3 = None
|
|
33
|
+
_IMPORT_ERROR: ImportError | None = _exc
|
|
34
|
+
else:
|
|
35
|
+
_IMPORT_ERROR = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
CredentialSource = Literal["profile", "explicit", "chain"]
|
|
39
|
+
VerificationStatus = Literal["verified", "pending", "failed", "unknown"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True, frozen=True)
|
|
43
|
+
class CredentialProbe:
|
|
44
|
+
ok: bool
|
|
45
|
+
account_id: str | None = None
|
|
46
|
+
arn: str | None = None
|
|
47
|
+
error: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True, frozen=True)
|
|
51
|
+
class IdentityProbe:
|
|
52
|
+
address_status: VerificationStatus
|
|
53
|
+
domain_status: VerificationStatus
|
|
54
|
+
error: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True, frozen=True)
|
|
58
|
+
class SandboxProbe:
|
|
59
|
+
in_sandbox: bool
|
|
60
|
+
detection: Literal["api", "quota_heuristic", "unknown"]
|
|
61
|
+
error: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True, frozen=True)
|
|
65
|
+
class TestSendProbe:
|
|
66
|
+
ok: bool
|
|
67
|
+
message_id: str | None = None
|
|
68
|
+
error: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def aws_available() -> bool:
|
|
72
|
+
"""Whether the ``ses`` extra (aioboto3) is importable.
|
|
73
|
+
|
|
74
|
+
Used by the routes layer to short-circuit AWS-touching steps
|
|
75
|
+
with a clear error when the extra is missing — though the lazy
|
|
76
|
+
click group should catch this at invocation time, the test suite
|
|
77
|
+
exercises the routes directly without going through click.
|
|
78
|
+
"""
|
|
79
|
+
return aioboto3 is not None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _session(
|
|
83
|
+
*,
|
|
84
|
+
source: CredentialSource,
|
|
85
|
+
profile: str | None,
|
|
86
|
+
access_key_id: str | None,
|
|
87
|
+
secret_access_key: str | None,
|
|
88
|
+
) -> Any:
|
|
89
|
+
if aioboto3 is None: # pragma: no cover — extras gate
|
|
90
|
+
raise RuntimeError(
|
|
91
|
+
"aioboto3 not installed — install the 'ses' extra: `pip install 'regstack[ses]'`."
|
|
92
|
+
)
|
|
93
|
+
if source == "profile":
|
|
94
|
+
return aioboto3.Session(profile_name=profile)
|
|
95
|
+
if source == "explicit":
|
|
96
|
+
return aioboto3.Session(
|
|
97
|
+
aws_access_key_id=access_key_id,
|
|
98
|
+
aws_secret_access_key=secret_access_key,
|
|
99
|
+
)
|
|
100
|
+
return aioboto3.Session()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def probe_credentials(
|
|
104
|
+
*,
|
|
105
|
+
region: str,
|
|
106
|
+
source: CredentialSource,
|
|
107
|
+
profile: str | None = None,
|
|
108
|
+
access_key_id: str | None = None,
|
|
109
|
+
secret_access_key: str | None = None,
|
|
110
|
+
) -> CredentialProbe:
|
|
111
|
+
"""Resolve credentials by calling STS ``GetCallerIdentity``.
|
|
112
|
+
|
|
113
|
+
STS is global; the region argument is passed only so a misconfigured
|
|
114
|
+
profile points at the same region the rest of the wizard uses.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
session = _session(
|
|
118
|
+
source=source,
|
|
119
|
+
profile=profile,
|
|
120
|
+
access_key_id=access_key_id,
|
|
121
|
+
secret_access_key=secret_access_key,
|
|
122
|
+
)
|
|
123
|
+
async with session.client("sts", region_name=region) as sts:
|
|
124
|
+
identity = await sts.get_caller_identity()
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
return CredentialProbe(ok=False, error=_describe(exc))
|
|
127
|
+
return CredentialProbe(
|
|
128
|
+
ok=True,
|
|
129
|
+
account_id=str(identity.get("Account") or ""),
|
|
130
|
+
arn=str(identity.get("Arn") or ""),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def probe_sender_identity(
|
|
135
|
+
*,
|
|
136
|
+
region: str,
|
|
137
|
+
from_address: str,
|
|
138
|
+
source: CredentialSource,
|
|
139
|
+
profile: str | None = None,
|
|
140
|
+
access_key_id: str | None = None,
|
|
141
|
+
secret_access_key: str | None = None,
|
|
142
|
+
) -> IdentityProbe:
|
|
143
|
+
"""Check SES verification status for the sender's address and domain.
|
|
144
|
+
|
|
145
|
+
SES accepts either an email-address-level verification (verify
|
|
146
|
+
a specific ``noreply@example.com``) or a domain-level verification
|
|
147
|
+
(verify ``example.com`` and any address under it works). We report
|
|
148
|
+
both so the SPA can render the right next-step link.
|
|
149
|
+
"""
|
|
150
|
+
if "@" not in from_address:
|
|
151
|
+
return IdentityProbe(
|
|
152
|
+
address_status="unknown",
|
|
153
|
+
domain_status="unknown",
|
|
154
|
+
error="from_address missing '@'",
|
|
155
|
+
)
|
|
156
|
+
domain = from_address.split("@", 1)[1].lower()
|
|
157
|
+
try:
|
|
158
|
+
session = _session(
|
|
159
|
+
source=source,
|
|
160
|
+
profile=profile,
|
|
161
|
+
access_key_id=access_key_id,
|
|
162
|
+
secret_access_key=secret_access_key,
|
|
163
|
+
)
|
|
164
|
+
async with session.client("ses", region_name=region) as ses:
|
|
165
|
+
result = await ses.get_identity_verification_attributes(
|
|
166
|
+
Identities=[from_address, domain],
|
|
167
|
+
)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
return IdentityProbe(
|
|
170
|
+
address_status="unknown",
|
|
171
|
+
domain_status="unknown",
|
|
172
|
+
error=_describe(exc),
|
|
173
|
+
)
|
|
174
|
+
attrs = result.get("VerificationAttributes") or {}
|
|
175
|
+
return IdentityProbe(
|
|
176
|
+
address_status=_status(attrs.get(from_address, {}).get("VerificationStatus")),
|
|
177
|
+
domain_status=_status(attrs.get(domain, {}).get("VerificationStatus")),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def probe_sandbox_state(
|
|
182
|
+
*,
|
|
183
|
+
region: str,
|
|
184
|
+
source: CredentialSource,
|
|
185
|
+
profile: str | None = None,
|
|
186
|
+
access_key_id: str | None = None,
|
|
187
|
+
secret_access_key: str | None = None,
|
|
188
|
+
) -> SandboxProbe:
|
|
189
|
+
"""Detect whether the account is in the SES sandbox.
|
|
190
|
+
|
|
191
|
+
Preferred path: ``ses.get_account()['ProductionAccessEnabled']``
|
|
192
|
+
(added to the SES API in 2021). Fallback: the historic quota
|
|
193
|
+
heuristic (sandbox accounts have a 200-message-per-day cap that
|
|
194
|
+
typically isn't increased without a graduation request).
|
|
195
|
+
|
|
196
|
+
Tolerant of ``AccessDenied`` on both — some operator IAM
|
|
197
|
+
policies block ``ses:GetAccount`` even when they allow
|
|
198
|
+
``ses:SendEmail``. In that case we return ``in_sandbox=False`` +
|
|
199
|
+
``detection="unknown"`` and the routes layer surfaces a non-
|
|
200
|
+
blocking advisory.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
session = _session(
|
|
204
|
+
source=source,
|
|
205
|
+
profile=profile,
|
|
206
|
+
access_key_id=access_key_id,
|
|
207
|
+
secret_access_key=secret_access_key,
|
|
208
|
+
)
|
|
209
|
+
async with session.client("ses", region_name=region) as ses:
|
|
210
|
+
try:
|
|
211
|
+
account = await ses.get_account()
|
|
212
|
+
# `ProductionAccessEnabled` is the canonical key. When
|
|
213
|
+
# absent (older SDK / control-plane version) we drop
|
|
214
|
+
# to the quota heuristic.
|
|
215
|
+
if "ProductionAccessEnabled" in account:
|
|
216
|
+
return SandboxProbe(
|
|
217
|
+
in_sandbox=not bool(account["ProductionAccessEnabled"]),
|
|
218
|
+
detection="api",
|
|
219
|
+
)
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
quota = await ses.get_send_quota()
|
|
224
|
+
# Sandbox heuristic: 200 messages/day + 1 message/sec is
|
|
225
|
+
# the default sandbox cap. Anything strictly above is a
|
|
226
|
+
# graduated account.
|
|
227
|
+
max_24h = float(quota.get("Max24HourSend") or 0)
|
|
228
|
+
max_rate = float(quota.get("MaxSendRate") or 0)
|
|
229
|
+
in_sandbox = max_24h <= 200.0 and max_rate <= 1.0
|
|
230
|
+
return SandboxProbe(in_sandbox=in_sandbox, detection="quota_heuristic")
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
return SandboxProbe(in_sandbox=False, detection="unknown", error=_describe(exc))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def send_test_email(
|
|
236
|
+
*,
|
|
237
|
+
region: str,
|
|
238
|
+
from_address: str,
|
|
239
|
+
to_address: str,
|
|
240
|
+
source: CredentialSource,
|
|
241
|
+
profile: str | None = None,
|
|
242
|
+
access_key_id: str | None = None,
|
|
243
|
+
secret_access_key: str | None = None,
|
|
244
|
+
) -> TestSendProbe:
|
|
245
|
+
subject = "regstack SES wizard — test send"
|
|
246
|
+
body = (
|
|
247
|
+
"This is a test message from the `regstack ses setup` wizard.\n"
|
|
248
|
+
"If you can read it, your SES configuration is working.\n"
|
|
249
|
+
)
|
|
250
|
+
try:
|
|
251
|
+
session = _session(
|
|
252
|
+
source=source,
|
|
253
|
+
profile=profile,
|
|
254
|
+
access_key_id=access_key_id,
|
|
255
|
+
secret_access_key=secret_access_key,
|
|
256
|
+
)
|
|
257
|
+
async with session.client("ses", region_name=region) as ses:
|
|
258
|
+
response = await ses.send_email(
|
|
259
|
+
Source=from_address,
|
|
260
|
+
Destination={"ToAddresses": [to_address]},
|
|
261
|
+
Message={
|
|
262
|
+
"Subject": {"Data": subject, "Charset": "UTF-8"},
|
|
263
|
+
"Body": {"Text": {"Data": body, "Charset": "UTF-8"}},
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
return TestSendProbe(ok=False, error=_describe(exc))
|
|
268
|
+
return TestSendProbe(ok=True, message_id=str(response.get("MessageId") or ""))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Helpers
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _status(raw: Any) -> VerificationStatus:
|
|
277
|
+
"""Map SES's ``VerificationStatus`` strings to our enum.
|
|
278
|
+
|
|
279
|
+
SES returns ``"Success"`` for verified, ``"Pending"`` for
|
|
280
|
+
waiting-on-DNS, ``"Failed"`` for the DNS-record-missing branch,
|
|
281
|
+
and omits the key entirely for an unknown identity.
|
|
282
|
+
"""
|
|
283
|
+
if raw == "Success":
|
|
284
|
+
return "verified"
|
|
285
|
+
if raw == "Pending":
|
|
286
|
+
return "pending"
|
|
287
|
+
if raw == "Failed":
|
|
288
|
+
return "failed"
|
|
289
|
+
return "unknown"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _describe(exc: BaseException) -> str:
|
|
293
|
+
"""Short human-readable error string for the SPA.
|
|
294
|
+
|
|
295
|
+
Doesn't try to be exhaustive — the operator who can read AWS
|
|
296
|
+
error codes also knows what they mean. We strip the boto3-specific
|
|
297
|
+
prefix and cap length so a single long traceback line doesn't
|
|
298
|
+
blow up the SPA layout.
|
|
299
|
+
"""
|
|
300
|
+
text = f"{type(exc).__name__}: {exc}"
|
|
301
|
+
# Cap at 400 chars; SES error bodies are usually shorter.
|
|
302
|
+
return text[:400]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
__all__ = [
|
|
306
|
+
"CredentialProbe",
|
|
307
|
+
"IdentityProbe",
|
|
308
|
+
"SandboxProbe",
|
|
309
|
+
"TestSendProbe",
|
|
310
|
+
"aws_available",
|
|
311
|
+
"probe_credentials",
|
|
312
|
+
"probe_sandbox_state",
|
|
313
|
+
"probe_sender_identity",
|
|
314
|
+
"send_test_email",
|
|
315
|
+
]
|