External Publication
Visit Post

How to Send Emails with NestJS, Mailgun, and reCAPTCHA Enterprise

Ana's Dev Scribbles February 6, 2026
Source

I created a shopping list app which is in a very saturated niche. When I was creating it, I wasn't thinking about dominating the market. I created it for me and my husband. I'm glad that even with that saturation I have my users and I wanted to give them a way to report back to me.

I've tried mailto

It's a special hyperlink which opens a native email client, works almost everywhere:

Linking.openURL(
  'mailto:support@example.com?subject=Hello&body=Report%20App%20issue'
);

It's my laziest and lowest effort of all. I could have at least used expo's mail composer. I don't like leaving my app, and yet my laziness was stronger.

I've tried FormSpree

I have a static documentation website for my app. Support page has a Formspree form, which is free and convenient when you don't want to build a backend or don't want to use mailto.

In this case, FormSpree is a worse option than mailto. I didn't realize it until writing this article.

With first option you can at least collect device data (platform, app version, error logs). With a web form you rely on the user to provide you with enough data. No, he won't!

User might send something like "App doesn't work".

What version are you using?

Are you on Android or iOS?

Btw, Formspree does support additional form fields, but I might as well build my own contacting solution.

I've moved on to my own backend and an in-app contact form

I've moved to my own backend with an in-app contact form. It's a better UX since users don't have to leave the app just to send feedback. This tutorial breaks down a full-stack setup using NestJS to send emails through Mailgun, with reCAPTCHA Enterprise for bot protection. The client is built with React Native and uses React Hook Form and Zod for form validation.

Set Up reCAPTCHA Enterprise

Start by enabling reCAPTCHA Enterprise in your Google Cloud project. Create a key for each platform. For Android, provide your app's package name. In advanced options, you'll want to set up a risk score for testing purposes. iOS might need a device check private key.

reCaptcha risk score

Configure Mailgun Domain

Add your domain to Mailgun. If you're using Namecheap, automatic DNS setup often fails to add important DNS records like MX. You'll likely need to enter these manually. You'll also need your Mailgun sending key saved in your environment.

Store reCAPTCHA site keys, Mailgun sending key, domain, and sender email in your NestJS environment file.

NestJS: Create the reCAPTCHA Service

Install the @google-cloud/recaptcha-enterprise package.

In my demo environment I have the site key for recaptcha android site key, but add there for any other environments where you use it - ios, web.

Important thing is that a backend service that will be running NestJS has to have "Recaptcha Enterprise Agent" assigned. Without that recaptcha token checks won't work. One way to do it is have GOOGLE_APPLICATION_CREDENTIALS in environment, pointing to the service account which has access to the necessary role.

Create a service to verify tokens.

async verifyToken({ token, platform, action }: { token: string; platform: string; action: string }): Promise<void> {
    const keyMap = {
      'android': this.envConfigService.get('RECAPTCHA_ANDROID_KEY'),
      'web': this.envConfigService.get('RECAPTCHA_WEB_KEY'),
      'ios': this.envConfigService.get('RECAPTCHA_IOS_KEY'),
    };
    const siteKey = keyMap[platform];
    if (!siteKey) {
      throw new RecaptchaTokenInvalidException();
    }

    const projectId = this.envConfigService.get('GOOGLE_CLOUD_PROJECT');

    const [response] = await this.client.createAssessment({
      parent: `projects/${projectId}`,
      assessment: {
        event: {
          token,
          siteKey,
          expectedAction: action,
        },
      },
    });

    if (!response.tokenProperties?.valid) {
      throw new RecaptchaTokenInvalidException();
    }

    if (response.tokenProperties.action !== action) {
      throw new RecaptchaActionMismatchException();
    }

    if ((response.riskAnalysis?.score ?? 0) < this.riskThreshold) {
      throw new RecaptchaLowScoreException();
    }
  }

The function checks if the token is valid, action is expected and risk score passes the defined threshold. All of my custom exceptions share the same base class, so my errors are mapped in the global errors filter to appropriate status.

@Catch()
export class ErrorsFilter implements ExceptionFilter {
  private readonly logger = new Logger(ErrorsFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();

    //...

    if (exception instanceof RecaptchaException) {
      return response.status(HttpStatus.FORBIDDEN).json({
        message: exception.message,
      });
    }

    //...
  }
}

NestJS: Create the Mailgun Service

This is my service that is in charge of communicating with Mailgun:

@Injectable()
export class MailgunService {
  private readonly client: ReturnType<Mailgun['client']>;

  constructor(private readonly config: EnvConfigService) {
    const mailgun = new Mailgun(formData);
    const region = this.config.get('MAILGUN_REGION');
    const url = region === 'EU' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net';

    this.client = mailgun.client({
      username: 'api',
      url,
      key: this.config.get('MAILGUN_SENDING_KEY'),
    });
  }

  async sendMail(data: SendEmailForm): Promise<MessagesSendResult> {
    const domain = this.config.get('MAILGUN_DOMAIN');
    const to = this.config.get('MAILGUN_TO');
    const replyTo = data.name ? `${data.name} <${data.from}>` : data.from;
    const text = `
        Message from: ${replyTo}
        ------------------------------
        ${data.message}
        `;

    try {
      return await this.client.messages.create(domain, {
        from: this.config.get('MAILGUN_FROM'),
        to,
        subject: `Feedback: ${data.subject}`,
        text,
        'h:Reply-To': replyTo,
      });
    } catch (err) {
      throw new EmailSendFailedError(err instanceof Error ? err.message : undefined);
    }
  }
}

You'll need a Mailgun api sending key, your Mailgun domain, and a destination email address. Notice that the from address is not the user's email. Mailgun will reject unverified senders. Set from to an address on your verified sending domain, Put the user's email in the Reply-To header, so replies go to them.

SendEmailForm is actually a result of a zod schema parse:

const SendEmailFormSchema = z.object({
  subject: z.string().max(255),
  message: z.string().max(5000),
  from: z.email(),
  name: z.string().optional(),
});

My schema validator is provided as a pipe in the controller:

  @Post('feedback'
  async sendEmail(@Body(new ZodValidatorPipe(SendEmailFormSchemaWithToken)) data: SendEmailFormWithToken) {
    await this.recaptchaService.verifyToken({
      token: data.token,
      platform: data.platform,
      action: 'feedback',
    });

    const result = await this.mailgun.sendMail(data);

    return { success: result?.status === 200, status: result?.status };
  }

ZodValidatorPipe checks that the client input satisfies the schema definition.

Recaptcha token check protects Mailgun from any spam calls, but route is not protected from too many calls. You should implement rate limiting either on NestJS level or delegate it to upper layer (nginx, Cloudflare, WAF...). I'd delegate it, just looks cleaner to me like that.

Client: React Native Expo

I use a simple contact form which is handled by react hook forms and zod validation.

const {
  control,
  handleSubmit,
  reset,
  formState: { errors, isValid },
} = useForm<SendEmailForm>({
  resolver: zodResolver(SendEmailFormSchema),
  mode: 'onChange',
  reValidateMode: 'onChange',
});

To wire inputs to react hook forms, use Controlled components. They're not pretty, but I suppose a shared component might be extracted to hide this ugliness:

<Controller
    control={control}
    name="name"
    render={({ field: { onChange, onBlur, value } }) => (
      <>
        <TextInput
          label="Name"
          value={value}
          onChangeText={onChange}
          onBlur={onBlur}
          autoCapitalize="words"
          mode="outlined"
          error={!!errors.name}
        />
        {!!errors.name && (
          <HelperText
            type="error"
            visible={true}>
            {errors.name.message}
          </HelperText>
        )}
      </>
    )}
  />

Input component needs to link value and events to the rhf controller. Helper text is there to show an error from the form.

If the form is valid, call to nestjs api can be made. Btw, use 10.0.2.2 for Android and localhost for iOS.

const sendFeedback = async (data: SendEmailFormWithToken) => {
    setLoading(true);
    try {
      const token = await getRecaptchaToken('feedback');
      const sendResult = await sendEmail({
        ...data,
        platform: Platform.OS,
        token,
      });
      if (sendResult.data.success) {
        reset();
        Alert.alert('Success', 'Email sent successfully');
      } else {
        Alert.alert('Error', 'Failed to send email');
      }
    } catch (err) {
      console.error(err);
      Alert.alert('Error', err.message || 'Failed to send email');
    } finally {
      setLoading(false);
    }
  };

Client generates the recaptcha token and calls the nestjs endpoint, which results in an Alert feedback.


If you want a more lightweight tutorial on reCaptcha, I wrote how to implement it with firebase functions.

GitHub: https://github.com/amarjanica/firebase-expo-demo

Youtube: https://youtu.be/udHdEo3KWuM

Discussion in the ATmosphere

Loading comments...