Skip to content

Commit 9d94877

Browse files
committed
Expand alias APIs and refresh launcher assets
1 parent 7a82dbb commit 9d94877

8 files changed

Lines changed: 429 additions & 48 deletions

File tree

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ GMAIL_WATCH_LABEL_IDS=INBOX
2727
INBOXANCHOR_ALIAS_MANAGED_ENABLED=false
2828
INBOXANCHOR_ALIAS_DOMAIN=
2929
INBOXANCHOR_ALIAS_ALLOW_PLUS_FALLBACK=false
30-
INBOXANCHOR_ALIAS_RESOLVER_SECRET=
30+
INBOXANCHOR_ALIAS_RESOLVER_SECRET=changeme-use-a-long-random-string
31+
INBOXANCHOR_ALIAS_RESOLVER_BASE_URL=
32+
INBOXANCHOR_ALIAS_INBOUND_READY=false
3133

3234
IMAP_HOST=
3335
IMAP_PORT=993
Lines changed: 5 additions & 17 deletions
Loading
-578 KB
Binary file not shown.
10.3 KB
Loading

inboxanchor/api/v1/routers/frontend.py

Lines changed: 190 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import ipaddress
55
import json
6+
import logging
67
import secrets
78
import threading
89
from dataclasses import dataclass, field
@@ -53,6 +54,7 @@
5354
from inboxanchor.sender_intelligence import SenderIntelligenceResolver
5455

5556
router = APIRouter(tags=["frontend-compat"])
57+
logger = logging.getLogger(__name__)
5658

5759
FRONTEND_RUN_CACHE: dict[str, str] = {}
5860
FRONTEND_SERVICE_CACHE: dict[str, InboxAnchorService] = {}
@@ -138,6 +140,23 @@ class FrontendAliasGenerateRequest(BaseModel):
138140
purpose: str = Field(default="", max_length=512)
139141

140142

143+
class FrontendAliasCreateRequest(BaseModel):
144+
alias_address: str = Field(min_length=3, max_length=320)
145+
target_email: str = Field(min_length=3, max_length=320)
146+
label: str = Field(default="", max_length=128)
147+
purpose: str = Field(default="", max_length=512)
148+
note: str = Field(default="", max_length=1024)
149+
alias_type: str = Field(default="plus", max_length=32)
150+
provider: str = Field(default="gmail", max_length=32)
151+
152+
153+
class FrontendAliasStatusResponse(BaseModel):
154+
active: bool
155+
status: str
156+
alias_address: str
157+
forward_to: str
158+
159+
141160
class AliasResolveRequest(BaseModel):
142161
alias_address: str = Field(min_length=3, max_length=320)
143162
sender: str = Field(default="", max_length=320)
@@ -967,6 +986,30 @@ def _alias_resolution_payload(alias: EmailAlias) -> dict:
967986
}
968987

969988

989+
def _emit_alias_event(event_type: str, alias: EmailAlias) -> None:
990+
STREAM_HUB.emit(
991+
{
992+
"type": event_type,
993+
"alias": alias.alias_address,
994+
"status": alias.status,
995+
"provider": alias.provider,
996+
"alias_type": alias.alias_type,
997+
"owner_email": alias.owner_email,
998+
}
999+
)
1000+
1001+
1002+
def _generate_available_alias(repository: InboxRepository, builder, *, attempts: int = 12) -> str:
1003+
for _ in range(attempts):
1004+
alias_address = _normalize_alias_address(builder())
1005+
if repository.get_alias_by_address(alias_address) is None:
1006+
return alias_address
1007+
raise HTTPException(
1008+
status_code=503,
1009+
detail="InboxAnchor could not allocate a unique alias address right now. Try again.",
1010+
)
1011+
1012+
9701013
def _frontend_email_payload(
9711014
detail: dict,
9721015
mailbox_email: Optional[dict] = None,
@@ -2998,6 +3041,72 @@ def frontend_list_aliases(
29983041
}
29993042

30003043

3044+
@router.post("/aliases")
3045+
def frontend_create_alias(
3046+
payload: FrontendAliasCreateRequest,
3047+
authorization: Optional[str] = Header(default=None),
3048+
):
3049+
actor_email = _require_actor_email(authorization)
3050+
alias_address = _normalize_alias_address(payload.alias_address)
3051+
target_email = payload.target_email.strip().lower()
3052+
provider_name = (payload.provider or "gmail").strip().lower() or "gmail"
3053+
alias_type = (payload.alias_type or "plus").strip().lower() or "plus"
3054+
3055+
if "@" not in alias_address:
3056+
raise HTTPException(status_code=400, detail="Alias address must contain @.")
3057+
if "@" not in target_email:
3058+
raise HTTPException(status_code=400, detail="Target email must contain @.")
3059+
3060+
with session_scope() as session:
3061+
repository = InboxRepository(session)
3062+
if repository.get_alias_by_address(alias_address) is not None:
3063+
raise HTTPException(status_code=409, detail="Alias address is already in use.")
3064+
3065+
routing_label = ""
3066+
if provider_name == "gmail":
3067+
try:
3068+
routing_label = _configure_alias_inbox_routing(
3069+
alias_address=alias_address,
3070+
label=payload.label,
3071+
purpose=payload.purpose,
3072+
)
3073+
except HTTPException as exc:
3074+
logger.warning(
3075+
"InboxAnchor could not install Gmail routing for manual alias",
3076+
extra={"alias_address": alias_address, "error": str(exc.detail)},
3077+
)
3078+
except Exception as exc:
3079+
logger.warning(
3080+
"InboxAnchor could not install Gmail routing for manual alias",
3081+
extra={"alias_address": alias_address, "error": str(exc)},
3082+
)
3083+
3084+
note = payload.note.strip()
3085+
if routing_label:
3086+
suffix = (
3087+
f"New mail to this alias is auto-labeled into {routing_label} "
3088+
"and skipped from the main inbox."
3089+
)
3090+
note = f"{note} {suffix}".strip() if note else suffix
3091+
3092+
stored = repository.create_alias(
3093+
EmailAlias(
3094+
owner_email=actor_email,
3095+
provider=provider_name,
3096+
alias_address=alias_address,
3097+
target_email=target_email,
3098+
alias_type=alias_type,
3099+
label=payload.label.strip(),
3100+
purpose=payload.purpose.strip(),
3101+
note=note,
3102+
created_at=datetime.now(timezone.utc),
3103+
)
3104+
)
3105+
3106+
_emit_alias_event("alias_created", stored)
3107+
return stored.model_dump(mode="json")
3108+
3109+
30013110
@router.post("/aliases/generate")
30023111
def frontend_generate_alias(
30033112
payload: FrontendAliasGenerateRequest,
@@ -3012,13 +3121,11 @@ def frontend_generate_alias(
30123121
detail="InboxAnchor could not determine the target inbox for this alias.",
30133122
)
30143123

3015-
if _managed_aliases_ready():
3124+
managed_aliases_ready = _managed_aliases_ready()
3125+
managed_configured_but_not_ready = _managed_aliases_enabled()
3126+
3127+
if managed_aliases_ready:
30163128
alias_domain = _managed_alias_domain()
3017-
alias = _generate_managed_alias(
3018-
alias_domain,
3019-
label=payload.label,
3020-
purpose=payload.purpose,
3021-
)
30223129
alias_type = "managed"
30233130
provider = "inboxanchor"
30243131
note = (
@@ -3028,7 +3135,6 @@ def frontend_generate_alias(
30283135
"Revoking it in InboxAnchor blocks future reuse here."
30293136
)
30303137
else:
3031-
managed_configured_but_not_ready = _managed_aliases_enabled()
30323138
if managed_configured_but_not_ready and not _plus_alias_fallback_enabled():
30333139
raise HTTPException(
30343140
status_code=503,
@@ -3057,11 +3163,6 @@ def frontend_generate_alias(
30573163
"uses Gmail plus-addressing."
30583164
),
30593165
)
3060-
alias = _generate_plus_alias(
3061-
target_email,
3062-
label=payload.label,
3063-
purpose=payload.purpose,
3064-
)
30653166
alias_type = "plus"
30663167
provider = "gmail"
30673168
if managed_configured_but_not_ready:
@@ -3079,18 +3180,37 @@ def frontend_generate_alias(
30793180
"exposes part of the underlying inbox address because Gmail requires the "
30803181
"original local-part before the + tag."
30813182
)
3082-
routing_label = _configure_alias_inbox_routing(
3083-
alias_address=alias,
3084-
label=payload.label,
3085-
purpose=payload.purpose,
3086-
)
3087-
if routing_label:
3088-
note = (
3089-
f"{note} New mail to this alias is auto-labeled into {routing_label} "
3090-
"and skipped from the main inbox."
3091-
)
30923183
with session_scope() as session:
30933184
repository = InboxRepository(session)
3185+
if managed_aliases_ready:
3186+
alias = _generate_available_alias(
3187+
repository,
3188+
lambda: _generate_managed_alias(
3189+
alias_domain,
3190+
label=payload.label,
3191+
purpose=payload.purpose,
3192+
),
3193+
)
3194+
else:
3195+
alias = _generate_available_alias(
3196+
repository,
3197+
lambda: _generate_plus_alias(
3198+
target_email,
3199+
label=payload.label,
3200+
purpose=payload.purpose,
3201+
),
3202+
)
3203+
3204+
routing_label = _configure_alias_inbox_routing(
3205+
alias_address=alias,
3206+
label=payload.label,
3207+
purpose=payload.purpose,
3208+
)
3209+
if routing_label:
3210+
note = (
3211+
f"{note} New mail to this alias is auto-labeled into {routing_label} "
3212+
"and skipped from the main inbox."
3213+
)
30943214
stored = repository.create_email_alias(
30953215
EmailAlias(
30963216
owner_email=actor_email,
@@ -3104,6 +3224,7 @@ def frontend_generate_alias(
31043224
created_at=datetime.now(timezone.utc),
31053225
)
31063226
)
3227+
_emit_alias_event("alias_created", stored)
31073228
return stored.model_dump(mode="json")
31083229

31093230

@@ -3124,9 +3245,54 @@ def frontend_revoke_alias(
31243245
revoked = repository.revoke_email_alias(alias_id)
31253246
if revoked is None:
31263247
raise HTTPException(status_code=404, detail="Alias not found.")
3248+
_emit_alias_event("alias_revoked", revoked)
31273249
return revoked.model_dump(mode="json")
31283250

31293251

3252+
@router.post("/aliases/by-address/{alias_address}/revoke")
3253+
def frontend_revoke_alias_by_address(
3254+
alias_address: str,
3255+
authorization: Optional[str] = Header(default=None),
3256+
):
3257+
actor_email = _require_actor_email(authorization)
3258+
normalized_alias = _normalize_alias_address(alias_address)
3259+
with session_scope() as session:
3260+
repository = InboxRepository(session)
3261+
alias = repository.get_alias_by_address(normalized_alias)
3262+
if alias is None:
3263+
raise HTTPException(status_code=404, detail="Alias not found.")
3264+
if alias.owner_email != actor_email:
3265+
raise HTTPException(status_code=403, detail="You can only revoke your own aliases.")
3266+
_remove_alias_inbox_routing(alias.alias_address)
3267+
revoked = repository.revoke_alias(normalized_alias)
3268+
if revoked is None:
3269+
raise HTTPException(status_code=404, detail="Alias not found.")
3270+
_emit_alias_event("alias_revoked", revoked)
3271+
return revoked.model_dump(mode="json")
3272+
3273+
3274+
@router.get("/aliases/{alias_address}/status")
3275+
def frontend_alias_status(
3276+
alias_address: str,
3277+
authorization: Optional[str] = Header(default=None),
3278+
) -> FrontendAliasStatusResponse:
3279+
actor_email = _require_actor_email(authorization)
3280+
normalized_alias = _normalize_alias_address(alias_address)
3281+
with session_scope() as session:
3282+
repository = InboxRepository(session)
3283+
alias = repository.get_alias_by_address(normalized_alias)
3284+
if alias is None:
3285+
raise HTTPException(status_code=404, detail="Alias not found.")
3286+
if alias.owner_email != actor_email:
3287+
raise HTTPException(status_code=403, detail="You can only view your own aliases.")
3288+
return FrontendAliasStatusResponse(
3289+
active=alias.status == "active",
3290+
status=alias.status,
3291+
alias_address=alias.alias_address,
3292+
forward_to=alias.target_email,
3293+
)
3294+
3295+
31303296
@router.post("/aliases/resolve")
31313297
def frontend_resolve_alias(
31323298
payload: AliasResolveRequest,
@@ -3136,7 +3302,7 @@ def frontend_resolve_alias(
31363302
alias_address = _normalize_alias_address(payload.alias_address)
31373303
with session_scope() as session:
31383304
repository = InboxRepository(session)
3139-
alias = repository.get_email_alias_by_address(alias_address)
3305+
alias = repository.get_alias_by_address(alias_address)
31403306
if alias is None:
31413307
return {
31423308
"active": False,
@@ -3150,6 +3316,7 @@ def frontend_resolve_alias(
31503316
"action": "reject",
31513317
"reason": "Alias has been revoked.",
31523318
"alias_address": alias.alias_address,
3319+
"owner_email": alias.owner_email,
31533320
}
31543321
resolved = _alias_resolution_payload(alias)
31553322
return {

0 commit comments

Comments
 (0)