Skip to content

Commit a2569fa

Browse files
committed
Replace global email blacklist with list-based consent
- Add Brevo list management (add/remove contacts to lists) to client - Set email_blacklisted=false for all app signups, use list membership for Product Updates (all users) and Marketing (opt-in only) - Track SCANOPY_MARKETING_OPT_IN and SCANOPY_MARKETING_OPT_IN_DATE - Default marketing checkbox to unchecked (GDPR compliance) - Update marketing label to clarify scope (partnerships, press, news)
1 parent ccd7119 commit a2569fa

File tree

6 files changed

+177
-9
lines changed

6 files changed

+177
-9
lines changed

backend/src/server/brevo/client.rs

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use crate::server::brevo::types::{
2-
CompanyAttributes, CompanyListResponse, CompanyResponse, ContactAttributes,
3-
CreateCompanyRequest, CreateContactRequest, CreateContactResponse, CreateDealRequest,
4-
CreateDealResponse, EventIdentifiers, LinkUnlinkRequest, TrackEventRequest,
5-
UpdateCompanyRequest, UpdateContactRequest,
2+
AddContactsToListRequest, CompanyAttributes, CompanyListResponse, CompanyResponse,
3+
ContactAttributes, CreateCompanyRequest, CreateContactRequest, CreateContactResponse,
4+
CreateDealRequest, CreateDealResponse, EventIdentifiers, LinkUnlinkRequest,
5+
RemoveContactsFromListRequest, TrackEventRequest, UpdateCompanyRequest, UpdateContactRequest,
66
};
77
use anyhow::{Result, anyhow};
88
use backon::{ExponentialBuilder, Retryable};
@@ -589,6 +589,115 @@ impl BrevoClient {
589589
.await
590590
}
591591

592+
/// Add contacts to a Brevo list
593+
pub async fn add_contacts_to_list(&self, list_id: i64, emails: Vec<String>) -> Result<()> {
594+
let url = format!("{}/contacts/lists/{}/contacts/add", BREVO_API_BASE, list_id);
595+
let body = AddContactsToListRequest { emails };
596+
597+
let operation = || async {
598+
self.wait_for_rate_limit().await;
599+
600+
let response = self
601+
.client
602+
.post(&url)
603+
.header("api-key", &self.api_key)
604+
.header("Content-Type", "application/json")
605+
.json(&body)
606+
.send()
607+
.await
608+
.map_err(|e| anyhow!("Brevo add to list failed: {}", e))?;
609+
610+
let status = response.status();
611+
612+
if status.is_success() {
613+
return Ok(());
614+
}
615+
616+
let error_body = response
617+
.text()
618+
.await
619+
.unwrap_or_else(|_| "Unknown error".to_string());
620+
621+
if Self::is_retryable_error(status) {
622+
return Err(anyhow!(
623+
"Brevo list add error (retryable) {}: {}",
624+
status,
625+
error_body
626+
));
627+
}
628+
629+
Err(anyhow!("Brevo list add error {}: {}", status, error_body))
630+
};
631+
632+
operation
633+
.retry(
634+
ExponentialBuilder::default()
635+
.with_max_times(3)
636+
.with_min_delay(std::time::Duration::from_millis(500))
637+
.with_max_delay(std::time::Duration::from_secs(10)),
638+
)
639+
.when(|e| e.to_string().contains("retryable"))
640+
.await
641+
}
642+
643+
/// Remove contacts from a Brevo list
644+
pub async fn remove_contacts_from_list(&self, list_id: i64, emails: Vec<String>) -> Result<()> {
645+
let url = format!(
646+
"{}/contacts/lists/{}/contacts/remove",
647+
BREVO_API_BASE, list_id
648+
);
649+
let body = RemoveContactsFromListRequest { emails };
650+
651+
let operation = || async {
652+
self.wait_for_rate_limit().await;
653+
654+
let response = self
655+
.client
656+
.post(&url)
657+
.header("api-key", &self.api_key)
658+
.header("Content-Type", "application/json")
659+
.json(&body)
660+
.send()
661+
.await
662+
.map_err(|e| anyhow!("Brevo remove from list failed: {}", e))?;
663+
664+
let status = response.status();
665+
666+
if status.is_success() {
667+
return Ok(());
668+
}
669+
670+
let error_body = response
671+
.text()
672+
.await
673+
.unwrap_or_else(|_| "Unknown error".to_string());
674+
675+
if Self::is_retryable_error(status) {
676+
return Err(anyhow!(
677+
"Brevo list remove error (retryable) {}: {}",
678+
status,
679+
error_body
680+
));
681+
}
682+
683+
Err(anyhow!(
684+
"Brevo list remove error {}: {}",
685+
status,
686+
error_body
687+
))
688+
};
689+
690+
operation
691+
.retry(
692+
ExponentialBuilder::default()
693+
.with_max_times(3)
694+
.with_min_delay(std::time::Duration::from_millis(500))
695+
.with_max_delay(std::time::Duration::from_secs(10)),
696+
)
697+
.when(|e| e.to_string().contains("retryable"))
698+
.await
699+
}
700+
592701
/// Create or update contact, then create or update company, linking them.
593702
/// Returns the Brevo company ID to be stored on the organization record.
594703
pub async fn sync_contact_and_company(

backend/src/server/brevo/service.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ use chrono::Utc;
2323
use std::sync::Arc;
2424
use uuid::Uuid;
2525

26+
/// Brevo list ID for "App Users - Product Updates" (all app signups)
27+
const BREVO_PRODUCT_UPDATES_LIST_ID: i64 = 9;
28+
/// Brevo list ID for "App Users - Marketing" (explicit opt-in only)
29+
const BREVO_MARKETING_LIST_ID: i64 = 10;
30+
2631
/// Service for syncing data to Brevo CRM
2732
pub struct BrevoService {
2833
pub client: Arc<BrevoClient>,
@@ -260,7 +265,9 @@ impl BrevoService {
260265
.with_role("owner")
261266
.with_signup_date(event.timestamp)
262267
.with_last_login_date(event.timestamp)
263-
.with_email_blacklisted(!marketing_opt_in);
268+
.with_email_blacklisted(false)
269+
.with_marketing_opt_in(marketing_opt_in)
270+
.with_marketing_opt_in_date(event.timestamp);
264271

265272
if let Some(use_case) = &use_case {
266273
contact_attrs = contact_attrs.with_use_case(use_case);
@@ -295,6 +302,25 @@ impl BrevoService {
295302
.sync_contact_and_company(email.as_ref(), contact_attrs, org_name, company_attrs)
296303
.await?;
297304

305+
// Add to "Product Updates" list (all signups)
306+
if let Err(e) = self
307+
.client
308+
.add_contacts_to_list(BREVO_PRODUCT_UPDATES_LIST_ID, vec![email.to_string()])
309+
.await
310+
{
311+
tracing::warn!(error = %e, "Failed to add contact to Product Updates list");
312+
}
313+
314+
// Add to "Marketing" list only if opted in
315+
if marketing_opt_in
316+
&& let Err(e) = self
317+
.client
318+
.add_contacts_to_list(BREVO_MARKETING_LIST_ID, vec![email.to_string()])
319+
.await
320+
{
321+
tracing::warn!(error = %e, "Failed to add contact to Marketing list");
322+
}
323+
298324
// Store the company ID on the organization
299325
if let Some(mut org) = self
300326
.organization_service

backend/src/server/brevo/types.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ pub struct ContactAttributes {
2020
/// Brevo built-in field (not a custom attribute) — controls email campaign eligibility.
2121
/// true = blocklisted from campaigns, false = can receive campaigns.
2222
pub email_blacklisted: Option<bool>,
23+
pub scanopy_marketing_opt_in: Option<bool>,
24+
pub scanopy_marketing_opt_in_date: Option<String>,
2325
}
2426

2527
impl ContactAttributes {
@@ -72,6 +74,16 @@ impl ContactAttributes {
7274
self
7375
}
7476

77+
pub fn with_marketing_opt_in(mut self, opt_in: bool) -> Self {
78+
self.scanopy_marketing_opt_in = Some(opt_in);
79+
self
80+
}
81+
82+
pub fn with_marketing_opt_in_date(mut self, date: DateTime<Utc>) -> Self {
83+
self.scanopy_marketing_opt_in_date = Some(date.to_rfc3339());
84+
self
85+
}
86+
7587
/// Convert to Brevo API attributes map (UPPERCASE keys)
7688
pub fn to_attributes(&self) -> HashMap<String, serde_json::Value> {
7789
let mut attrs = HashMap::new();
@@ -103,6 +115,15 @@ impl ContactAttributes {
103115
if let Some(v) = &self.scanopy_last_login_date {
104116
attrs.insert("SCANOPY_LAST_LOGIN_DATE".to_string(), serde_json::json!(v));
105117
}
118+
if let Some(v) = self.scanopy_marketing_opt_in {
119+
attrs.insert("SCANOPY_MARKETING_OPT_IN".to_string(), serde_json::json!(v));
120+
}
121+
if let Some(v) = &self.scanopy_marketing_opt_in_date {
122+
attrs.insert(
123+
"SCANOPY_MARKETING_OPT_IN_DATE".to_string(),
124+
serde_json::json!(v),
125+
);
126+
}
106127
attrs
107128
}
108129
}
@@ -503,3 +524,15 @@ pub struct EventIdentifiers {
503524
#[serde(skip_serializing_if = "Option::is_none")]
504525
pub email_id: Option<String>,
505526
}
527+
528+
/// POST /contacts/lists/{listId}/contacts/add
529+
#[derive(Debug, Clone, Serialize)]
530+
pub struct AddContactsToListRequest {
531+
pub emails: Vec<String>,
532+
}
533+
534+
/// POST /contacts/lists/{listId}/contacts/remove
535+
#[derive(Debug, Clone, Serialize)]
536+
pub struct RemoveContactsFromListRequest {
537+
pub emails: Vec<String>,
538+
}

messages/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"auth_signInToScanopy": "Sign in to Scanopy",
3636
"auth_signInWith": "Sign in with {provider}",
3737
"auth_signInWithEmail": "Sign In with Email",
38-
"auth_signUpForUpdates": "Sign up for product updates via email",
38+
"auth_signUpForUpdates": "Keep me posted on Scanopy partnerships, press, and community news",
3939
"auth_signingIn": "Signing in...",
4040
"auth_termsAndPrivacy": "I agree to the <a class='text-link' target='_blank' href='https://scanopy.net/terms'>terms</a> and <a target='_blank' class='text-link' href='https://scanopy.net/privacy'>privacy policy</a>",
4141
"auth_youreInvitedBody": "You have been invited to join {orgName} by {invitedBy}. Please sign in or register to continue.",

messages/fr.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"auth_signInToScanopy": "Se connecter à Scanopy",
3434
"auth_signInWith": "Se connecter avec {provider}",
3535
"auth_signInWithEmail": "S'enregistrer avec une adresse électronique",
36-
"auth_signUpForUpdates": "S'enregistrer pour des mises à jour produit par adresse électronique",
36+
"auth_signUpForUpdates": "",
3737
"auth_signingIn": "Enregistrement...",
3838
"auth_termsAndPrivacy": "J'accepte les termes <a class='text-link' target='_blank' href='https://scanopy.net/terms'></a>et<a target='_blank' class='text-link' href='https://scanopy.net/privacy'>la politique de gestion de la vie privée</a>",
3939
"auth_youreAllSetBodyMultiple": "Enregistrez vous pour finir l'installation. Vos services vont commencer à scanner \"{networkNames}\" et compléter votre carte réseau.",

ui/src/lib/features/auth/components/RegisterModal.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
email: '',
7474
password: '',
7575
confirmPassword: '',
76-
subscribed: true,
76+
subscribed: false,
7777
terms_accepted: false
7878
},
7979
onSubmit: async ({ value }) => {
@@ -99,7 +99,7 @@
9999
email: '',
100100
password: '',
101101
confirmPassword: '',
102-
subscribed: true,
102+
subscribed: false,
103103
terms_accepted: false
104104
});
105105
subStep = 'email';

0 commit comments

Comments
 (0)