{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreiga5kfozyjkuv4sewn5zky6dm4jxo7jmau52fs2uinhaahtsypa6i",
"uri": "at://did:plc:nueu5rkumgo3omtdzftnx2ff/app.bsky.feed.post/3me6f25hzxtf2"
},
"description": "I created a shopping list app which is in a very saturated niche. When I was\ncreating 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.\n\n\n\n\n\n\nI've tried mailto\n\nIt's a special hyperlink which opens a native email client, works almost everywhere:\n\nLinking.openURL(\n 'mailto:support@example.com?subject=Hello&body=Report%20App%20issue'\n);\n\nIt's my lazies",
"path": "/how-to-send-emails-with-nestjs-mailgun-and-recaptcha-enterprise/",
"publishedAt": "2026-02-06T07:29:27.000Z",
"site": "https://www.amarjanica.com",
"tags": [
"shopping list app",
"expo's mail composer",
"rate limiting either on NestJS level",
"implement it with firebase functions",
"https://github.com/amarjanica/firebase-expo-demo",
"https://youtu.be/udHdEo3KWuM",
"@google-cloud",
"@Catch",
"@Injectable",
"@Post",
"@Body"
],
"textContent": "I created a shopping list app which is in a very saturated niche. When I was\ncreating 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.\n\n### I've tried mailto\n\nIt's a special hyperlink which opens a native email client, works almost everywhere:\n\n\n Linking.openURL(\n 'mailto:support@example.com?subject=Hello&body=Report%20App%20issue'\n );\n\nIt'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.\n\n### I've tried FormSpree\n\nI 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.\n\nIn this case, FormSpree is a worse option than mailto. I didn't realize it until writing this article.\n\nWith 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!\n\nUser might send something like \"App doesn't work\".\n\nWhat version are you using?\n\nAre you on Android or iOS?\n\nBtw, Formspree does support additional form fields, but I might as well build my own contacting solution.\n\n## I've moved on to my own backend and an in-app contact form\n\nI'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.\n\n## Set Up reCAPTCHA Enterprise\n\nStart 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.\n\nreCaptcha risk score\n\n## Configure Mailgun Domain\n\nAdd 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.\n\nStore reCAPTCHA site keys, Mailgun sending key, domain, and sender email in your NestJS environment file.\n\n## NestJS: Create the reCAPTCHA Service\n\nInstall the `@google-cloud/recaptcha-enterprise` package.\n\nIn 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.\n\nImportant 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.\n\nCreate a service to verify tokens.\n\n\n async verifyToken({ token, platform, action }: { token: string; platform: string; action: string }): Promise<void> {\n const keyMap = {\n 'android': this.envConfigService.get('RECAPTCHA_ANDROID_KEY'),\n 'web': this.envConfigService.get('RECAPTCHA_WEB_KEY'),\n 'ios': this.envConfigService.get('RECAPTCHA_IOS_KEY'),\n };\n const siteKey = keyMap[platform];\n if (!siteKey) {\n throw new RecaptchaTokenInvalidException();\n }\n\n const projectId = this.envConfigService.get('GOOGLE_CLOUD_PROJECT');\n\n const [response] = await this.client.createAssessment({\n parent: `projects/${projectId}`,\n assessment: {\n event: {\n token,\n siteKey,\n expectedAction: action,\n },\n },\n });\n\n if (!response.tokenProperties?.valid) {\n throw new RecaptchaTokenInvalidException();\n }\n\n if (response.tokenProperties.action !== action) {\n throw new RecaptchaActionMismatchException();\n }\n\n if ((response.riskAnalysis?.score ?? 0) < this.riskThreshold) {\n throw new RecaptchaLowScoreException();\n }\n }\n\nThe 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.\n\n\n @Catch()\n export class ErrorsFilter implements ExceptionFilter {\n private readonly logger = new Logger(ErrorsFilter.name);\n\n catch(exception: unknown, host: ArgumentsHost) {\n const ctx = host.switchToHttp();\n const response = ctx.getResponse<Response>();\n\n //...\n\n if (exception instanceof RecaptchaException) {\n return response.status(HttpStatus.FORBIDDEN).json({\n message: exception.message,\n });\n }\n\n //...\n }\n }\n\n## NestJS: Create the Mailgun Service\n\nThis is my service that is in charge of communicating with Mailgun:\n\n\n @Injectable()\n export class MailgunService {\n private readonly client: ReturnType<Mailgun['client']>;\n\n constructor(private readonly config: EnvConfigService) {\n const mailgun = new Mailgun(formData);\n const region = this.config.get('MAILGUN_REGION');\n const url = region === 'EU' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net';\n\n this.client = mailgun.client({\n username: 'api',\n url,\n key: this.config.get('MAILGUN_SENDING_KEY'),\n });\n }\n\n async sendMail(data: SendEmailForm): Promise<MessagesSendResult> {\n const domain = this.config.get('MAILGUN_DOMAIN');\n const to = this.config.get('MAILGUN_TO');\n const replyTo = data.name ? `${data.name} <${data.from}>` : data.from;\n const text = `\n Message from: ${replyTo}\n ------------------------------\n ${data.message}\n `;\n\n try {\n return await this.client.messages.create(domain, {\n from: this.config.get('MAILGUN_FROM'),\n to,\n subject: `Feedback: ${data.subject}`,\n text,\n 'h:Reply-To': replyTo,\n });\n } catch (err) {\n throw new EmailSendFailedError(err instanceof Error ? err.message : undefined);\n }\n }\n }\n\nYou'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.\n\nSendEmailForm is actually a result of a zod schema parse:\n\n\n const SendEmailFormSchema = z.object({\n subject: z.string().max(255),\n message: z.string().max(5000),\n from: z.email(),\n name: z.string().optional(),\n });\n\nMy schema validator is provided as a pipe in the controller:\n\n\n @Post('feedback'\n async sendEmail(@Body(new ZodValidatorPipe(SendEmailFormSchemaWithToken)) data: SendEmailFormWithToken) {\n await this.recaptchaService.verifyToken({\n token: data.token,\n platform: data.platform,\n action: 'feedback',\n });\n\n const result = await this.mailgun.sendMail(data);\n\n return { success: result?.status === 200, status: result?.status };\n }\n\n`ZodValidatorPipe` checks that the client input satisfies the schema definition.\n\nRecaptcha 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.\n\n## Client: React Native Expo\n\nI use a simple contact form which is handled by react hook forms and zod validation.\n\n\n const {\n control,\n handleSubmit,\n reset,\n formState: { errors, isValid },\n } = useForm<SendEmailForm>({\n resolver: zodResolver(SendEmailFormSchema),\n mode: 'onChange',\n reValidateMode: 'onChange',\n });\n\nTo 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:\n\n\n <Controller\n control={control}\n name=\"name\"\n render={({ field: { onChange, onBlur, value } }) => (\n <>\n <TextInput\n label=\"Name\"\n value={value}\n onChangeText={onChange}\n onBlur={onBlur}\n autoCapitalize=\"words\"\n mode=\"outlined\"\n error={!!errors.name}\n />\n {!!errors.name && (\n <HelperText\n type=\"error\"\n visible={true}>\n {errors.name.message}\n </HelperText>\n )}\n </>\n )}\n />\n\nInput component needs to link value and events to the rhf controller. Helper text is there to show an error from the form.\n\nIf the form is valid, call to nestjs api can be made. Btw, use 10.0.2.2 for Android and localhost for iOS.\n\n\n const sendFeedback = async (data: SendEmailFormWithToken) => {\n setLoading(true);\n try {\n const token = await getRecaptchaToken('feedback');\n const sendResult = await sendEmail({\n ...data,\n platform: Platform.OS,\n token,\n });\n if (sendResult.data.success) {\n reset();\n Alert.alert('Success', 'Email sent successfully');\n } else {\n Alert.alert('Error', 'Failed to send email');\n }\n } catch (err) {\n console.error(err);\n Alert.alert('Error', err.message || 'Failed to send email');\n } finally {\n setLoading(false);\n }\n };\n\nClient generates the recaptcha token and calls the nestjs endpoint, which results in an Alert feedback.\n\n* * *\n\nIf you want a more lightweight tutorial on reCaptcha, I wrote how to implement it with firebase functions.\n\nGitHub: https://github.com/amarjanica/firebase-expo-demo\n\nYoutube: https://youtu.be/udHdEo3KWuM",
"title": "How to Send Emails with NestJS, Mailgun, and reCAPTCHA Enterprise",
"updatedAt": "2026-02-06T07:29:27.000Z"
}