Skip to content

Commit fd2575c

Browse files
committed
Gate managed aliases behind public resolver readiness
1 parent cf9084c commit fd2575c

6 files changed

Lines changed: 141 additions & 7 deletions

File tree

frontend/src/lib/api-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,8 @@ export interface EmailAliasListResponse {
685685
managed_enabled?: boolean;
686686
managed_ready?: boolean;
687687
managed_resolver_configured?: boolean;
688+
managed_resolver_base_url?: string | null;
689+
managed_public_backend_ready?: boolean;
688690
managed_inbound_ready?: boolean;
689691
managed_blockers?: string[];
690692
plus_fallback_enabled?: boolean;

frontend/src/routes/settings.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ function SettingsPage() {
5151
const [managedAliasEnabled, setManagedAliasEnabled] = useState(false);
5252
const [managedAliasReady, setManagedAliasReady] = useState(false);
5353
const [managedAliasResolverConfigured, setManagedAliasResolverConfigured] = useState(false);
54+
const [managedAliasResolverBaseUrl, setManagedAliasResolverBaseUrl] = useState("");
55+
const [managedAliasPublicBackendReady, setManagedAliasPublicBackendReady] = useState(false);
5456
const [managedAliasInboundReady, setManagedAliasInboundReady] = useState(false);
5557
const [managedAliasBlockers, setManagedAliasBlockers] = useState<string[]>([]);
5658
const [plusFallbackEnabled, setPlusFallbackEnabled] = useState(false);
@@ -68,6 +70,8 @@ function SettingsPage() {
6870
const frontendLoginRedirect =
6971
typeof window === "undefined" ? "http://127.0.0.1:4173/login" : `${window.location.origin}/login`;
7072
const authEmail = getAuthEmail();
73+
const canGenerateFallbackAlias =
74+
plusFallbackEnabled && gmailConnected && (!managedAliasEnabled || !managedAliasReady);
7175

7276
useEffect(() => {
7377
const currentApiUrl = getApiUrl();
@@ -128,6 +132,8 @@ function SettingsPage() {
128132
setManagedAliasEnabled(Boolean(response.managed_enabled));
129133
setManagedAliasReady(Boolean(response.managed_ready));
130134
setManagedAliasResolverConfigured(Boolean(response.managed_resolver_configured));
135+
setManagedAliasResolverBaseUrl(response.managed_resolver_base_url || "");
136+
setManagedAliasPublicBackendReady(Boolean(response.managed_public_backend_ready));
131137
setManagedAliasInboundReady(Boolean(response.managed_inbound_ready));
132138
setManagedAliasBlockers(response.managed_blockers || []);
133139
setPlusFallbackEnabled(Boolean(response.plus_fallback_enabled));
@@ -504,13 +510,27 @@ function SettingsPage() {
504510
Managed aliases are not live yet.
505511
{" "}
506512
{!managedAliasResolverConfigured ? "The backend resolver secret is missing. " : ""}
513+
{!managedAliasPublicBackendReady ? "The Cloudflare worker has no public backend URL it can reach. " : ""}
507514
{!managedAliasInboundReady ? "Cloudflare inbound routing still needs to be marked ready. " : ""}
508515
</p>
516+
{managedAliasResolverBaseUrl ? (
517+
<p>
518+
Current resolver base URL:
519+
{" "}
520+
<span className="font-medium text-foreground">{managedAliasResolverBaseUrl}</span>
521+
</p>
522+
) : null}
509523
{managedAliasBlockers.map((item) => (
510524
<p key={item}>
511525
{item}
512526
</p>
513527
))}
528+
{plusFallbackEnabled ? (
529+
<p>
530+
InboxAnchor can still generate a working Gmail fallback alias while the managed
531+
path is offline.
532+
</p>
533+
) : null}
514534
</div>
515535
) : null}
516536
{!managedAliasEnabled ? (
@@ -552,14 +572,16 @@ function SettingsPage() {
552572
onClick={handleGenerateAlias}
553573
disabled={
554574
aliasLoading ||
555-
(managedAliasEnabled && !managedAliasReady) ||
575+
(managedAliasEnabled && !managedAliasReady && !canGenerateFallbackAlias) ||
556576
(!managedAliasEnabled && !plusFallbackEnabled)
557577
}
558578
>
559579
{aliasLoading
560580
? "Generating..."
561581
: managedAliasEnabled && managedAliasReady
562582
? "Generate InboxAnchor alias"
583+
: managedAliasEnabled && canGenerateFallbackAlias
584+
? "Generate Gmail fallback alias"
563585
: managedAliasEnabled
564586
? "Managed alias setup incomplete"
565587
: plusFallbackEnabled

inboxanchor/api/v1/routers/frontend.py

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import ipaddress
45
import json
56
import secrets
67
import threading
78
from dataclasses import dataclass, field
89
from datetime import datetime, timezone
910
from typing import Optional
11+
from urllib.parse import urlparse
1012

1113
from fastapi import APIRouter, Header, HTTPException, Query
1214
from fastapi.responses import StreamingResponse
@@ -209,6 +211,29 @@ def _alias_resolver_secret_configured() -> bool:
209211
return bool(SETTINGS.alias_resolver_secret.strip())
210212

211213

214+
def _managed_alias_resolver_base_url() -> str:
215+
return SETTINGS.alias_resolver_base_url.strip().rstrip("/")
216+
217+
218+
def _managed_alias_public_backend_ready() -> bool:
219+
base_url = _managed_alias_resolver_base_url()
220+
if not base_url:
221+
return False
222+
parsed = urlparse(base_url)
223+
if parsed.scheme != "https":
224+
return False
225+
hostname = (parsed.hostname or "").strip().lower()
226+
if not hostname or hostname in {"localhost", "127.0.0.1", "::1"}:
227+
return False
228+
if hostname.endswith(".local"):
229+
return False
230+
try:
231+
address = ipaddress.ip_address(hostname)
232+
except ValueError:
233+
return True
234+
return not (address.is_private or address.is_loopback or address.is_link_local)
235+
236+
212237
def _current_actor_email(authorization: Optional[str]) -> str:
213238
token = _extract_bearer_token(authorization)
214239
if not token:
@@ -796,6 +821,15 @@ def _managed_alias_blockers() -> list[str]:
796821
return blockers
797822
if not _alias_resolver_secret_configured():
798823
blockers.append("Set INBOXANCHOR_ALIAS_RESOLVER_SECRET on the backend.")
824+
if not _managed_alias_resolver_base_url():
825+
blockers.append(
826+
"Set INBOXANCHOR_ALIAS_RESOLVER_BASE_URL to a public HTTPS URL for the InboxAnchor API."
827+
)
828+
elif not _managed_alias_public_backend_ready():
829+
blockers.append(
830+
"INBOXANCHOR_ALIAS_RESOLVER_BASE_URL must be a public HTTPS URL, "
831+
"not localhost or a private IP."
832+
)
799833
if not _managed_alias_inbound_ready():
800834
blockers.append(
801835
"Deploy the Cloudflare Email Routing worker, then set "
@@ -2956,6 +2990,8 @@ def frontend_list_aliases(
29562990
"managed_enabled": _managed_aliases_enabled(),
29572991
"managed_ready": _managed_aliases_ready(),
29582992
"managed_resolver_configured": _alias_resolver_secret_configured(),
2993+
"managed_resolver_base_url": _managed_alias_resolver_base_url() or None,
2994+
"managed_public_backend_ready": _managed_alias_public_backend_ready(),
29592995
"managed_inbound_ready": _managed_alias_inbound_ready(),
29602996
"managed_blockers": _managed_alias_blockers(),
29612997
"plus_fallback_enabled": _plus_alias_fallback_enabled(),
@@ -2992,7 +3028,8 @@ def frontend_generate_alias(
29923028
"Revoking it in InboxAnchor blocks future reuse here."
29933029
)
29943030
else:
2995-
if _managed_aliases_enabled():
3031+
managed_configured_but_not_ready = _managed_aliases_enabled()
3032+
if managed_configured_but_not_ready and not _plus_alias_fallback_enabled():
29963033
raise HTTPException(
29973034
status_code=503,
29983035
detail=(
@@ -3027,11 +3064,21 @@ def frontend_generate_alias(
30273064
)
30283065
alias_type = "plus"
30293066
provider = "gmail"
3030-
note = (
3031-
"Fallback Gmail alias. It still lands in your mailbox automatically, but it exposes "
3032-
"part of the underlying inbox address because Gmail requires the original local-part "
3033-
"before the + tag."
3034-
)
3067+
if managed_configured_but_not_ready:
3068+
note = (
3069+
"Managed InboxAnchor aliases are not live yet, so InboxAnchor generated a "
3070+
"working Gmail fallback alias instead. "
3071+
+ " ".join(_managed_alias_blockers())
3072+
+ " This fallback still lands in your mailbox automatically, but it exposes "
3073+
"part of the underlying inbox address because Gmail requires the original "
3074+
"local-part before the + tag."
3075+
)
3076+
else:
3077+
note = (
3078+
"Fallback Gmail alias. It still lands in your mailbox automatically, but it "
3079+
"exposes part of the underlying inbox address because Gmail requires the "
3080+
"original local-part before the + tag."
3081+
)
30353082
routing_label = _configure_alias_inbox_routing(
30363083
alias_address=alias,
30373084
label=payload.label,

inboxanchor/config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class Settings:
8383
False,
8484
)
8585
alias_resolver_secret: str = os.getenv("INBOXANCHOR_ALIAS_RESOLVER_SECRET", "")
86+
alias_resolver_base_url: str = os.getenv("INBOXANCHOR_ALIAS_RESOLVER_BASE_URL", "")
8687
alias_inbound_ready: bool = _as_bool(
8788
os.getenv("INBOXANCHOR_ALIAS_INBOUND_READY"),
8889
False,

tests/test_api.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,6 +1525,11 @@ def test_frontend_managed_aliases_use_clean_domain(monkeypatch):
15251525
monkeypatch.setattr(frontend_router.SETTINGS, "alias_managed_enabled", True)
15261526
monkeypatch.setattr(frontend_router.SETTINGS, "alias_domain", "inboxanchor.com")
15271527
monkeypatch.setattr(frontend_router.SETTINGS, "alias_resolver_secret", "resolver-secret")
1528+
monkeypatch.setattr(
1529+
frontend_router.SETTINGS,
1530+
"alias_resolver_base_url",
1531+
"https://api.inboxanchor.com",
1532+
)
15281533
monkeypatch.setattr(frontend_router.SETTINGS, "alias_inbound_ready", True)
15291534

15301535
signup = client.post(
@@ -1557,6 +1562,8 @@ def test_frontend_managed_aliases_use_clean_domain(monkeypatch):
15571562
assert listed_payload["managed_enabled"] is True
15581563
assert listed_payload["managed_ready"] is True
15591564
assert listed_payload["managed_resolver_configured"] is True
1565+
assert listed_payload["managed_resolver_base_url"] == "https://api.inboxanchor.com"
1566+
assert listed_payload["managed_public_backend_ready"] is True
15601567
assert listed_payload["managed_inbound_ready"] is True
15611568
assert listed_payload["managed_blockers"] == []
15621569
assert listed_payload["domain"] == "inboxanchor.com"
@@ -1568,6 +1575,7 @@ def test_frontend_managed_alias_generation_requires_live_inbound(monkeypatch):
15681575
monkeypatch.setattr(frontend_router.SETTINGS, "alias_managed_enabled", True)
15691576
monkeypatch.setattr(frontend_router.SETTINGS, "alias_domain", "inboxanchor.com")
15701577
monkeypatch.setattr(frontend_router.SETTINGS, "alias_resolver_secret", "")
1578+
monkeypatch.setattr(frontend_router.SETTINGS, "alias_resolver_base_url", "http://127.0.0.1:8010")
15711579
monkeypatch.setattr(frontend_router.SETTINGS, "alias_inbound_ready", False)
15721580

15731581
signup = client.post(
@@ -1586,6 +1594,8 @@ def test_frontend_managed_alias_generation_requires_live_inbound(monkeypatch):
15861594
assert listed_payload["managed_enabled"] is True
15871595
assert listed_payload["managed_ready"] is False
15881596
assert listed_payload["managed_resolver_configured"] is False
1597+
assert listed_payload["managed_resolver_base_url"] == "http://127.0.0.1:8010"
1598+
assert listed_payload["managed_public_backend_ready"] is False
15891599
assert listed_payload["managed_inbound_ready"] is False
15901600
assert listed_payload["managed_blockers"]
15911601

@@ -1599,6 +1609,49 @@ def test_frontend_managed_alias_generation_requires_live_inbound(monkeypatch):
15991609
assert "managed aliases are configured but not live yet" in generated.json()["detail"].lower()
16001610

16011611

1612+
def test_frontend_alias_generation_falls_back_to_plus_when_managed_path_is_not_live(monkeypatch):
1613+
from inboxanchor.api.v1.routers import frontend as frontend_router
1614+
1615+
monkeypatch.setattr(frontend_router.SETTINGS, "alias_managed_enabled", True)
1616+
monkeypatch.setattr(frontend_router.SETTINGS, "alias_domain", "inboxanchor.com")
1617+
monkeypatch.setattr(frontend_router.SETTINGS, "alias_resolver_secret", "")
1618+
monkeypatch.setattr(frontend_router.SETTINGS, "alias_resolver_base_url", "http://127.0.0.1:8010")
1619+
monkeypatch.setattr(frontend_router.SETTINGS, "alias_inbound_ready", False)
1620+
monkeypatch.setattr(frontend_router.SETTINGS, "alias_allow_plus_fallback", True)
1621+
1622+
signup = client.post(
1623+
"/auth/signup",
1624+
json={
1625+
"full_name": "Fallback Alias Operator",
1626+
"email": "fallback-managed-owner@example.com",
1627+
"password": "fallback-managed-pass",
1628+
},
1629+
)
1630+
token = signup.json()["token"]
1631+
client.put(
1632+
"/providers/gmail/connection",
1633+
json={
1634+
"status": "connected",
1635+
"account_hint": "fallback-owner@gmail.com",
1636+
"sync_enabled": True,
1637+
"dry_run_only": False,
1638+
"notes": "Connected for alias fallback tests.",
1639+
},
1640+
)
1641+
1642+
generated = client.post(
1643+
"/aliases/generate",
1644+
headers={"Authorization": f"Bearer {token}"},
1645+
json={"label": "travel", "purpose": "airlines"},
1646+
)
1647+
1648+
assert generated.status_code == 200
1649+
payload = generated.json()
1650+
assert payload["alias_type"] == "plus"
1651+
assert "+ia-travel" in payload["alias_address"]
1652+
assert "Managed InboxAnchor aliases are not live yet" in payload["note"]
1653+
1654+
16021655
def test_frontend_alias_resolve_returns_forwarding_payload(monkeypatch):
16031656
from inboxanchor.api.v1.routers import frontend as frontend_router
16041657

@@ -1609,6 +1662,11 @@ def test_frontend_alias_resolve_returns_forwarding_payload(monkeypatch):
16091662
"alias_resolver_secret",
16101663
"resolver-secret",
16111664
)
1665+
monkeypatch.setattr(
1666+
frontend_router.SETTINGS,
1667+
"alias_resolver_base_url",
1668+
"https://api.inboxanchor.com",
1669+
)
16121670
monkeypatch.setattr(frontend_router.SETTINGS, "alias_inbound_ready", True)
16131671

16141672
signup = client.post(

workers/alias-gateway/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ Set this on the InboxAnchor API:
2525
INBOXANCHOR_ALIAS_MANAGED_ENABLED=true
2626
INBOXANCHOR_ALIAS_DOMAIN=inboxanchor.com
2727
INBOXANCHOR_ALIAS_RESOLVER_SECRET=replace-with-a-long-random-secret
28+
INBOXANCHOR_ALIAS_RESOLVER_BASE_URL=https://your-public-inboxanchor-api.example.com
29+
INBOXANCHOR_ALIAS_INBOUND_READY=true
2830
```
2931

3032
## Required Worker vars
@@ -46,5 +48,7 @@ INBOXANCHOR_ALIAS_RESOLVER_SECRET=replace-with-the-same-random-secret
4648
## Notes
4749

4850
- This is the app-side and Worker-side scaffold.
51+
- The Worker must call a public HTTPS InboxAnchor API. A backend running only on
52+
`127.0.0.1` or `localhost` cannot resolve aliases for Cloudflare.
4953
- Real `@inboxanchor.com` delivery still requires domain ownership, MX setup, and live Cloudflare
5054
Email Routing configuration.

0 commit comments

Comments
 (0)