@@ -63,6 +63,48 @@ pub struct RelayAddress {
6363 pub num_spam : i64 ,
6464}
6565
66+ /// Represents a bounce status object nested within the profile.
67+ #[ derive( Debug , Deserialize , uniffi:: Record ) ]
68+ pub struct BounceStatus {
69+ pub paused : bool ,
70+ #[ serde( rename = "type" ) ]
71+ pub bounce_type : String ,
72+ }
73+
74+ /// Represents a Relay user profile returned by the Relay API.
75+ ///
76+ /// Contains information about the user's subscription status, usage statistics,
77+ /// and account settings.
78+ ///
79+ /// See: https://mozilla.github.io/fx-private-relay/api_docs.html#tag/privaterelay/operation/profiles_retrieve
80+ #[ derive( Debug , Deserialize , uniffi:: Record ) ]
81+ pub struct RelayProfile {
82+ pub id : i64 ,
83+ pub server_storage : bool ,
84+ pub store_phone_log : bool ,
85+ pub subdomain : Option < String > ,
86+ pub has_premium : bool ,
87+ pub has_phone : bool ,
88+ pub has_vpn : bool ,
89+ pub has_megabundle : bool ,
90+ pub onboarding_state : i64 ,
91+ pub onboarding_free_state : i64 ,
92+ pub date_phone_registered : Option < String > ,
93+ pub date_subscribed : Option < String > ,
94+ pub avatar : Option < String > ,
95+ pub next_email_try : String ,
96+ pub bounce_status : BounceStatus ,
97+ pub api_token : String ,
98+ pub emails_blocked : i64 ,
99+ pub emails_forwarded : i64 ,
100+ pub emails_replied : i64 ,
101+ pub level_one_trackers_blocked : i64 ,
102+ pub remove_level_one_email_trackers : Option < bool > ,
103+ pub total_masks : i64 ,
104+ pub at_mask_limit : bool ,
105+ pub metrics_enabled : bool ,
106+ }
107+
66108#[ derive( Debug , Serialize ) ]
67109struct CreateAddressPayload < ' a > {
68110 enabled : bool ,
@@ -211,6 +253,47 @@ impl RelayClient {
211253 let address: RelayAddress = response. json ( ) ?;
212254 Ok ( address)
213255 }
256+
257+ /// Retrieves the profile for the authenticated user.
258+ ///
259+ /// Returns a [`RelayProfile`] object containing subscription status, usage statistics,
260+ /// and account settings. The `has_premium` field indicates whether the user has
261+ /// an active premium subscription.
262+ ///
263+ /// ## Errors
264+ ///
265+ /// - `RelayApi`: Returned for any non-successful (non-2xx) HTTP response.
266+ /// Provides the HTTP `status` and response `body`; downstream consumers can inspect
267+ /// these fields. If the response body is JSON with `error_code` or `detail` fields,
268+ /// these are parsed and included for more granular handling; otherwise, the raw
269+ /// response text is used as the error detail.
270+ /// - `Network`: Returned for transport-level failures, like loss of connectivity,
271+ /// with details in `reason`.
272+ /// - Other variants may be returned for unexpected deserialization, URL, or backend errors.
273+ #[ handle_error( Error ) ]
274+ pub fn fetch_profile ( & self ) -> ApiResult < RelayProfile > {
275+ let url = self . build_url ( "/api/v1/profiles/" ) ?;
276+ let request = self . prepare_request ( Method :: Get , url) ?;
277+
278+ let response = request. send ( ) ?;
279+ let status = response. status ;
280+ let body = response. text ( ) ;
281+ log:: trace!( "response text: {}" , body) ;
282+
283+ if status >= 400 {
284+ return Err ( Error :: RelayApi {
285+ status,
286+ body : body. to_string ( ) ,
287+ } ) ;
288+ }
289+
290+ // The API returns an array with a single profile object for the authenticated user
291+ let profiles: Vec < RelayProfile > = response. json ( ) ?;
292+ profiles. into_iter ( ) . next ( ) . ok_or_else ( || Error :: RelayApi {
293+ status : 200 ,
294+ body : "No profile found for authenticated user" . to_string ( ) ,
295+ } )
296+ }
214297}
215298
216299#[ cfg( test) ]
@@ -537,4 +620,184 @@ mod tests {
537620 assert_eq ! ( address. generated_for, "example.com" ) ;
538621 assert ! ( address. enabled) ;
539622 }
623+
624+ fn mock_profile_json (
625+ id : i64 ,
626+ has_premium : bool ,
627+ subdomain : Option < & str > ,
628+ total_masks : i64 ,
629+ at_mask_limit : bool ,
630+ emails_forwarded : i64 ,
631+ emails_blocked : i64 ,
632+ ) -> String {
633+ let subdomain_json = subdomain
634+ . map ( |s| format ! ( r#""{}""# , s) )
635+ . unwrap_or_else ( || "null" . to_string ( ) ) ;
636+ let date_subscribed = if has_premium {
637+ r#""2023-01-10T08:00:00Z""#
638+ } else {
639+ "null"
640+ } ;
641+ let date_phone_registered = if has_premium {
642+ r#""2023-01-15T10:30:00Z""#
643+ } else {
644+ "null"
645+ } ;
646+ let avatar = if has_premium {
647+ r#""https://example.com/avatar.png""#
648+ } else {
649+ "null"
650+ } ;
651+ let remove_level_one_email_trackers = if has_premium { "true" } else { "null" } ;
652+
653+ format ! (
654+ r#"
655+ [
656+ {{
657+ "id": {id},
658+ "server_storage": {has_premium},
659+ "store_phone_log": {has_premium},
660+ "subdomain": {subdomain_json},
661+ "has_premium": {has_premium},
662+ "has_phone": {has_premium},
663+ "has_vpn": false,
664+ "has_megabundle": false,
665+ "onboarding_state": 5,
666+ "onboarding_free_state": 0,
667+ "date_phone_registered": {date_phone_registered},
668+ "date_subscribed": {date_subscribed},
669+ "avatar": {avatar},
670+ "next_email_try": "2023-12-01T00:00:00Z",
671+ "bounce_status": {{
672+ "paused": false,
673+ "type": "none"
674+ }},
675+ "api_token": "550e8400-e29b-41d4-a716-446655440000",
676+ "emails_blocked": {emails_blocked},
677+ "emails_forwarded": {emails_forwarded},
678+ "emails_replied": 10,
679+ "level_one_trackers_blocked": 42,
680+ "remove_level_one_email_trackers": {remove_level_one_email_trackers},
681+ "total_masks": {total_masks},
682+ "at_mask_limit": {at_mask_limit},
683+ "metrics_enabled": true
684+ }}
685+ ]
686+ "#
687+ )
688+ }
689+
690+ #[ test]
691+ fn test_fetch_profile_premium_user ( ) {
692+ viaduct_dev:: init_backend_dev ( ) ;
693+
694+ let profile_json = mock_profile_json ( 123 , true , Some ( "testuser" ) , 15 , false , 150 , 25 ) ;
695+
696+ let _mock = mock ( "GET" , "/api/v1/profiles/" )
697+ . with_status ( 200 )
698+ . with_header ( "content-type" , "application/json" )
699+ . with_body ( profile_json)
700+ . create ( ) ;
701+
702+ let client = RelayClient :: new ( mockito:: server_url ( ) , Some ( "mock_token" . to_string ( ) ) ) ;
703+
704+ let profile = client
705+ . expect ( "success" )
706+ . fetch_profile ( )
707+ . expect ( "should fetch profile" ) ;
708+
709+ assert_eq ! ( profile. id, 123 ) ;
710+ assert ! ( profile. has_premium) ;
711+ assert_eq ! ( profile. total_masks, 15 ) ;
712+ assert ! ( !profile. at_mask_limit) ;
713+ assert_eq ! ( profile. subdomain, Some ( "testuser" . to_string( ) ) ) ;
714+ assert ! ( profile. has_phone) ;
715+ assert ! ( !profile. has_vpn) ;
716+ assert_eq ! ( profile. emails_forwarded, 150 ) ;
717+ assert_eq ! ( profile. emails_blocked, 25 ) ;
718+ }
719+
720+ #[ test]
721+ fn test_fetch_profile_free_user ( ) {
722+ viaduct_dev:: init_backend_dev ( ) ;
723+
724+ let profile_json = mock_profile_json ( 456 , false , None , 5 , true , 20 , 5 ) ;
725+
726+ let _mock = mock ( "GET" , "/api/v1/profiles/" )
727+ . with_status ( 200 )
728+ . with_header ( "content-type" , "application/json" )
729+ . with_body ( profile_json)
730+ . create ( ) ;
731+
732+ let client = RelayClient :: new ( mockito:: server_url ( ) , Some ( "mock_token" . to_string ( ) ) ) ;
733+
734+ let profile = client
735+ . expect ( "success" )
736+ . fetch_profile ( )
737+ . expect ( "should fetch profile" ) ;
738+
739+ assert_eq ! ( profile. id, 456 ) ;
740+ assert ! ( !profile. has_premium) ;
741+ assert_eq ! ( profile. total_masks, 5 ) ;
742+ assert ! ( profile. at_mask_limit) ;
743+ assert_eq ! ( profile. subdomain, None ) ;
744+ assert ! ( !profile. has_phone) ;
745+ assert_eq ! ( profile. date_subscribed, None ) ;
746+ }
747+
748+ #[ test]
749+ fn test_fetch_profile_unauthorized ( ) {
750+ viaduct_dev:: init_backend_dev ( ) ;
751+
752+ let error_json = r#"{"detail": "Authentication credentials were not provided."}"# ;
753+ let _mock = mock ( "GET" , "/api/v1/profiles/" )
754+ . with_status ( 403 )
755+ . with_header ( "content-type" , "application/json" )
756+ . with_body ( error_json)
757+ . create ( ) ;
758+
759+ let client = RelayClient :: new ( mockito:: server_url ( ) , None ) ;
760+ let result = client. expect ( "success" ) . fetch_profile ( ) ;
761+
762+ match result {
763+ Err ( RelayApiError :: Api {
764+ status,
765+ code,
766+ detail,
767+ } ) => {
768+ assert_eq ! ( status, 403 ) ;
769+ assert_eq ! ( code, "unknown" ) ;
770+ assert_eq ! ( detail, "Authentication credentials were not provided." ) ;
771+ }
772+ other => panic ! ( "Expected RelayApiError::Api but got {:?}" , other) ,
773+ }
774+ }
775+
776+ #[ test]
777+ fn test_fetch_profile_invalid_token ( ) {
778+ viaduct_dev:: init_backend_dev ( ) ;
779+
780+ let error_json = r#"{"error_code": "invalid_token", "detail": "Invalid FXA token."}"# ;
781+ let _mock = mock ( "GET" , "/api/v1/profiles/" )
782+ . with_status ( 401 )
783+ . with_header ( "content-type" , "application/json" )
784+ . with_body ( error_json)
785+ . create ( ) ;
786+
787+ let client = RelayClient :: new ( mockito:: server_url ( ) , Some ( "bad_token" . to_string ( ) ) ) ;
788+ let result = client. expect ( "success" ) . fetch_profile ( ) ;
789+
790+ match result {
791+ Err ( RelayApiError :: Api {
792+ status,
793+ code,
794+ detail,
795+ } ) => {
796+ assert_eq ! ( status, 401 ) ;
797+ assert_eq ! ( code, "invalid_token" ) ;
798+ assert_eq ! ( detail, "Invalid FXA token." ) ;
799+ }
800+ other => panic ! ( "Expected RelayApiError::Api but got {:?}" , other) ,
801+ }
802+ }
540803}
0 commit comments