Как сделать форму подписки на email с помощью Yandex Cloud Postbox и SmartCaptcha
Я уже полтора года пишу в этот standalone блог. До этого я писал на Medium. И вот там была классная фича, сделать
которую тут у меня все не доходили руки. Это форма подписки на email. Я хотел сделать так, чтобы читатели могли
подписаться
на новые статьи и получать на почту уведомления. Но я не хотел использовать Mailchimp или подобные сервисы. Я хотел
сделать все, используя Yandex Cloud. И вот наконец-то я это сделал. В этой статье я расскажу, как я это сделал.
Кстати, справа вы видите именно эту форму подписки. Если вы хотите подписаться на новые статьи, введите свой email и нажмите кнопку "Подписаться". А теперь давайте разберемся, как это работает.
Введение
Мой блог создан с помощью Docusaurus и размещен в Yandex Cloud Object Storage как статический сайт. Однако приведенное ниже решение может быть легко адаптировано для других веб-сайто на основе React, таких как Next.js и использовать другой в качестве сервера для обработки запросов что угодно, не обязательно Yandex Cloud Functions, которые я использую в этой статье.
Что нам понадобится
Это не значит, что нельзя сделать проще и что-то выкинуть, но мне показалось интересным заодно показать, как можно использовать различные сервисы Yandex Cloud вместе. Вот что нам понадобится:
- Форма подписки на email. Ее мы сделаем с помощью React.
- Yandex SmartCaptcha для защиты от спама.
- Yandex Managed Service for YDB для хранения email подписчиков.
- Yandex Cloud API Gateway для публикации API на нашем домене.
- Yandex Cloud Functions для обработки запросов.
- Yandex EventRouter для описания логики обработки событий.
- Yandex Cloud Postbox для отправки email.
- Yandex Cloud DataStreams в качестве шин событий.
Самое приятное, что оплата за все эти сервисы взымается только за потребленные ресурсы. Т.е. если у вас нет нагрузки, то и платить не придется.
Создание формы подписки на email
Для начала нам нужно установить зависимости:
npm install @yandex/smart-captcha
Затем создадим форму подписки на email:
import React, { useState } from 'react';
import BrowserOnly from '@docusaurus/BrowserOnly';
import { SmartCaptcha } from '@yandex/smart-captcha';
import styles from '@src/theme/BlogLayout/styles.module.css'
function SignUpForm() {
const [token, setToken] = useState(null);
const [email, setEmail] = useState('');
const [disabled, setDisabled] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState(null);
const onSubmit = async (event) => {
if (!token) {
return;
}
event.preventDefault();
setDisabled(true);
var data = {
email: email,
captchaToken: token
};
try {
const response = await fetch('https://blog.nikolaymatrosov.ru/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
setSubmitted(true);
} else {
const error = await response.json();
console.error(error);
setError(error.message);
}
} catch (e) {
console.error(e);
}
};
const buttonDisabled = disabled || !token || !email;
return (
<div id="signup-side" className={styles.signupForm}>
<BrowserOnly fallback={<div>Loading...</div>}>
{() => {
if (submitted) {
return <div>Спасибо за подписку! Вам придёт письмо для подтверждения на указанный email.</div>
}
if (error) {
return <div className={styles.error}>
{error}
</div>
}
return <form onSubmit={onSubmit}>
<h3>Подпишитесь на новые посты</h3>
<input
type="email"
value={email}
placeholder="Ваш email"
onChange={(evt) => setEmail(evt.target.value)}
className={styles.emailInput}
/>
<SmartCaptcha
sitekey="{YOUR_CLIENT_KEY}"
onSuccess={setToken}
language="ru"
/>
<input type="submit" value="Подписаться" disabled={buttonDisabled} className={styles.submitBtn}/>
</form>
}}
</BrowserOnly>
</div>
);
}
В принципе это простая форма с одним полем для ввода email и кнопкой "Подписаться". Но есть один нюанс. Мы дополнительно проверяем, что CAPTCHA вернула нам токен, с которым наш сервер сможет проверить, что запрос пришел от человека, а не от бота.
Вам нужно будет заменить YOUR_CLIENT_KEY
на ваш ключ, который вы получите при создании CAPTCHA в консоли Yandex Cloud.
Создавая CAPTCHA, укажите домен вашего сайта, чтобы CAPTCHA работала только на вашем сайте. Вы можете указать несколько доменов, это будет удобно при разработке. Вот как можно создать CAPTCHA через Terraform:
resource "yandex_smartcaptcha_captcha" "subscription" {
name = "${local.app_prefix}subscription-captcha"
complexity = "MEDIUM"
pre_check_type = "CHECKBOX"
challenge_type = "IMAGE_TEXT"
allowed_sites = [
"deploy.local",
"nikolaymatrosov.ru",
]
}
Вы можете задать параметры CAPTCHA, такие, как сложность, визуальный UI элемент и тип испытания. Подробнее об этом можно прочитать в документации.
Отправка формы
Вот так будет схема работы функции, которая будет обрабатывать запросы:
Итак, по порядку:
- Пользователь заполняет форму и нажимает кнопку "Подписаться". Браузер отправляет
POST
JSON запрос с данными формы{"email", "captchaToken"}
в API Gateway по пути/subscribe
. - API Gateway вызывает функцию, которая обрабатывает запрос.
- Функция проверяет берет параметр
captchaToken
из тела запроса и проверяет его в SmartCaptcha. - Если токен валиден, то функция продолжает выполнение, иначе возвращает ошибку.
- Функция сохраняет email в YDB. Перед этим мы выполняем пару проверок, что email уникален и валиден.
- Функция также сохраняет событие в YDB в отдельную таблицу
events
. Это нужно для того, чтобы развязать логику обработки запроса, сохранения его в базу, и отправки email. Таким образом, мы можем легко добавить новые обработчики событий. - Функция возвращает
200 Ok
в API Gateway - Который возвращает ответ пользователю.
import { generateId } from '../../ids'
import { createSubscription, getSubscriptionByEmail, insertEvent, listSubscriptionsByIp } from '../db'
import { Subscription, SubscriptionCreatedEvent } from '../../models'
import { Request, RequestHandler, Response } from 'express'
import { logger } from '../../logger'
import { YdbError } from 'ydb-sdk'
import { getIp } from './common'
const CAPTCHA_SECRET = process.env.CAPTCHA_SECRET || '';
const CAPTCHA_URL = 'https://smartcaptcha.yandexcloud.net/validate';
function validEmail(email: string): boolean {
const re = /^[\w\-.]+(\+[\w\-.]+)?@([\w-]+\.)+[\w-]{2,}$/
return re.test(email)
}
export const subscribe: RequestHandler = async (req: Request, res: Response) => {
const driver = req.apiGateway.context.driver;
const ip = getIp(req);
const email = req.body.email;
const token = req.body.captchaToken;
if (!validEmail(email)) {
res.status(400).json({
message: 'Invalid email'
});
return
}
if (!token) {
res.status(400).json({
message: 'Captcha token is required'
});
return
}
const captcha = await validateCaptcha(token, ip);
if (!captcha) {
res.status(400).json({
message: 'Captcha validation failed'
});
return
}
const newSub = newSubscription(ip, email)
try {
const result = await driver.queryClient.do({
fn: async (session) => {
await session.beginTransaction({ serializableReadWrite: {} });
const subscriptions = await getSubscriptionByEmail(session, newSub.email);
if (subscriptions.length > 0) {
await session.rollbackTransaction();
return {
message: 'Subscription already exists'
}
}
const fromIp = await listSubscriptionsByIp(session, newSub.ip);
if (fromIp.length > 2) {
await session.rollbackTransaction();
return {
message: 'Too many subscriptions from this IP'
}
}
await createSubscription(session, newSub);
await insertEvent(session, new SubscriptionCreatedEvent({
id: generateId(),
createdAt: new Date(),
subscriptionId: newSub.id,
payload: {
type: 'SubscriptionCreated',
subscriptionId: newSub.id,
email: newSub.email,
createdAt: newSub.createdAt,
headers: req.headers,
},
}));
await session.commitTransaction();
}
});
if (result) {
res.status(400).json(result);
return
}
res.json({
message: 'Subscription created'
});
return
} catch (e) {
if (e instanceof YdbError) {
logger.error('err', { stack: e.stack, message: e.message, cause: e.cause });
}
console.error(e);
res.status(500).json({
message: 'Internal Server Error'
});
return
}
}
function validateCaptcha(token: string, ip: string): Promise<boolean> {
return fetch(CAPTCHA_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: captchaRequest(token, ip)
})
.then(res => res.json() as unknown as CaptchaResponse)
.then(data => {
logger.info('Captcha response', { data });
return data.status === 'ok'
});
}
interface CaptchaResponse {
status: 'ok' | 'failed'
message: string
host: string
}
function captchaRequest(token: string, ip: string): string {
return `secret=${CAPTCHA_SECRET}&token=${token}&ip=${ip}`;
}
function newSubscription(ip: string, email: string): Subscription {
return new Subscription({
id: generateId(),
ip: ip,
email: email,
verified: false,
createdAt: new Date(),
verifiedAt: null,
})
}
Отправка проверочного письма
Для того реагировать на событие добавления email в базу, мы можем использовать CDC (Change Data Capture) в YDB.
CREATE TABLE events (
id Utf8,
created_at Timestamp,
subscription_id Utf8,
payload JSONDocument,
INDEX events_subscription_id GLOBAL SYNC ON (subscription_id),
PRIMARY KEY (id)
);
ALTER TABLE `events` ADD CHANGEFEED `events_feed` WITH (
FORMAT = 'JSON',
MODE = 'NEW_AND_OLD_IMAGES'
);
ALTER TOPIC `events/events_feed` ADD CONSUMER eventrouter WITH (important = true);
Теперь, когда в таблицу events
добавляется запись сообщение об этом отправляется в топик events/events_feed
. Далее
нам нужно реагировать на это событие. Для этого раньше в Yandex Cloud были доступны только триггеры. Но у них есть
недостаток, они будут срабатывать на все сообщения в топике. Если же нам нужно реагировать только на определенные
события, в нашем случае добавление создание новой подписки, то в случае с триггерами нам бы пришлось в обработчике
проверять, умеем ли мы обрабатывать это событие. А еще и платить за лишние срабатывания функции.
Теперь же мы можем использовать EventRouter. Он позволяет описать условия фильтрации событий при помощи jq выражения и вызвать нужные функции для обработки подошедших событий. Вот так можно описать создание EventRouter в Terraform:
resource "yandex_serverless_eventrouter_bus" "cdc_router" {
name = "${local.app_prefix}cdc-router"
folder_id = var.folder_id
}
resource "yandex_serverless_eventrouter_connector" "cdc_connector" {
name = "${local.app_prefix}cdc-connector"
bus_id = yandex_serverless_eventrouter_bus.cdc_router.id
depends_on = [
yandex_serverless_eventrouter_bus.cdc_router,
null_resource.migrations
]
yds {
consumer = "eventrouter"
database = yandex_ydb_database_serverless.db.database_path
stream_name = "events/events_feed"
service_account_id = yandex_iam_service_account.accounts["cdc-trigger"].id
}
}
resource "yandex_serverless_eventrouter_rule" "cdc_rule" {
name = "${local.app_prefix}cdc-rule"
bus_id = yandex_serverless_eventrouter_bus.cdc_router.id
depends_on = [
yandex_serverless_eventrouter_connector.cdc_connector
]
jq_filter = <<EOF
.newImage.payload.type == "SubscriptionCreated"
EOF
function {
function_id = yandex_function.worker_function.id
service_account_id = yandex_iam_service_account.accounts["cdc-trigger"].id
}
}
Теперь посмотрим, что будет делать функция обработчик событий от EventRouter:
- YDB пишет событие в CDC-топик, откуда его вычитывает EventRouter. Проверяет, что это событие добавления подписки.
- EventRouter вызывает функцию обработчик, которая отправляет email.
- Сохраняет событие в YDB в отдельной транзакции. Так что если отправка email завершится неудачно, мы можем повторить ее.
- Функция обработчик отправляет email через Postbox.
- Функция обновляет событие в YDB, отмечая, что оно обработано.
- Возвращает
200 Ok
в EventRouter.
Ниже приведен код функции обработчика событий:
import { Driver, TokenAuthService } from 'ydb-sdk'
import { logger } from '../logger'
import { SesClientFactory } from './ses-client'
import { SendEmailCommand, SendEmailRequest, SESv2Client } from '@aws-sdk/client-sesv2'
import { FunctionHandler, } from '@yandex-cloud/function-types/dist/src/functionHandler'
import { ISubscriptionCreatedEventPayload, Verification } from '../models'
import * as handlebars from 'handlebars'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { insertVerification, updateVerification } from './db'
type Event = Record<string, any>;
let endpoint = process.env.YDB_ENDPOINT;
let database = process.env.YDB_DATABASE;
async function send(driver: Driver, sesClient: SESv2Client, event: Event) {
const payload = extractPayload(event);
if (payload === null) {
return;
}
const verification = new Verification({
eventId: event.key[0],
email: payload.email,
subscriptionId: payload.subscriptionId,
createdAt: new Date(),
sentAt: null,
})
// Use two transactions to insert verification and send email
await driver.queryClient.do({
fn: async (session) => {
return insertVerification(session, verification)
}
})
// If sending email fails, we will not update the sent_at field
return driver.queryClient.do({
fn: async (session) => {
const input = composeEmailReq(payload.email, payload.subscriptionId);
const sendCmd = new SendEmailCommand(input);
try {
const sesMsg = await sesClient.send(sendCmd)
logger.info(`Email sent to ${payload.email}, messageId: ${sesMsg.MessageId}`);
const sentAt = new Date();
return updateVerification(session, verification.eventId, sentAt);
} catch (e) {
logger.error(`Error sending email to ${payload.email}`, { error: e });
}
}
})
}
function extractPayload(msg: Record<string, any>): ISubscriptionCreatedEventPayload | null {
if (msg.newImage === undefined || msg.newImage?.payload === undefined) {
return null;
}
if (msg.newImage?.payload.type !== 'SubscriptionCreated') {
return null;
}
return msg.newImage?.payload as ISubscriptionCreatedEventPayload;
}
export const handler: FunctionHandler<Event> = async (event, context: any) => {
logger.info('Received event', { event });
if (context.token === undefined || context.token.access_token === undefined) {
logger.error('Token is not provided');
return {
statusCode: 401,
body: 'Unauthorized'
}
}
const token = context.token?.access_token as string;
const authService = new TokenAuthService(token);
const driver = new Driver({ endpoint, database, authService, logger: logger });
const timeout = 3000;
if (!await driver.ready(timeout)) {
logger.error(`Driver has not become ready in ${timeout}ms!`);
return {
statusCode: 500,
body: 'Internal Server Error'
}
}
const sesClient = SesClientFactory(token);
await send(driver, sesClient, event);
return {
statusCode: 200,
body: 'OK'
}
}
const rawTemplate = fs.readFileSync(path.join(__dirname, '../templates/verification-email.html'), 'utf8');
const template = handlebars.compile(rawTemplate);
function composeEmailReq(email: string, subscriptionId: string): SendEmailRequest {
return {
FromEmailAddress: 'verify@blog.nikolaymatrosov.ru',
Destination: {
ToAddresses: [email]
},
Content: {
Simple: {
Subject: {
Data: 'Подтвердите подписку'
},
Body: {
Text: {
Data: `Подтвердите подписку на новые посты в блоге https://nikolaymatrosov.ru по ссылке: https://blog.nikolaymatrosov.ru/verify/${subscriptionId}`
},
Html: {
Data: template({ subscriptionId })
}
},
}
}
}
}
Обработчик ссылок из проверочного письма
Когда пользователь подписывается на email, мы отправляем ему письмо со ссылкой для подтверждения подписки. Пользователь должен перейти по этой ссылке, чтобы подписка вступила в силу. Для этого нам нужно обработать переход по ссылке.
- Пользователь переходит по ссылке вида
GET /verify/:subscriptionId
. - API Gateway вызывает функцию обработчик.
- Функция обработчик получает подписку из YDB.
- Если подписка существует, то база возвращает ее.
- Функция обновляет подписку, отмечая, что она подтверждена.
- YDB возвращает
Ok
. - Функция возвращает
301 Redirect
в API Gateway. - API Gateway перенаправляет пользователя на главную страницу.
import { Request, RequestHandler, Response } from 'express'
import { logger } from '../../logger'
import { SubscriptionVerifiedEvent } from '../../models'
import { generateId } from '../../ids'
import { getSubscription, insertEvent, verifySubscription } from '../db'
export const verify: RequestHandler = async (req: Request, res: Response) => {
const driver = req.apiGateway.context.driver;
const subscriptionId = req.params.subscriptionId;
try {
await driver.queryClient.do({
fn: async (session) => {
await session.beginTransaction({ serializableReadWrite: {} });
const subscriptions = await getSubscription(session, subscriptionId);
if (subscriptions.length == 0) {
await session.rollbackTransaction();
res.json({
message: 'Subscription not found'
});
return
}
const sub = subscriptions[0];
sub.verifiedAt = new Date();
await verifySubscription(session, sub);
await insertEvent(session, new SubscriptionVerifiedEvent({
id: generateId(),
createdAt: new Date(),
subscriptionId: sub.id,
payload: {
type: 'SubscriptionVerified',
subscriptionId: sub.id,
verifiedAt: sub.verifiedAt,
headers: req.headers,
},
}));
await session.commitTransaction();
}
});
} catch (e) {
logger.error('Error verifying subscription', { error: e });
res.status(500).json({
message: 'Internal Server Error'
});
return
}
res.redirect('https://nikolaymatrosov.ru/');
}
Заключение
Вот и все. Теперь у вас есть форма подписки на email, которая работает на Yandex Cloud. Теперь когда я напишу новую статью, я смогу отправить уведомление всем подписчикам. Надеюсь, что вам была полезна эта инструкция и вы подпишетесь, чтобы не пропустить новые статьи.
Полный код проекта доступен на GitHub.