Bug: feat(users): handle email URLs for users in different tenancies



We will be sending emails to users in different tenancies, on clicking the email they should land on that particular tenant URL. 
For that we need tenant level user configs. And 'll be using it to generate emails.

diff --git a/config/config.example.toml b/config/config.example.toml
index 0fe6b8caa2c..8d7c926c3e2 100644
--- a/config/config.example.toml
+++ b/config/config.example.toml
@@ -758,8 +758,15 @@ sdk_eligible_payment_methods = "card"
 enabled = false
 global_tenant = { schema = "public", redis_key_prefix = "", clickhouse_database = "default"}
 
-[multitenancy.tenants]
-public = { base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default" } # schema -> Postgres db schema, redis_key_prefix -> redis key distinguisher, base_url -> url of the tenant
+[multitenancy.tenants.public]
+base_url = "http://localhost:8080"               # URL of the tenant
+schema = "public"                                # Postgres db schema
+redis_key_prefix = ""                            # Redis key distinguisher
+clickhouse_database = "default"                  # Clickhouse database
+
+[multitenancy.tenants.public.user]
+control_center_url =  "http://localhost:9000"    # Control center URL
+
 
 [user_auth_methods]
 encryption_key = "" # Encryption key used for encrypting data in user_authentication_methods table
diff --git a/config/deployments/env_specific.toml b/config/deployments/env_specific.toml
index 848a2305ae4..286639e89f3 100644
--- a/config/deployments/env_specific.toml
+++ b/config/deployments/env_specific.toml
@@ -304,8 +304,14 @@ region = "kms_region" # The AWS region used by the KMS SDK for decrypting data.
 enabled = false
 global_tenant = { schema = "public", redis_key_prefix = "", clickhouse_database = "default"}
 
-[multitenancy.tenants]
-public = { base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default" }
+[multitenancy.tenants.public]
+base_url = "http://localhost:8080" 
+schema = "public"     
+redis_key_prefix = ""
+clickhouse_database = "default"
+
+[multitenancy.tenants.public.user]
+control_center_url =  "http://localhost:9000"
 
 [user_auth_methods]
 encryption_key = "user_auth_table_encryption_key" # Encryption key used for encrypting data in user_authentication_methods table
diff --git a/config/development.toml b/config/development.toml
index 077e09f0463..d3cdbd11bbc 100644
--- a/config/development.toml
+++ b/config/development.toml
@@ -774,8 +774,14 @@ sdk_eligible_payment_methods = "card"
 enabled = false
 global_tenant = { schema = "public", redis_key_prefix = "", clickhouse_database = "default"}
 
-[multitenancy.tenants]
-public = { base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"}
+[multitenancy.tenants.public]
+base_url = "http://localhost:8080"
+schema = "public"
+redis_key_prefix = ""
+clickhouse_database = "default"
+
+[multitenancy.tenants.public.user]
+control_center_url =  "http://localhost:9000"
 
 [user_auth_methods]
 encryption_key = "A8EF32E029BC3342E54BF2E172A4D7AA43E8EF9D2C3A624A9F04E2EF79DC698F"
diff --git a/config/docker_compose.toml b/config/docker_compose.toml
index dbd5286b290..b861e47e594 100644
--- a/config/docker_compose.toml
+++ b/config/docker_compose.toml
@@ -633,8 +633,14 @@ sdk_eligible_payment_methods = "card"
 enabled = false
 global_tenant = { schema = "public", redis_key_prefix = "", clickhouse_database = "default" }
 
-[multitenancy.tenants]
-public = { base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default" }
+[multitenancy.tenants.public]
+base_url = "http://localhost:8080" 
+schema = "public"     
+redis_key_prefix = ""
+clickhouse_database = "default"
+
+[multitenancy.tenants.public.user]
+control_center_url =  "http://localhost:9000"
 
 [user_auth_methods]
 encryption_key = "A8EF32E029BC3342E54BF2E172A4D7AA43E8EF9D2C3A624A9F04E2EF79DC698F"
diff --git a/crates/external_services/src/email.rs b/crates/external_services/src/email.rs
index 2e05a26e1f1..a98b03c0b54 100644
--- a/crates/external_services/src/email.rs
+++ b/crates/external_services/src/email.rs
@@ -47,6 +47,7 @@ pub trait EmailService: Sync + Send + dyn_clone::DynClone {
     /// Compose and send email using the email data
     async fn compose_and_send_email(
         &self,
+        base_url: &str,
         email_data: Box<dyn EmailData + Send>,
         proxy_url: Option<&String>,
     ) -> EmailResult<()>;
@@ -60,10 +61,11 @@ where
 {
     async fn compose_and_send_email(
         &self,
+        base_url: &str,
         email_data: Box<dyn EmailData + Send>,
         proxy_url: Option<&String>,
     ) -> EmailResult<()> {
-        let email_data = email_data.get_email_data();
+        let email_data = email_data.get_email_data(base_url);
         let email_data = email_data.await?;
 
         let EmailContents {
@@ -113,7 +115,7 @@ pub struct EmailContents {
 #[async_trait::async_trait]
 pub trait EmailData {
     /// Get the email contents
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError>;
+    async fn get_email_data(&self, base_url: &str) -> CustomResult<EmailContents, EmailError>;
 }
 
 dyn_clone::clone_trait_object!(EmailClient<RichText = Body>);
diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs
index 4ab2a8fd5c7..ccd43d25b8e 100644
--- a/crates/router/src/configs/settings.rs
+++ b/crates/router/src/configs/settings.rs
@@ -169,6 +169,12 @@ pub struct Tenant {
     pub schema: String,
     pub redis_key_prefix: String,
     pub clickhouse_database: String,
+    pub user: TenantUserConfig,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct TenantUserConfig {
+    pub control_center_url: String,
 }
 
 impl storage_impl::config::TenantConfig for Tenant {
@@ -1130,6 +1136,7 @@ impl<'de> Deserialize<'de> for TenantConfig {
             schema: String,
             redis_key_prefix: String,
             clickhouse_database: String,
+            user: TenantUserConfig,
         }
 
         let hashmap = <HashMap<id_type::TenantId, Inner>>::deserialize(deserializer)?;
@@ -1146,6 +1153,7 @@ impl<'de> Deserialize<'de> for TenantConfig {
                             schema: value.schema,
                             redis_key_prefix: value.redis_key_prefix,
                             clickhouse_database: value.clickhouse_database,
+                            user: value.user,
                         },
                     )
                 })
diff --git a/crates/router/src/core/recon.rs b/crates/router/src/core/recon.rs
index c2f289d4cc0..3b2aacd4514 100644
--- a/crates/router/src/core/recon.rs
+++ b/crates/router/src/core/recon.rs
@@ -80,6 +80,7 @@ pub async fn send_recon_request(
         state
             .email_client
             .compose_and_send_email(
+                email_types::get_base_url(&state),
                 Box::new(email_contents),
                 state.conf.proxy.https_url.as_ref(),
             )
@@ -179,7 +180,7 @@ pub async fn recon_merchant_account_update(
         let theme = theme_utils::get_most_specific_theme_using_lineage(
             &state.clone(),
             ThemeLineage::Merchant {
-                tenant_id: state.tenant.tenant_id,
+                tenant_id: state.tenant.tenant_id.clone(),
                 org_id: auth.merchant_account.get_org_id().clone(),
                 merchant_id: merchant_id.clone(),
             },
@@ -210,6 +211,7 @@ pub async fn recon_merchant_account_update(
             let _ = state
                 .email_client
                 .compose_and_send_email(
+                    email_types::get_base_url(&state),
                     Box::new(email_contents),
                     state.conf.proxy.https_url.as_ref(),
                 )
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index 642473bdf8e..19f257ef4f3 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -96,6 +96,7 @@ pub async fn signup_with_merchant_id(
     let send_email_result = state
         .email_client
         .compose_and_send_email(
+            email_types::get_base_url(&state),
             Box::new(email_contents),
             state.conf.proxy.https_url.as_ref(),
         )
@@ -239,6 +240,7 @@ pub async fn connect_account(
         let send_email_result = state
             .email_client
             .compose_and_send_email(
+                email_types::get_base_url(&state),
                 Box::new(email_contents),
                 state.conf.proxy.https_url.as_ref(),
             )
@@ -294,6 +296,7 @@ pub async fn connect_account(
         let magic_link_result = state
             .email_client
             .compose_and_send_email(
+                email_types::get_base_url(&state),
                 Box::new(magic_link_email),
                 state.conf.proxy.https_url.as_ref(),
             )
@@ -310,6 +313,7 @@ pub async fn connect_account(
             let welcome_email_result = state
                 .email_client
                 .compose_and_send_email(
+                    email_types::get_base_url(&state),
                     Box::new(welcome_to_community_email),
                     state.conf.proxy.https_url.as_ref(),
                 )
@@ -438,6 +442,7 @@ pub async fn forgot_password(
     state
         .email_client
         .compose_and_send_email(
+            email_types::get_base_url(&state),
             Box::new(email_contents),
             state.conf.proxy.https_url.as_ref(),
         )
@@ -845,6 +850,7 @@ async fn handle_existing_user_invitation(
         is_email_sent = state
             .email_client
             .compose_and_send_email(
+                email_types::get_base_url(state),
                 Box::new(email_contents),
                 state.conf.proxy.https_url.as_ref(),
             )
@@ -1000,6 +1006,7 @@ async fn handle_new_user_invitation(
         let send_email_result = state
             .email_client
             .compose_and_send_email(
+                email_types::get_base_url(state),
                 Box::new(email_contents),
                 state.conf.proxy.https_url.as_ref(),
             )
@@ -1151,6 +1158,7 @@ pub async fn resend_invite(
     state
         .email_client
         .compose_and_send_email(
+            email_types::get_base_url(&state),
             Box::new(email_contents),
             state.conf.proxy.https_url.as_ref(),
         )
@@ -1782,6 +1790,7 @@ pub async fn send_verification_mail(
     state
         .email_client
         .compose_and_send_email(
+            email_types::get_base_url(&state),
             Box::new(email_contents),
             state.conf.proxy.https_url.as_ref(),
         )
diff --git a/crates/router/src/core/user/dashboard_metadata.rs b/crates/router/src/core/user/dashboard_metadata.rs
index 88380363b4e..aa67a70223f 100644
--- a/crates/router/src/core/user/dashboard_metadata.rs
+++ b/crates/router/src/core/user/dashboard_metadata.rs
@@ -496,6 +496,7 @@ async fn insert_metadata(
                     let send_email_result = state
                         .email_client
                         .compose_and_send_email(
+                            email_types::get_base_url(state),
                             Box::new(email_contents),
                             state.conf.proxy.https_url.as_ref(),
                         )
diff --git a/crates/router/src/services/email/types.rs b/crates/router/src/services/email/types.rs
index 128250a61aa..476cd1292f6 100644
--- a/crates/router/src/services/email/types.rs
+++ b/crates/router/src/services/email/types.rs
@@ -225,6 +225,14 @@ pub fn get_link_with_token(
     email_url
 }
 
+pub fn get_base_url(state: &SessionState) -> &str {
+    if !state.conf.multitenancy.enabled {
+        &state.conf.user.base_url
+    } else {
+        &state.tenant.user.control_center_url
+    }
+}
+
 pub struct VerifyEmail {
     pub recipient_email: domain::UserEmail,
     pub settings: std::sync::Arc<configs::Settings>,
@@ -237,7 +245,7 @@ pub struct VerifyEmail {
 /// Currently only HTML is supported
 #[async_trait::async_trait]
 impl EmailData for VerifyEmail {
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
+    async fn get_email_data(&self, base_url: &str) -> CustomResult<EmailContents, EmailError> {
         let token = EmailToken::new_token(
             self.recipient_email.clone(),
             None,
@@ -248,7 +256,7 @@ impl EmailData for VerifyEmail {
         .change_context(EmailError::TokenGenerationFailure)?;
 
         let verify_email_link = get_link_with_token(
-            &self.settings.user.base_url,
+            base_url,
             token,
             "verify_email",
             &self.auth_id,
@@ -279,7 +287,7 @@ pub struct ResetPassword {
 
 #[async_trait::async_trait]
 impl EmailData for ResetPassword {
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
+    async fn get_email_data(&self, base_url: &str) -> CustomResult<EmailContents, EmailError> {
         let token = EmailToken::new_token(
             self.recipient_email.clone(),
             None,
@@ -290,7 +298,7 @@ impl EmailData for ResetPassword {
         .change_context(EmailError::TokenGenerationFailure)?;
 
         let reset_password_link = get_link_with_token(
-            &self.settings.user.base_url,
+            base_url,
             token,
             "set_password",
             &self.auth_id,
@@ -322,7 +330,7 @@ pub struct MagicLink {
 
 #[async_trait::async_trait]
 impl EmailData for MagicLink {
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
+    async fn get_email_data(&self, base_url: &str) -> CustomResult<EmailContents, EmailError> {
         let token = EmailToken::new_token(
             self.recipient_email.clone(),
             None,
@@ -333,7 +341,7 @@ impl EmailData for MagicLink {
         .change_context(EmailError::TokenGenerationFailure)?;
 
         let magic_link_login = get_link_with_token(
-            &self.settings.user.base_url,
+            base_url,
             token,
             "verify_email",
             &self.auth_id,
@@ -366,7 +374,7 @@ pub struct InviteUser {
 
 #[async_trait::async_trait]
 impl EmailData for InviteUser {
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
+    async fn get_email_data(&self, base_url: &str) -> CustomResult<EmailContents, EmailError> {
         let token = EmailToken::new_token(
             self.recipient_email.clone(),
             Some(self.entity.clone()),
@@ -377,7 +385,7 @@ impl EmailData for InviteUser {
         .change_context(EmailError::TokenGenerationFailure)?;
 
         let invite_user_link = get_link_with_token(
-            &self.settings.user.base_url,
+            base_url,
             token,
             "accept_invite_from_email",
             &self.auth_id,
@@ -406,7 +414,7 @@ pub struct ReconActivation {
 
 #[async_trait::async_trait]
 impl EmailData for ReconActivation {
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
+    async fn get_email_data(&self, _base_url: &str) -> CustomResult<EmailContents, EmailError> {
         let body = html::get_html_body(EmailBody::ReconActivation {
             user_name: self.user_name.clone().get_secret().expose(),
         });
@@ -461,7 +469,7 @@ impl BizEmailProd {
 
 #[async_trait::async_trait]
 impl EmailData for BizEmailProd {
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
+    async fn get_email_data(&self, _base_url: &str) -> CustomResult<EmailContents, EmailError> {
         let body = html::get_html_body(EmailBody::BizEmailProd {
             user_name: self.user_name.clone().expose(),
             poc_email: self.poc_email.clone().expose(),
@@ -491,7 +499,7 @@ pub struct ProFeatureRequest {
 
 #[async_trait::async_trait]
 impl EmailData for ProFeatureRequest {
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
+    async fn get_email_data(&self, _base_url: &str) -> CustomResult<EmailContents, EmailError> {
         let recipient = self.recipient_email.clone().into_inner();
 
         let body = html::get_html_body(EmailBody::ProFeatureRequest {
@@ -521,7 +529,7 @@ pub struct ApiKeyExpiryReminder {
 
 #[async_trait::async_trait]
 impl EmailData for ApiKeyExpiryReminder {
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
+    async fn get_email_data(&self, _base_url: &str) -> CustomResult<EmailContents, EmailError> {
         let recipient = self.recipient_email.clone().into_inner();
 
         let body = html::get_html_body(EmailBody::ApiKeyExpiryReminder {
@@ -545,7 +553,7 @@ pub struct WelcomeToCommunity {
 
 #[async_trait::async_trait]
 impl EmailData for WelcomeToCommunity {
-    async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
+    async fn get_email_data(&self, _base_url: &str) -> CustomResult<EmailContents, EmailError> {
         let body = html::get_html_body(EmailBody::WelcomeToCommunity);
 
         Ok(EmailContents {
diff --git a/crates/router/src/workflows/api_key_expiry.rs b/crates/router/src/workflows/api_key_expiry.rs
index cdc4959c456..2ff527a046e 100644
--- a/crates/router/src/workflows/api_key_expiry.rs
+++ b/crates/router/src/workflows/api_key_expiry.rs
@@ -9,7 +9,7 @@ use crate::{
     consts, errors,
     logger::error,
     routes::{metrics, SessionState},
-    services::email::types::ApiKeyExpiryReminder,
+    services::email::types::{self as email_types, ApiKeyExpiryReminder},
     types::{api, domain::UserEmail, storage},
     utils::{user::theme as theme_utils, OptionExt},
 };
@@ -110,6 +110,7 @@ impl ProcessTrackerWorkflow<SessionState> for ApiKeyExpiryWorkflow {
             .email_client
             .clone()
             .compose_and_send_email(
+                email_types::get_base_url(state),
                 Box::new(email_contents),
                 state.conf.proxy.https_url.as_ref(),
             )
diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml
index 51981a298e3..09b52bc357d 100644
--- a/loadtest/config/development.toml
+++ b/loadtest/config/development.toml
@@ -385,8 +385,14 @@ keys = "accept-language,user-agent"
 enabled = false
 global_tenant = { schema = "public", redis_key_prefix = "" }
 
-[multitenancy.tenants]
-public = { base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"}
+[multitenancy.tenants.public]
+base_url = "http://localhost:8080" 
+schema = "public"     
+redis_key_prefix = ""
+clickhouse_database = "default"
+
+[multitenancy.tenants.public.user]
+control_center_url =  "http://localhost:9000"
 
 [email]
 sender_email = "example@example.com"

## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [ ] Bugfix
- [x] New feature
- [ ] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
- Add support for user in tenants configs.
- Handle email URLs for different tenancies


### Additional Changes

- [ ] This PR modifies the API contract
- [ ] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
Closes [#6808](https://github.com/juspay/hyperswitch/issues/6808)


## How did you test it?
Tested locally with email feature flag on, for different tenancies the emails we were getting, we were landing onto to the URL specified in the tenant's config.
We specified the following in the local configs.
```
[multitenancy]
enabled = true
global_tenant = { schema = "public", redis_key_prefix = "", clickhouse_database = "default"}

[multitenancy.tenants.public]
base_url = "http://localhost:8080"
schema = "public"
redis_key_prefix = ""
clickhouse_database = "default"

[multitenancy.tenants.public.user]
control_center_url =  "http://localhost:9000"

[multitenancy.tenants.test]
base_url = "http://localhost:8080"
schema = "public"
redis_key_prefix = ""
clickhouse_database = "default"

[multitenancy.tenants.test.user]
control_center_url =  "http://localhost:9001"
```
We were getting the mails
![image](https://github.com/user-attachments/assets/af9b9b9e-0968-45c5-a1d1-f76494ca700f)

For public Tenant: (**http://localhost:9000**/user/verify_email?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImFwb29ydmRpeGl0ODhAZ21haWwuY29tIiwiZmxvdyI6InZlcmlmeV9lbWFpbCIsImV4cCI6MTczNDAwNDg0MSwiZW50aXR5IjpudWxsfQ.j0RlE85yahAALsMccb2uQtt34JIrFV31t3eNYfO5Sdc)

For test Tenant: (**http://locatlhost:9001**/user/verify_email?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImFwb29ydmRpeGl0ODgrMUBnbWFpbC5jb20iLCJmbG93IjoidmVyaWZ5X2VtYWlsIiwiZXhwIjoxNzM0MDA0OTY4LCJlbnRpdHkiOm51bGx9.KIiSssKT1J0awgOt9C64tx6lSNrc0CbfZPPXpyRXP_8)



## Checklist
<!-- Put an `x` in the boxes that apply -->

- [x] I formatted the code `cargo +nightly fmt --all`
- [x] I addressed lints thrown by `cargo clippy`
- [x] I reviewed the submitted code
- [ ] I added unit tests for my changes where possible
