Merge remote-tracking branch 'origin/codex/update-compliance-requirements-for-outreach-send'
This commit is contained in:
@@ -329,6 +329,15 @@ async def send_outreach(
|
||||
subject=subject,
|
||||
body=body,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
sender_identity=payload.sender_identity,
|
||||
legal_basis=payload.legal_basis,
|
||||
contact_discovery_source=payload.contact_discovery_source,
|
||||
recipient_region=payload.recipient_region,
|
||||
recipient_region_source=payload.recipient_region_source,
|
||||
consent_status=payload.consent_status,
|
||||
approved_by_human=payload.approved_by_human,
|
||||
unsubscribe_url=payload.unsubscribe_url,
|
||||
one_click_unsubscribe=payload.one_click_unsubscribe,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl, EmailStr
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class BacklinkKeywordInput(BaseModel):
|
||||
|
||||
|
||||
class OpportunityContactInfo(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
email: Optional[str] = None
|
||||
contact_page: Optional[HttpUrl] = None
|
||||
|
||||
|
||||
@@ -149,6 +149,21 @@ class OutreachStatusRecord(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
class SenderIdentity(BaseModel):
|
||||
name: str = Field(default="", description="Human sender name displayed to the recipient")
|
||||
email: str = Field(default="")
|
||||
organization: str = Field(default="", description="Organization or brand responsible for the outreach")
|
||||
physical_mailing_address: str = Field(default="", description="Postal address required for commercial outreach compliance")
|
||||
reply_to_email: Optional[str] = Field(None, description="Optional reply-to mailbox if different from sender email")
|
||||
|
||||
|
||||
class OneClickUnsubscribe(BaseModel):
|
||||
enabled: bool = Field(default=False)
|
||||
mailto: Optional[str] = Field(None, description="Mailbox for one-click unsubscribe requests")
|
||||
header_value: Optional[str] = Field(None, description="List-Unsubscribe / one-click unsubscribe header value")
|
||||
|
||||
|
||||
class SendOutreachRequest(BaseModel):
|
||||
lead_id: str = Field(..., min_length=1)
|
||||
campaign_id: str = Field(..., min_length=1)
|
||||
@@ -158,6 +173,15 @@ class SendOutreachRequest(BaseModel):
|
||||
subject: str = Field(..., min_length=1)
|
||||
body: str = Field(..., min_length=1)
|
||||
idempotency_key: str = Field(..., min_length=8)
|
||||
sender_identity: Optional[SenderIdentity] = None
|
||||
legal_basis: str = Field(default="")
|
||||
contact_discovery_source: str = Field(default="")
|
||||
recipient_region: str = Field(default="unknown")
|
||||
recipient_region_source: str = Field(default="user_attested", min_length=2)
|
||||
consent_status: str = Field(default="unknown", min_length=2)
|
||||
approved_by_human: bool = False
|
||||
unsubscribe_url: Optional[HttpUrl] = None
|
||||
one_click_unsubscribe: Optional[OneClickUnsubscribe] = None
|
||||
template_id: Optional[str] = Field(None, description="Optional template ID for personalization")
|
||||
template_variables: Optional[dict] = Field(None, description="Variable values for template personalization")
|
||||
|
||||
@@ -241,10 +265,15 @@ class PolicyValidationRequest(BaseModel):
|
||||
recipient_email: str = Field(..., min_length=1)
|
||||
recipient_domain: str
|
||||
recipient_region: str = Field(default="unknown")
|
||||
legal_basis: str = Field(..., min_length=2)
|
||||
recipient_region_source: str = Field(default="user_attested", min_length=2)
|
||||
legal_basis: str = Field(default="")
|
||||
contact_discovery_source: str = Field(default="")
|
||||
consent_status: str = Field(default="unknown", min_length=2)
|
||||
approved_by_human: bool = False
|
||||
unsubscribe_url: Optional[HttpUrl] = None
|
||||
sender_identity: str = Field(..., min_length=3)
|
||||
one_click_unsubscribe: Optional[OneClickUnsubscribe] = None
|
||||
sender_identity: Optional[SenderIdentity] = None
|
||||
sender_email: Optional[str] = Field(None, description="Transport sender email, if separate from identity")
|
||||
idempotency_key: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
|
||||
@@ -165,19 +165,70 @@ class BacklinkOutreachService:
|
||||
def _get_storage(self) -> BacklinkOutreachStorageService:
|
||||
return BacklinkOutreachStorageService()
|
||||
|
||||
CONSENT_REQUIRED_REGIONS = {"eu", "eea", "uk", "ca"}
|
||||
MANUAL_REVIEW_REGIONS = {"unknown", "br", "cn", "jp", "kr"}
|
||||
LOW_CONFIDENCE_REGION_SOURCES = {"tld_inference", "domain_tld", "inferred", "unknown"}
|
||||
VALID_LEGAL_BASES = {"legitimate_interest", "consent", "contract"}
|
||||
VALID_CONSENT_STATUSES = {"explicit", "implied", "not_required", "unknown"}
|
||||
|
||||
@staticmethod
|
||||
def _has_one_click_unsubscribe(payload: PolicyValidationRequest) -> bool:
|
||||
one_click = payload.one_click_unsubscribe
|
||||
if not one_click or not one_click.enabled:
|
||||
return False
|
||||
return bool(one_click.mailto or (one_click.header_value or "").strip())
|
||||
|
||||
def validate_send_policy(self, payload: PolicyValidationRequest) -> PolicyValidationResponse:
|
||||
reasons: List[str] = []
|
||||
storage = self._get_storage()
|
||||
|
||||
legal_basis = payload.legal_basis.strip().lower()
|
||||
recipient_region = payload.recipient_region.strip().lower()
|
||||
region_source = payload.recipient_region_source.strip().lower()
|
||||
consent_status = payload.consent_status.strip().lower()
|
||||
discovery_source = payload.contact_discovery_source.strip()
|
||||
sender = payload.sender_identity
|
||||
|
||||
if payload.workspace_id.startswith("new-") and not payload.approved_by_human:
|
||||
reasons.append("human_review_required_for_new_workspace")
|
||||
if payload.legal_basis.lower() not in {"legitimate_interest", "consent", "contract"}:
|
||||
reasons.append("invalid_legal_basis")
|
||||
if payload.recipient_region.lower() in {"eu", "eea"} and payload.legal_basis.lower() != "consent":
|
||||
reasons.append("region_requires_explicit_consent")
|
||||
if not legal_basis:
|
||||
reasons.append("legal_basis_required")
|
||||
elif legal_basis not in self.VALID_LEGAL_BASES:
|
||||
reasons.append("invalid_legal_basis_recorded")
|
||||
if not discovery_source:
|
||||
reasons.append("contact_discovery_source_required")
|
||||
if consent_status not in self.VALID_CONSENT_STATUSES:
|
||||
reasons.append("invalid_consent_status")
|
||||
|
||||
if len(payload.sender_identity.strip()) < 3:
|
||||
reasons.append("sender_identity_required")
|
||||
has_unsubscribe = bool(payload.unsubscribe_url) or self._has_one_click_unsubscribe(payload)
|
||||
if not has_unsubscribe:
|
||||
reasons.append("unsubscribe_url_or_one_click_unsubscribe_required")
|
||||
|
||||
if not sender:
|
||||
reasons.append("complete_sender_identity_required")
|
||||
else:
|
||||
sender_email = str(sender.email).strip()
|
||||
if not sender.name.strip():
|
||||
reasons.append("sender_name_required")
|
||||
if not sender_email:
|
||||
reasons.append("sender_email_required")
|
||||
elif not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", sender_email):
|
||||
reasons.append("sender_email_invalid")
|
||||
if not sender.organization.strip():
|
||||
reasons.append("sender_organization_required")
|
||||
if not sender.physical_mailing_address.strip():
|
||||
reasons.append("sender_physical_mailing_address_required")
|
||||
if payload.sender_email and sender_email.lower() != str(payload.sender_email).lower():
|
||||
reasons.append("sender_identity_email_mismatch")
|
||||
|
||||
if recipient_region in self.CONSENT_REQUIRED_REGIONS:
|
||||
if legal_basis != "consent" or consent_status != "explicit":
|
||||
reasons.append("region_requires_recorded_explicit_consent")
|
||||
elif recipient_region in self.MANUAL_REVIEW_REGIONS and not payload.approved_by_human:
|
||||
reasons.append("manual_review_required_for_recipient_region")
|
||||
|
||||
if region_source in self.LOW_CONFIDENCE_REGION_SOURCES and not payload.approved_by_human:
|
||||
reasons.append("manual_review_required_for_tld_or_unknown_region_source")
|
||||
|
||||
if storage.is_suppressed(str(payload.recipient_email), payload.recipient_domain, user_id=payload.user_id):
|
||||
reasons.append("recipient_suppressed")
|
||||
@@ -227,8 +278,12 @@ class BacklinkOutreachService:
|
||||
return SendOutreachResponse(attempt_id="", status="failed", policy_allowed=False, policy_reasons=["lead_not_found"])
|
||||
|
||||
domain = lead.get("domain", request.sender_email.split("@")[-1] if "@" in request.sender_email else "unknown")
|
||||
recipient_region = self._infer_region(domain)
|
||||
legal_basis = "consent" if recipient_region == "eu" else "legitimate_interest"
|
||||
recipient_region = (request.recipient_region or "unknown").strip().lower()
|
||||
if recipient_region == "unknown":
|
||||
recipient_region = self._infer_region(domain)
|
||||
region_source = "tld_inference" if recipient_region != "unknown" else request.recipient_region_source
|
||||
else:
|
||||
region_source = request.recipient_region_source
|
||||
|
||||
policy_req = PolicyValidationRequest(
|
||||
user_id=request.user_id,
|
||||
@@ -237,10 +292,15 @@ class BacklinkOutreachService:
|
||||
recipient_email=lead.get("email", ""),
|
||||
recipient_domain=domain,
|
||||
recipient_region=recipient_region,
|
||||
legal_basis=legal_basis,
|
||||
approved_by_human=False,
|
||||
unsubscribe_url=None,
|
||||
sender_identity=request.sender_email,
|
||||
recipient_region_source=region_source,
|
||||
legal_basis=request.legal_basis,
|
||||
contact_discovery_source=request.contact_discovery_source,
|
||||
consent_status=request.consent_status,
|
||||
approved_by_human=request.approved_by_human,
|
||||
unsubscribe_url=request.unsubscribe_url,
|
||||
one_click_unsubscribe=request.one_click_unsubscribe,
|
||||
sender_identity=request.sender_identity,
|
||||
sender_email=request.sender_email,
|
||||
idempotency_key=request.idempotency_key,
|
||||
)
|
||||
policy = self.validate_send_policy(policy_req)
|
||||
|
||||
@@ -76,6 +76,20 @@ export interface DeepDiscoveryResponse {
|
||||
|
||||
// -- Policy --
|
||||
|
||||
export interface SenderIdentity {
|
||||
name: string;
|
||||
email: string;
|
||||
organization: string;
|
||||
physical_mailing_address: string;
|
||||
reply_to_email?: string;
|
||||
}
|
||||
|
||||
export interface OneClickUnsubscribe {
|
||||
enabled: boolean;
|
||||
mailto?: string;
|
||||
header_value?: string;
|
||||
}
|
||||
|
||||
export interface BacklinkPolicyValidationRequest {
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
@@ -83,10 +97,15 @@ export interface BacklinkPolicyValidationRequest {
|
||||
recipient_email: string;
|
||||
recipient_domain: string;
|
||||
recipient_region: string;
|
||||
recipient_region_source: string;
|
||||
legal_basis: string;
|
||||
contact_discovery_source: string;
|
||||
consent_status: string;
|
||||
approved_by_human: boolean;
|
||||
unsubscribe_url?: string;
|
||||
sender_identity: string;
|
||||
one_click_unsubscribe?: OneClickUnsubscribe;
|
||||
sender_identity: SenderIdentity;
|
||||
sender_email?: string;
|
||||
idempotency_key: string;
|
||||
}
|
||||
|
||||
@@ -184,6 +203,15 @@ export interface SendOutreachRequest {
|
||||
subject: string;
|
||||
body: string;
|
||||
idempotency_key: string;
|
||||
sender_identity: SenderIdentity;
|
||||
legal_basis: string;
|
||||
contact_discovery_source: string;
|
||||
recipient_region: string;
|
||||
recipient_region_source: string;
|
||||
consent_status: string;
|
||||
approved_by_human: boolean;
|
||||
unsubscribe_url?: string;
|
||||
one_click_unsubscribe?: OneClickUnsubscribe;
|
||||
template_id?: string;
|
||||
template_variables?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,19 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
||||
const [subjectSuggestions, setSubjectSuggestions] = useState<string[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const [senderName, setSenderName] = useState('');
|
||||
const [senderEmail, setSenderEmail] = useState('');
|
||||
const [senderOrganization, setSenderOrganization] = useState('');
|
||||
const [senderAddress, setSenderAddress] = useState('');
|
||||
const [unsubscribeUrl, setUnsubscribeUrl] = useState('');
|
||||
const [oneClickUnsubscribe, setOneClickUnsubscribe] = useState(false);
|
||||
const [legalBasis, setLegalBasis] = useState('legitimate_interest');
|
||||
const [contactDiscoverySource, setContactDiscoverySource] = useState('');
|
||||
const [recipientRegion, setRecipientRegion] = useState('unknown');
|
||||
const [recipientRegionSource, setRecipientRegionSource] = useState('user_attested');
|
||||
const [consentStatus, setConsentStatus] = useState('unknown');
|
||||
const [approvedByHuman, setApprovedByHuman] = useState(false);
|
||||
|
||||
const [leadName, setLeadName] = useState('');
|
||||
const [leadSite, setLeadSite] = useState('');
|
||||
const [leadContentTopic, setLeadContentTopic] = useState('');
|
||||
@@ -398,10 +411,27 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
||||
{ key: 'campaigns', label: 'Campaigns', desc: 'Create and manage outreach campaigns' },
|
||||
{ key: 'discover', label: 'Discover', desc: 'AI-powered search for guest post opportunities' },
|
||||
{ key: 'leads', label: 'Leads', desc: 'Track leads, send outreach, and manage replies' },
|
||||
{ key: 'composer', label: 'Composer', desc: 'AI email composer with smart suggestions' },
|
||||
{ key: 'composer', label: 'Composer', desc: 'AI email composer with compliance metadata' },
|
||||
{ key: 'analytics', label: 'Analytics', desc: 'Campaign performance metrics and exports' },
|
||||
];
|
||||
|
||||
|
||||
const complianceReasons = [
|
||||
!unsubscribeUrl.trim() && !oneClickUnsubscribe ? 'Add an unsubscribe URL or enable one-click unsubscribe.' : '',
|
||||
!senderName.trim() ? 'Add the sender name.' : '',
|
||||
!senderEmail.trim() ? 'Add the sender email.' : '',
|
||||
!senderOrganization.trim() ? 'Add the sender organization.' : '',
|
||||
!senderAddress.trim() ? 'Add a physical mailing address.' : '',
|
||||
!legalBasis.trim() ? 'Record the legal basis.' : '',
|
||||
!contactDiscoverySource.trim() ? 'Record where the contact was discovered.' : '',
|
||||
recipientRegion === 'unknown' && !approvedByHuman ? 'Unknown recipient region requires manual review.' : '',
|
||||
recipientRegionSource === 'tld_inference' && !approvedByHuman ? 'TLD-only region inference requires manual review.' : '',
|
||||
['eu', 'eea', 'uk', 'ca'].includes(recipientRegion) && (legalBasis !== 'consent' || consentStatus !== 'explicit')
|
||||
? 'Selected recipient region requires recorded explicit consent.' : '',
|
||||
].filter(Boolean);
|
||||
|
||||
const complianceReady = complianceReasons.length === 0;
|
||||
|
||||
const SectionHeader: React.FC<{ title: string; subtitle: string }> = ({ title, subtitle }) => (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: 0, background: GRADIENT_PRIMARY, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', fontSize: '18px' }}>{title}</h3>
|
||||
@@ -900,6 +930,71 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
||||
style={{ ...inputSx, fontFamily: 'monospace', fontSize: '13px', resize: 'vertical', lineHeight: 1.6 }} />
|
||||
</div>
|
||||
|
||||
{/* Compliance metadata */}
|
||||
<div style={{ marginTop: '20px', padding: '16px', borderRadius: '10px', background: complianceReady ? 'rgba(67,233,123,0.08)' : 'rgba(245,87,108,0.08)', border: `1px solid ${complianceReady ? 'rgba(67,233,123,0.22)' : 'rgba(245,87,108,0.22)'}` }}>
|
||||
<h4 style={{ margin: '0 0 4px', color: '#fff', fontSize: '14px' }}>Send Compliance Metadata</h4>
|
||||
<p style={{ margin: '0 0 12px', color: 'rgba(255,255,255,0.45)', fontSize: '12px' }}>Policy checks require unsubscribe, sender identity, legal basis, contact source, and region-aware consent/review details before a send can be approved.</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
|
||||
<input type="text" value={senderName} onChange={(e) => setSenderName(e.target.value)} placeholder="Sender name" style={inputSx} />
|
||||
<input type="email" value={senderEmail} onChange={(e) => setSenderEmail(e.target.value)} placeholder="Sender email" style={inputSx} />
|
||||
<input type="text" value={senderOrganization} onChange={(e) => setSenderOrganization(e.target.value)} placeholder="Organization / brand" style={inputSx} />
|
||||
<input type="text" value={senderAddress} onChange={(e) => setSenderAddress(e.target.value)} placeholder="Physical mailing address" style={inputSx} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
|
||||
<input type="url" value={unsubscribeUrl} onChange={(e) => setUnsubscribeUrl(e.target.value)} placeholder="Unsubscribe URL" style={inputSx} />
|
||||
<label style={{ ...inputSx, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={oneClickUnsubscribe} onChange={(e) => setOneClickUnsubscribe(e.target.checked)} />
|
||||
One-click unsubscribe available
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
|
||||
<select value={legalBasis} onChange={(e) => setLegalBasis(e.target.value)} style={selectSx}>
|
||||
<option value="legitimate_interest">Legitimate interest</option>
|
||||
<option value="consent">Consent</option>
|
||||
<option value="contract">Contract</option>
|
||||
</select>
|
||||
<input type="text" value={contactDiscoverySource} onChange={(e) => setContactDiscoverySource(e.target.value)} placeholder="Contact discovery source (e.g. contact page URL)" style={inputSx} />
|
||||
<select value={recipientRegion} onChange={(e) => setRecipientRegion(e.target.value)} style={selectSx}>
|
||||
<option value="unknown">Recipient region unknown</option>
|
||||
<option value="us">United States</option>
|
||||
<option value="eu">EU / EEA</option>
|
||||
<option value="uk">United Kingdom</option>
|
||||
<option value="ca">Canada</option>
|
||||
<option value="au">Australia</option>
|
||||
<option value="br">Brazil</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select value={recipientRegionSource} onChange={(e) => setRecipientRegionSource(e.target.value)} style={selectSx}>
|
||||
<option value="user_attested">Region user-attested</option>
|
||||
<option value="crm_record">Region from CRM/contact record</option>
|
||||
<option value="billing_or_profile">Region from profile/billing data</option>
|
||||
<option value="tld_inference">Region inferred from TLD only</option>
|
||||
<option value="unknown">Region source unknown</option>
|
||||
</select>
|
||||
<select value={consentStatus} onChange={(e) => setConsentStatus(e.target.value)} style={selectSx}>
|
||||
<option value="unknown">Consent status unknown</option>
|
||||
<option value="explicit">Explicit consent recorded</option>
|
||||
<option value="implied">Implied consent / soft opt-in</option>
|
||||
<option value="not_required">Not required for selected basis</option>
|
||||
</select>
|
||||
<label style={{ ...inputSx, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={approvedByHuman} onChange={(e) => setApprovedByHuman(e.target.checked)} />
|
||||
Manual review approved
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '10px 12px', borderRadius: '8px', background: complianceReady ? 'rgba(67,233,123,0.12)' : 'rgba(245,87,108,0.12)', color: complianceReady ? '#43e97b' : '#f5576c', fontSize: '12px' }}>
|
||||
{complianceReady ? 'Compliance metadata is complete for policy validation.' : (
|
||||
<ul style={{ margin: 0, paddingLeft: '18px' }}>
|
||||
{complianceReasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personalize */}
|
||||
<div style={{ marginTop: '24px', padding: '16px', borderRadius: '10px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<h4 style={{ margin: '0 0 4px', color: '#fff', fontSize: '14px' }}>Personalize for Lead</h4>
|
||||
@@ -953,13 +1048,13 @@ const BacklinkOutreachDashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{selectedCampaign && subject.trim() && body.trim() && (
|
||||
<div style={{ marginTop: '16px', padding: '14px', borderRadius: '10px', background: 'rgba(67,233,123,0.1)', border: '1px solid rgba(67,233,123,0.2)' }}>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#43e97b' }}>
|
||||
Ready to send this email to leads in <strong>{selectedCampaign.name}</strong>?
|
||||
<div style={{ marginTop: '16px', padding: '14px', borderRadius: '10px', background: complianceReady ? 'rgba(67,233,123,0.1)' : 'rgba(245,87,108,0.1)', border: `1px solid ${complianceReady ? 'rgba(67,233,123,0.2)' : 'rgba(245,87,108,0.2)'}` }}>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '13px', color: complianceReady ? '#43e97b' : '#f5576c' }}>
|
||||
{complianceReady ? <>Ready to send this email to leads in <strong>{selectedCampaign.name}</strong>.</> : <>Complete compliance metadata before sending to <strong>{selectedCampaign.name}</strong> leads.</>}
|
||||
</p>
|
||||
<TooltipWrap text="Go to the Leads tab to select recipients and send">
|
||||
<button onClick={() => setActiveTab('leads')}
|
||||
style={{ ...btnBase, padding: '8px 20px', background: GRADIENT_SUCCESS, color: '#1a1a2e', fontSize: '13px' }}>
|
||||
<TooltipWrap text={complianceReady ? 'Go to the Leads tab to select recipients and send' : 'Policy validation will block sends until all listed compliance fields are complete'}>
|
||||
<button onClick={() => setActiveTab('leads')} disabled={!complianceReady}
|
||||
style={{ ...btnBase, padding: '8px 20px', background: GRADIENT_SUCCESS, color: '#1a1a2e', fontSize: '13px', opacity: complianceReady ? 1 : 0.5 }}>
|
||||
Go to Campaign Leads
|
||||
</button>
|
||||
</TooltipWrap>
|
||||
|
||||
Reference in New Issue
Block a user