diff --git a/backend/routers/backlink_outreach.py b/backend/routers/backlink_outreach.py index 1900f38b..2130c059 100644 --- a/backend/routers/backlink_outreach.py +++ b/backend/routers/backlink_outreach.py @@ -270,6 +270,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, ) ) diff --git a/backend/services/backlink_outreach_models.py b/backend/services/backlink_outreach_models.py index 8f7e0873..13c884dc 100644 --- a/backend/services/backlink_outreach_models.py +++ b/backend/services/backlink_outreach_models.py @@ -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 @@ -148,6 +148,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) @@ -157,6 +172,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") @@ -240,10 +264,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) diff --git a/backend/services/backlink_outreach_service.py b/backend/services/backlink_outreach_service.py index da8846cd..dcd649c2 100644 --- a/backend/services/backlink_outreach_service.py +++ b/backend/services/backlink_outreach_service.py @@ -144,19 +144,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") @@ -206,8 +257,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, @@ -216,10 +271,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) diff --git a/frontend/src/api/backlinkOutreachApi.ts b/frontend/src/api/backlinkOutreachApi.ts index 4e6f07ea..2d5dc0b3 100644 --- a/frontend/src/api/backlinkOutreachApi.ts +++ b/frontend/src/api/backlinkOutreachApi.ts @@ -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; } @@ -183,6 +202,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; } diff --git a/frontend/src/components/BacklinkOutreach/BacklinkOutreachDashboard.tsx b/frontend/src/components/BacklinkOutreach/BacklinkOutreachDashboard.tsx index 35f9d8d9..746687b8 100644 --- a/frontend/src/components/BacklinkOutreach/BacklinkOutreachDashboard.tsx +++ b/frontend/src/components/BacklinkOutreach/BacklinkOutreachDashboard.tsx @@ -116,6 +116,19 @@ const BacklinkOutreachDashboard: React.FC = () => { const [subjectSuggestions, setSubjectSuggestions] = useState([]); 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(''); @@ -391,10 +404,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 }) => (

{title}

@@ -893,6 +923,71 @@ const BacklinkOutreachDashboard: React.FC = () => { style={{ ...inputSx, fontFamily: 'monospace', fontSize: '13px', resize: 'vertical', lineHeight: 1.6 }} />
+ {/* Compliance metadata */} +
+

Send Compliance Metadata

+

Policy checks require unsubscribe, sender identity, legal basis, contact source, and region-aware consent/review details before a send can be approved.

+ +
+ setSenderName(e.target.value)} placeholder="Sender name" style={inputSx} /> + setSenderEmail(e.target.value)} placeholder="Sender email" style={inputSx} /> + setSenderOrganization(e.target.value)} placeholder="Organization / brand" style={inputSx} /> + setSenderAddress(e.target.value)} placeholder="Physical mailing address" style={inputSx} /> +
+ +
+ setUnsubscribeUrl(e.target.value)} placeholder="Unsubscribe URL" style={inputSx} /> + +
+ +
+ + setContactDiscoverySource(e.target.value)} placeholder="Contact discovery source (e.g. contact page URL)" style={inputSx} /> + + + + +
+ +
+ {complianceReady ? 'Compliance metadata is complete for policy validation.' : ( +
    + {complianceReasons.map((reason) =>
  • {reason}
  • )} +
+ )} +
+
+ {/* Personalize */}

Personalize for Lead

@@ -946,13 +1041,13 @@ const BacklinkOutreachDashboard: React.FC = () => {
{selectedCampaign && subject.trim() && body.trim() && ( -
-

- Ready to send this email to leads in {selectedCampaign.name}? +

+

+ {complianceReady ? <>Ready to send this email to leads in {selectedCampaign.name}. : <>Complete compliance metadata before sending to {selectedCampaign.name} leads.}

- -