Build an IP Intelligence Firewall for Newsletter Signups

Protect Your Newsletter with Advanced IP Filtering Techniques

·Matija Žiberna·
Build an IP Intelligence Firewall for Newsletter Signups

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

If you’ve followed my guides on building the Next.js newsletter form and wiring Brevo to Sanity, you know the stack that powers buildwithmatija.com. It was humming along nicely—more than 1,000 readers strong—until a traffic spike drew in a wave of Russian spam signups. reCAPTCHA v3 and Vercel bot protection slowed them down, but bad actors still slipped into Brevo. I needed a harder gate that fit the workflow I’d already shared.

Here’s the exact pipeline I added: capture browser hints, resolve and geolocate the client IP, run AbuseIPDB scoring, divert suspicious signups into a dedicated Sanity collection, and only sync clean contacts to Brevo. You can graft the same firewall onto your own setup in a weekend.

Step 1: Capture client hints alongside the form submission

The first step still happens in the browser. I want timezone, language, and the page URL so I can compare the visitor’s story with what the server learns later. Every signup surface reuses a small hook that hydrates hidden inputs.

// File: src/hooks/use-signup-metadata.ts
'use client'

import { useEffect, useState } from 'react'

type SignupMetadata = {
  timezone: string
  language: string
  signupUrl: string
}

const fallback: SignupMetadata = {
  timezone: '',
  language: '',
  signupUrl: '',
}

export function useSignupMetadata(): SignupMetadata {
  const [metadata, setMetadata] = useState<SignupMetadata>(fallback)

  useEffect(() => {
    try {
      setMetadata({
        timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
        language: navigator.language || '',
        signupUrl: window.location.href || '',
      })
    } catch (error) {
      console.warn('useSignupMetadata: failed to resolve metadata', error)
      setMetadata(fallback)
    }
  }, [])

  return metadata
}

This keeps the markup clean—each form adds hidden fields for timezone, language, and signupUrl. Those hints become useful once we cross-check them with the server-side lookups coming next.

Step 2: Resolve the signup IP and geo-enrich it

The server action already has access to the request headers, so grabbing an origin IP meant choosing the most reliable header Vercel exposes (x-forwarded-for) and falling back through the usual suspects. I then wrapped an ipinfo.io call in a helper so the logic stayed testable and reusable.

// File: src/lib/brevo/ip-geolocation.ts
import 'server-only'

interface GeoCoordinates {
  latitude?: number
  longitude?: number
}

export interface IpGeoResult extends GeoCoordinates {
  city?: string
  region?: string
  country?: string
  timezone?: string
  hostname?: string
  organization?: string
  postalCode?: string
}

export async function lookupGeoForIp(ipAddress: string | null | undefined): Promise<IpGeoResult | null> {
  if (!ipAddress) return null

  const token = process.env.IPINFO_TOKEN
  if (!token) {
    console.warn('lookupGeoForIp: IPINFO_TOKEN not configured')
    return null
  }

  const trimmed = ipAddress.trim()
  if (!trimmed || trimmed === '::1' || trimmed.startsWith('127.')) return null

  try {
    const response = await fetch(`https://ipinfo.io/${trimmed}/json?token=${token}`, {
      headers: { Accept: 'application/json' },
      next: { revalidate: 3600 },
    })

    if (!response.ok) {
      console.error('lookupGeoForIp: request failed', {
        status: response.status,
        statusText: response.statusText,
      })
      return null
    }

    const data: {
      city?: string
      region?: string
      country?: string
      timezone?: string
      loc?: string
      hostname?: string
      org?: string
      postal?: string
    } = await response.json()

    const result: IpGeoResult = {
      city: data.city || undefined,
      region: data.region || undefined,
      country: data.country || undefined,
      timezone: data.timezone || undefined,
      hostname: data.hostname || undefined,
      organization: data.org || undefined,
      postalCode: data.postal || undefined,
    }

    if (data.loc) {
      const [lat, lng] = data.loc.split(',').map((value) => parseFloat(value))
      if (!Number.isNaN(lat) && !Number.isNaN(lng)) {
        result.latitude = lat
        result.longitude = lng
      }
    }

    return result
  } catch (error) {
    console.error('lookupGeoForIp: unexpected error', error)
    return null
  }
}

ipinfo’s free tier gives city, region, country, timezone, hostname, ISP, and even coordinates. I cache responses for an hour to avoid hammering the endpoint when the same bot keeps hitting the form. The helper quietly skips loopback addresses and logs a warning if the token is missing so deployments don’t fail silently.

Step 3: Score the IP with AbuseIPDB

The second piece of the puzzle is reputation. AbuseIPDB has a generous free quota and the API is dead simple. I wrapped it in another helper that returns a normalized object.

// File: src/lib/brevo/abuseipdb.ts
import 'server-only'

type AbuseIpResponse = {
  ipAddress: string
  isPublic: boolean
  ipVersion: number
  isWhitelisted: boolean
  abuseConfidenceScore: number
  countryCode?: string
  usageType?: string
  isp?: string
  domain?: string
  hostnames?: string[]
  isTor: boolean
  totalReports: number
  numDistinctUsers: number
  lastReportedAt?: string
}

export interface AbuseLookupResult {
  ipAddress: string
  isPublic: boolean
  isWhitelisted: boolean
  abuseConfidenceScore: number
  usageType?: string
  isp?: string
  domain?: string
  hostnames?: string[]
  isTor: boolean
  totalReports: number
  numDistinctUsers: number
  lastReportedAt?: string
  countryCode?: string
}

export async function lookupAbuseForIp(ipAddress: string | undefined): Promise<AbuseLookupResult | null> {
  if (!ipAddress) return null

  const apiKey = process.env.ABUSEIP_DB_API_KEY
  if (!apiKey) {
    console.warn('lookupAbuseForIp: ABUSEIP_DB_API_KEY not configured')
    return null
  }

  try {
    const url = new URL('https://api.abuseipdb.com/api/v2/check')
    url.searchParams.set('ipAddress', ipAddress)

    const response = await fetch(url.toString(), {
      headers: {
        Key: apiKey,
        Accept: 'application/json',
      },
      next: { revalidate: 3600 },
    })

    if (!response.ok) {
      console.error('lookupAbuseForIp: request failed', {
        status: response.status,
        statusText: response.statusText,
      })
      return null
    }

    const payload: { data?: AbuseIpResponse } = await response.json()
    if (!payload.data) return null

    const data = payload.data
    return {
      ipAddress: data.ipAddress,
      isPublic: data.isPublic,
      isWhitelisted: data.isWhitelisted,
      abuseConfidenceScore: data.abuseConfidenceScore,
      usageType: data.usageType,
      isp: data.isp,
      domain: data.domain,
      hostnames: data.hostnames,
      isTor: data.isTor,
      totalReports: data.totalReports,
      numDistinctUsers: data.numDistinctUsers,
      lastReportedAt: data.lastReportedAt,
      countryCode: data.countryCode,
    }
  } catch (error) {
    console.error('lookupAbuseForIp: unexpected error', error)
    return null
  }
}

The abuseConfidenceScore ranges from 0–100. Anything over ~50 is sketchy, but the goal here is to use the score as a trigger—not just to log it.

Step 4: Codify the diversion threshold

Once I saw how conservative AbuseIPDB scoring is, I picked a threshold of 70 to separate obviously malicious hosts from occasional noise. I also decided that any lookup failure should be treated as suspicious and sent to manual review. That logic now lives in a small helper so the newsletter action and comment opt-in can share it.

// File: src/lib/newsletter/spam.ts
import type { AbuseLookupResult } from '@/lib/brevo/abuseipdb'

export const SPAM_SCORE_THRESHOLD = 70

export type SpamDiversionReason = 'abuse-score-high' | 'abuse-lookup-failed'

export interface SpamAssessment {
  action: 'allow' | 'divert'
  reason?: SpamDiversionReason
  abuseConfidenceScore?: number
}

type AbuseConfidenceCarrier = Pick<AbuseLookupResult, 'abuseConfidenceScore'> | null | undefined

export function assessSubscriptionRisk(
  abuseResult: AbuseConfidenceCarrier
): SpamAssessment {
  if (!abuseResult || typeof abuseResult.abuseConfidenceScore !== 'number') {
    return {
      action: 'divert',
      reason: 'abuse-lookup-failed',
    }
  }

  if (abuseResult.abuseConfidenceScore >= SPAM_SCORE_THRESHOLD) {
    return {
      action: 'divert',
      reason: 'abuse-score-high',
      abuseConfidenceScore: abuseResult.abuseConfidenceScore,
    }
  }

  return {
    action: 'allow',
    abuseConfidenceScore: abuseResult.abuseConfidenceScore,
  }
}

Keeping the decision in one place makes it easy to tune the threshold later and gives me a consistent reason code (abuse-score-high versus abuse-lookup-failed) when I inspect diverted submissions.

Step 5: Extend the server action to branch on spam risk

With the helper in place, the newsletter action normalizes the IP, enriches it, runs the assessment, and only syncs to Brevo when the signup is clean. Suspicious signups now land in a separate Sanity collection for manual review.

// File: src/actions/subscribeToNewsletter.ts
'use server'

import { headers } from 'next/headers'
import { createEmailSubscription, createSpamEmailSubscription } from '@/lib/sanity/sanity-server'
import { addContactToBrevoList } from '@/lib/brevo/brevo'
import { verifyRecaptcha } from '@/lib/captcha/recaptcha'
import { lookupGeoForIp } from '@/lib/brevo/ip-geolocation'
import { lookupAbuseForIp } from '@/lib/brevo/abuseipdb'
import { assessSubscriptionRisk } from '@/lib/newsletter/spam'
import type { EmailSubscriptionInput } from '@/lib/sanity/sanity-server'

function normalizeIpAddress(candidate: string | undefined | null): string | undefined {
  if (!candidate) return undefined

  const trimmed = candidate.trim()
  if (
    trimmed.length === 0 ||
    trimmed === '::1' ||
    trimmed === '0.0.0.0' ||
    trimmed.startsWith('127.') ||
    trimmed === '::ffff:127.0.0.1'
  ) {
    return undefined
  }

  return trimmed
}

async function subscribeToNewsletter(
  previousState: { error?: string; success?: boolean } | null,
  formData: FormData
) {
  try {
    const token = formData.get('recaptchaToken')
    const action = formData.get('recaptchaAction')

    const requestHeaders = await headers()
    const forwardedFor = requestHeaders.get('x-forwarded-for')
    const rawIpCandidate = forwardedFor?.split(',')[0]?.trim()
      || requestHeaders.get('x-real-ip')?.trim()
      || requestHeaders.get('cf-connecting-ip')?.trim()
      || requestHeaders.get('fastly-client-ip')?.trim()
      || requestHeaders.get('true-client-ip')?.trim()
      || requestHeaders.get('x-client-ip')?.trim()
      || requestHeaders.get('x-cluster-client-ip')?.trim()
    const ipAddress = normalizeIpAddress(rawIpCandidate)

    if (typeof token !== 'string' || token.length === 0) {
      return { error: 'Security verification failed. Please refresh and try again.' }
    }

    const verification = await verifyRecaptcha({
      token,
      expectedAction: typeof action === 'string' && action.length > 0 ? action : 'newsletter_subscription',
      remoteIp: ipAddress,
    })

    if (!verification.success) {
      return {
        error: verification.error || 'Security verification failed. Please try again later.',
      }
    }

    const email = formData.get('email')
    if (typeof email !== 'string' || email.length === 0) {
      return { error: 'Email is required' }
    }

    const name = formData.get('name')
    const source = formData.get('source')
    const timezone = formData.get('timezone')
    const language = formData.get('language')
    const signupUrl = formData.get('signupUrl')

    const nameStr = typeof name === 'string' && name.length > 0 ? name : undefined
    const sourceStr = source?.toString() || 'homepage'
    const timezoneStr = typeof timezone === 'string' && timezone.length > 0 ? timezone : undefined
    const languageStr = typeof language === 'string' && language.length > 0 ? language : undefined

    let signupUrlStr: string | undefined
    if (typeof signupUrl === 'string' && signupUrl.length > 0) {
      try {
        signupUrlStr = new URL(signupUrl).toString()
      } catch {
        signupUrlStr = undefined
      }
    }

    const geoResult = await lookupGeoForIp(ipAddress)
    if (ipAddress && !geoResult) {
      console.warn('Geo lookup did not return location data', { ipAddress })
    }

    const abuseResult = await lookupAbuseForIp(ipAddress)
    if (ipAddress && !abuseResult) {
      console.warn('Abuse IP lookup did not return data', { ipAddress })
    }

    const spamAssessment = assessSubscriptionRisk(abuseResult)
    const resolvedTimezone = timezoneStr || geoResult?.timezone
    const abuseLastReportedIso = abuseResult?.lastReportedAt
      ? new Date(abuseResult.lastReportedAt).toISOString()
      : undefined

    console.log('Newsletter subscription metadata resolved', {
      email,
      timezone: resolvedTimezone,
      language: languageStr,
      source: sourceStr,
      ipProvided: Boolean(ipAddress),
      geoEnriched: Boolean(geoResult),
      abuseScore: abuseResult?.abuseConfidenceScore,
      spamAction: spamAssessment.action,
      spamReason: spamAssessment.reason,
    })

    const baseSubscription: EmailSubscriptionInput = {
      email,
      name: nameStr,
      source: sourceStr,
      timezone: resolvedTimezone,
      language: languageStr,
      signupUrl: signupUrlStr,
      ipAddress,
      ipHostname: geoResult?.hostname,
      geoCity: geoResult?.city,
      geoRegion: geoResult?.region,
      geoCountry: geoResult?.country,
      geoLatitude: geoResult?.latitude,
      geoLongitude: geoResult?.longitude,
      geoPostalCode: geoResult?.postalCode,
      geoOrganization: geoResult?.organization,
      abuseConfidenceScore: abuseResult?.abuseConfidenceScore,
      abuseIsPublic: abuseResult?.isPublic,
      abuseIsWhitelisted: abuseResult?.isWhitelisted,
      abuseUsageType: abuseResult?.usageType,
      abuseIsp: abuseResult?.isp,
      abuseDomain: abuseResult?.domain,
      abuseHostnames: abuseResult?.hostnames,
      abuseIsTor: abuseResult?.isTor,
      abuseTotalReports: abuseResult?.totalReports,
      abuseNumDistinctUsers: abuseResult?.numDistinctUsers,
      abuseLastReportedAt: abuseLastReportedIso,
      abuseCountryCode: abuseResult?.countryCode,
    }

    if (spamAssessment.action === 'divert') {
      const spamResult = await createSpamEmailSubscription({
        ...baseSubscription,
        reason: spamAssessment.reason ?? 'abuse-lookup-failed',
      })

      if (!spamResult.success) {
        return { error: spamResult.error || 'Failed to record newsletter signup' }
      }

      return { success: true }
    }

    const sanityResult = await createEmailSubscription(baseSubscription)

    if (!sanityResult.success) {
      return { error: sanityResult.error || 'Failed to subscribe to newsletter' }
    }

    const brevoResult = await addContactToBrevoList({
      email,
      name: nameStr,
      source: sourceStr,
      timezone: resolvedTimezone,
      language: languageStr,
      signupUrl: signupUrlStr,
      ipAddress,
      geoCity: geoResult?.city,
      geoRegion: geoResult?.region,
      geoCountry: geoResult?.country,
      geoLatitude: geoResult?.latitude,
      geoLongitude: geoResult?.longitude,
    }, 7)

    if (!brevoResult.success) {
      console.error('Brevo sync failed, but Sanity creation succeeded:', brevoResult.error)
    }

    return { success: true }
  } catch (error) {
    console.error('Failed to subscribe to newsletter:', error)
    return { error: 'Failed to subscribe to newsletter. Please try again later.' }
  }
}

export default subscribeToNewsletter

The safe path hasn’t changed: clean contacts go into the emailSubscription collection and sync to Brevo immediately. The interesting bit is the early return—anything that failed the check is written to a spam holding area and never touches Brevo. The UI still shows a friendly “thanks” so attackers don’t get feedback.

Step 6: Persist diverted signups in their own Sanity collection

Spam that never surfaces is great, but I still want an audit trail so I can promote false positives. A second document type mirrors the primary subscription schema, adds a reason field, and gives reviewers room for notes.

// File: src/lib/sanity/schemaTypes/spamEmailSubscription.ts
import { defineField, defineType } from 'sanity'

export default defineType({
  name: 'spamEmailSubscription',
  title: 'Spam Email Subscription',
  type: 'document',
  fieldsets: [
    {
      name: 'metadata',
      title: 'Signup Metadata',
      options: { collapsible: true, collapsed: true },
    },
    {
      name: 'abuseCheck',
      title: 'IP Abuse Check',
      options: { collapsible: true, collapsed: true },
    },
    {
      name: 'review',
      title: 'Review',
      options: { collapsible: true, collapsed: false },
    },
  ],
  fields: [
    defineField({ name: 'email', title: 'Email', type: 'string', validation: (Rule) => Rule.required().email() }),
    defineField({ name: 'name', title: 'Name', type: 'string' }),
    defineField({ name: 'subscriptionDate', title: 'Subscription Date', type: 'datetime', initialValue: () => new Date().toISOString() }),
    defineField({ name: 'source', title: 'Source', type: 'string', description: 'Where the subscription originated from' }),
    defineField({ name: 'timezone', title: 'Timezone', type: 'string', fieldset: 'metadata' }),
    defineField({ name: 'language', title: 'Language', type: 'string', fieldset: 'metadata' }),
    defineField({ name: 'signupUrl', title: 'Signup URL', type: 'url', fieldset: 'metadata' }),
    defineField({ name: 'ipAddress', title: 'IP Address', type: 'string', fieldset: 'metadata' }),
    defineField({ name: 'ipHostname', title: 'IP Hostname', type: 'string', fieldset: 'metadata' }),
    defineField({ name: 'geoCity', title: 'City', type: 'string', fieldset: 'metadata' }),
    defineField({ name: 'geoRegion', title: 'Region', type: 'string', fieldset: 'metadata' }),
    defineField({ name: 'geoCountry', title: 'Country', type: 'string', fieldset: 'metadata' }),
    defineField({ name: 'geoLatitude', title: 'Latitude', type: 'number', fieldset: 'metadata' }),
    defineField({ name: 'geoLongitude', title: 'Longitude', type: 'number', fieldset: 'metadata' }),
    defineField({ name: 'geoPostalCode', title: 'Postal Code', type: 'string', fieldset: 'metadata' }),
    defineField({ name: 'geoOrganization', title: 'Organization', type: 'string', fieldset: 'metadata' }),
    defineField({ name: 'abuseConfidenceScore', title: 'Abuse Confidence Score', type: 'number', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseIsPublic', title: 'Is Public IP', type: 'boolean', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseIsWhitelisted', title: 'Is Whitelisted', type: 'boolean', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseUsageType', title: 'Usage Type', type: 'string', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseIsp', title: 'ISP', type: 'string', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseDomain', title: 'Domain', type: 'string', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseHostnames', title: 'Hostnames', type: 'array', of: [{ type: 'string' }], fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseIsTor', title: 'TOR Exit Node', type: 'boolean', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseTotalReports', title: 'Total Reports', type: 'number', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseNumDistinctUsers', title: 'Distinct Reporters', type: 'number', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseLastReportedAt', title: 'Last Reported At', type: 'datetime', fieldset: 'abuseCheck' }),
    defineField({ name: 'abuseCountryCode', title: 'Country Code', type: 'string', fieldset: 'abuseCheck' }),
    defineField({
      name: 'reason',
      title: 'Spam Reason',
      type: 'string',
      fieldset: 'review',
      options: {
        list: [
          { title: 'Abuse score above threshold', value: 'abuse-score-high' },
          { title: 'Abuse lookup failed', value: 'abuse-lookup-failed' },
        ],
      },
      validation: (Rule) => Rule.required(),
    }),
    defineField({ name: 'notes', title: 'Reviewer Notes', type: 'text', fieldset: 'review' }),
  ],
  preview: {
    select: {
      title: 'email',
      subtitle: 'subscriptionDate',
    },
  },
})

This lives alongside emailSubscription in src/lib/sanity/schemaTypes/index.ts, so Sanity Studio exposes both collections. When I spot a legitimate signup stuck in the spam queue, I can copy the record into the primary collection, note the decision, and trigger Brevo manually.

Persisting the documents only takes a thin wrapper in the Sanity server helpers:

// File: src/lib/sanity/sanity-server.ts
export interface SpamEmailSubscriptionInput extends EmailSubscriptionInput {
  reason: 'abuse-score-high' | 'abuse-lookup-failed'
  notes?: string
}

export async function createSpamEmailSubscription(data: SpamEmailSubscriptionInput) {
  try {
    const result = await sanityClient.create({
      _type: 'spamEmailSubscription',
      email: data.email,
      name: data.name,
      source: data.source || 'unknown',
      subscriptionDate: new Date().toISOString(),
      timezone: data.timezone,
      language: data.language,
      signupUrl: data.signupUrl,
      ipAddress: data.ipAddress,
      ipHostname: data.ipHostname,
      geoCity: data.geoCity,
      geoRegion: data.geoRegion,
      geoCountry: data.geoCountry,
      geoLatitude: data.geoLatitude,
      geoLongitude: data.geoLongitude,
      geoPostalCode: data.geoPostalCode,
      geoOrganization: data.geoOrganization,
      abuseConfidenceScore: data.abuseConfidenceScore,
      abuseIsPublic: data.abuseIsPublic,
      abuseIsWhitelisted: data.abuseIsWhitelisted,
      abuseUsageType: data.abuseUsageType,
      abuseIsp: data.abuseIsp,
      abuseDomain: data.abuseDomain,
      abuseHostnames: data.abuseHostnames,
      abuseIsTor: data.abuseIsTor,
      abuseTotalReports: data.abuseTotalReports,
      abuseNumDistinctUsers: data.abuseNumDistinctUsers,
      abuseLastReportedAt: data.abuseLastReportedAt,
      abuseCountryCode: data.abuseCountryCode,
      reason: data.reason,
      notes: data.notes,
    })

    return { success: true, data: result }
  } catch (error: any) {
    console.error('Error creating spam email subscription:', error)
    return { success: false, error: error.message || 'Failed to store spam subscription' }
  }
}

The input type extends the existing EmailSubscriptionInput, so callers can pass the same metadata regardless of the path.

Step 7: Map the attributes for Brevo (only when clean)

The clean path still feeds Brevo the attributes it expects. The difference now is that this mapper only executes after the spam gate, so Brevo never sees the quarantined contacts.

// File: src/lib/brevo/brevo.ts
import { ContactsApi, ContactsApiApiKeys } from '@getbrevo/brevo'

const brevoApiKey = process.env.BREVO_API_KEY
const contactsApi: ContactsApi | null = brevoApiKey ? new ContactsApi() : null

if (contactsApi && brevoApiKey) {
  contactsApi.setApiKey(ContactsApiApiKeys.apiKey, brevoApiKey)
} else {
  console.warn('BREVO_API_KEY environment variable is not configured; Brevo sync disabled.')
}

interface BrevoContact {
  email: string
  name?: string
  source?: string
  timezone?: string
  language?: string
  signupUrl?: string
  ipAddress?: string
  geoCity?: string
  geoRegion?: string
  geoCountry?: string
  geoLatitude?: number
  geoLongitude?: number
}

export async function addContactToBrevoList(contact: BrevoContact, listId: number = 7) {
  if (!contactsApi) {
    console.warn('Skipping Brevo sync because BREVO_API_KEY is missing.', {
      email: contact.email,
      listId,
    })

    return {
      success: true,
      data: { message: 'Brevo integration disabled' },
    }
  }

  try {
    const attributes: Record<string, string> = {}

    if (contact.name) {
      const nameParts = contact.name.trim().split(' ')
      const firstName = nameParts[0]
      const lastName = nameParts.slice(1).join(' ').trim()

      if (firstName) {
        attributes.FIRSTNAME = firstName
      }

      if (lastName) {
        attributes.LASTNAME = lastName
      }
    }

    if (contact.source) {
      attributes.SOURCE = contact.source
    }

    if (contact.timezone) {
      attributes.CONTACT_TIMEZONE = contact.timezone
    }

    if (contact.language) {
      attributes.LANGUAGE = contact.language
    }

    if (contact.signupUrl) {
      attributes.SIGNUP_PAGE = contact.signupUrl
    }

    if (contact.ipAddress) {
      attributes.SIGNUP_IP = contact.ipAddress
    }

    if (contact.geoCity) {
      attributes.CITY = contact.geoCity
    }

    if (contact.geoRegion) {
      attributes.REGION = contact.geoRegion
    }

    if (contact.geoCountry) {
      attributes.COUNTRY = contact.geoCountry
    }

    if (typeof contact.geoLatitude === 'number') {
      attributes.LOCATION_LAT = String(contact.geoLatitude)
    }

    if (typeof contact.geoLongitude === 'number') {
      attributes.LOCATION_LNG = String(contact.geoLongitude)
    }

    const brevoContact = {
      email: contact.email,
      attributes,
      listIds: [listId],
      updateEnabled: true // This handles existing contacts gracefully
    }

    const result = await contactsApi.createContact(brevoContact)
    return { success: true, data: result.body }
  } catch (error: any) {
    console.error('❌ Failed to add contact to Brevo:', {
      email: contact.email,
      error: error.message,
      status: error.response?.status,
      statusText: error.response?.statusText,
      response: error.response?.body || error.response?.data,
      fullError: error
    })

    if (error.response?.body?.code === 'duplicate_parameter') {
      console.log('Contact already exists in Brevo, this is okay')
      return { success: true, data: { message: 'Contact already exists' } }
    }

    if (error.response?.status === 401) {
      console.error('Brevo API authentication failed - check API key validity')
    }

    return { 
      success: false, 
      error: error.message || 'Failed to add contact to Brevo' 
    }
  }
}

This still matches Brevo’s attribute table exactly: timezone, language, signup URL, source, IP, city, region, country, and coordinates. Pairing that with the clean Sanity record keeps my audience list pristine and ready for nurture sequences.

As a bonus, the same spam gating now protects the comment opt-in checkbox. The comment action reuses assessSubscriptionRisk, so spammy commenters don’t slip into the newsletter either.

Wrapping up

When the audience crossed four digits, the spammer traffic followed. Instead of scrapping the stack, I layered in intelligence: browser hints, ipinfo geolocation, AbuseIPDB scoring, a shared spam assessment helper, and a Sanity spam queue that keeps Brevo spotless. If you follow the same steps, your newsletter pipeline can welcome legitimate readers while silently diverting the garbage.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.