33import asyncio
44import ipaddress
55import json
6+ import logging
67import secrets
78import threading
89from dataclasses import dataclass , field
5354from inboxanchor .sender_intelligence import SenderIntelligenceResolver
5455
5556router = APIRouter (tags = ["frontend-compat" ])
57+ logger = logging .getLogger (__name__ )
5658
5759FRONTEND_RUN_CACHE : dict [str , str ] = {}
5860FRONTEND_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+
141160class 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+
9701013def _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" )
30023111def 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" )
31313297def 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