Документация / Nuxt

Отправка email в Nuxt через SMTP

В Nuxt нет собственного почтового слоя, поэтому отправка письма — это работа Nitro: серверный роут вызывает SMTP-клиент, а страница просто отправляет на него запрос. Ниже — подключение Nodemailer к fmailer для Nuxt 3 и Nuxt 4: доступы в runtimeConfig, транспорт в серверных утилитах, шаблоны Vue Email и пресеты деплоя, на которых SMTP не работает в принципе.

Логин и пароль можно получить на странице управления токенами домена.

Параметры подключения

ПараметрЗначение
SMTP-хостsmtp.fmailer.ru
Порт587
ШифрованиеSTARTTLS (соединение переводится в TLS до авторизации)
ЛогинЛогин SMTP-токена вашего домена
ПарольПароль этого токена — показывается один раз, при создании
Каждый режим доступен и на высоком порту: 8465 (SSL/TLS), 8587 (STARTTLS) и 8025 (без шифрования). Многие хостинги и облака блокируют исходящие 25, 465 и 587 — если соединение отваливается по таймауту, используйте соответствующий высокий порт

Установка

bash
npm install nodemailer
npm install -D @types/nodemailer

Доступы в runtimeConfig

Ключи верхнего уровня runtimeConfig доступны только на сервере, и каждый при старте перекрывается переменной окружения с соответствующим именем NUXT_: runtimeConfig.smtp.pass берётся из NUXT_SMTP_PASS, и обращаться к process.env не нужно:

nuxt.config.tsjavascript
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Приватная часть — только сервер. Ничего отсюда не попадает в браузер.
    smtp: {
      user: "",   // подставится из NUXT_SMTP_USER
      pass: "",   // подставится из NUXT_SMTP_PASS
      from: "",   // подставится из NUXT_SMTP_FROM
    },
    public: {
      // Почтовые доступы сюда класть нельзя: этот объект попадает в HTML.
    },
  },
});
.envproperties
NUXT_SMTP_USER=<логин токена>
NUXT_SMTP_PASS=<пароль токена>
NUXT_SMTP_FROM="Магазин <noreply@mail.example.ru>"
Всё, что лежит в public, сериализуется в HTML каждой страницы и видно через «просмотр кода». Утёкший так SMTP-токен позволяет кому угодно отправлять письма от вашего домена. Приватная часть runtimeConfig сервер не покидает.

Транспорт в серверных утилитах

Всё, что лежит в server/utils/, автоимпортируется в серверном коде, поэтому транспорт создаётся один раз, а не пересобирается на каждый запрос:

server/utils/mailer.tsjavascript
// server/utils/mailer.ts — автоимпортируется в любом серверном роуте
import nodemailer, { type Transporter } from "nodemailer";

let transporter: Transporter | undefined;

// Один транспорт на процесс, а не на запрос: каждый новый транспорт — это
// новое TCP-соединение и новое TLS-рукопожатие.
export function useMailer(): Transporter {
  if (!transporter) {
    const { smtp } = useRuntimeConfig();

    transporter = nodemailer.createTransport({
      host: "smtp.fmailer.ru",
      port: 587,
      secure: false,        // на 587 поднимается STARTTLS
      requireTLS: true,
      auth: {
        user: smtp.user,
        pass: smtp.pass,    // "pass", а не "password"
      },
      pool: true,
      maxConnections: 3,
    });
  }

  return transporter;
}

Отправка из серверного роута

server/api/contact.post.tsjavascript
// server/api/contact.post.ts
export default defineEventHandler(async (event) => {
  const { email, message } = await readBody<{ email: string; message: string }>(event);

  if (!email || !message) {
    throw createError({ statusCode: 400, statusMessage: "email и message обязательны" });
  }

  const { smtp } = useRuntimeConfig(event);

  try {
    const info = await useMailer().sendMail({
      from: smtp.from,
      to: "sales@mail.example.ru",
      replyTo: email,          // From остаётся вашим, ответ уйдёт посетителю
      subject: "Заявка с сайта",
      text: message,
    });

    return { ok: true, messageId: info.messageId };
  } catch (error) {
    console.error("Письмо не отправлено", error);
    throw createError({ statusCode: 502, statusMessage: "Не удалось отправить сообщение" });
  }
});

Обратите внимание на replyTo: адрес в from остаётся на подтверждённом домене — именно для него опубликованы DKIM и SPF, — а ответ уйдёт посетителю. Подстановка его адреса в from и есть причина, по которой письма с форм не проходят DMARC.

Вызов со страницы

Форма отправляет запрос через $fetch, а не useFetch: это действие по сабмиту, а не данные, которые нужны странице для отрисовки:

pages/contact.vuejavascript
<!-- pages/contact.vue -->
<script setup lang="ts">
const form = reactive({ email: "", message: "" });
const pending = ref(false);
const sent = ref(false);

async function submit() {
  pending.value = true;
  try {
    await $fetch("/api/contact", { method: "POST", body: form });
    sent.value = true;
  } finally {
    pending.value = false;
  }
}
</script>

<template>
  <form v-if="!sent" @submit.prevent="submit">
    <input v-model="form.email" type="email" required >
    <textarea v-model="form.message" required />
    <button type="submit" :disabled="pending">Отправить</button>
  </form>
  <p v-else>Спасибо, мы свяжемся с вами.</p>
</template>
Импорт из компонента, композабла или плагина тянет node:net и node:dns в клиентский бандл: Vite сообщает, что встроенные модули Node вынесены наружу ради совместимости с браузером, и вызов падает. Каталог server/ — единственное место, содержимое которого Nuxt не отдаёт клиенту.

HTML-письма через Vue Email

Верстать табличный HTML для писем вручную мучительно. Vue Email позволяет собрать письмо Vue-компонентом и отрисовать его в строку на сервере — та же модель компонентов, что и в остальном приложении:

emails/OrderConfirmed.vuejavascript
<!-- emails/OrderConfirmed.vue -->
<script setup lang="ts">
import { Html, Head, Body, Container, Heading, Text } from "@vue-email/components";

defineProps<{ orderId: number }>();
</script>

<template>
  <Html>
    <Head />
    <Body>
      <Container>
        <Heading>Заказ №{{ orderId }} подтверждён</Heading>
        <Text>Отправим завтра.</Text>
      </Container>
    </Body>
  </Html>
</template>
javascript
// server/api/orders.post.ts
import { render } from "@vue-email/render";
import OrderConfirmed from "~~/emails/OrderConfirmed.vue";

const props = { orderId: order.id };

await useMailer().sendMail({
  from: smtp.from,
  to: order.email,
  subject: `Заказ №${order.id} подтверждён`,
  html: await render(OrderConfirmed, props),
  text: await render(OrderConfirmed, props, { plainText: true }),
});

Отрисовка одного и того же компонента дважды — в HTML и с plainText — даёт текстовую часть без второй копии текста письма. Отправлять нужно обе.

Не задерживайте ответ

SMTP-рукопожатие вместе с отправкой занимает сотни миллисекунд, и запрос оформления заказа не должен их ждать:

server/api/orders.post.tsjavascript
// server/api/orders.post.ts
export default defineEventHandler(async (event) => {
  const order = await createOrder(event);

  // Ответ уходит сразу; Nitro не завершит обработчик, пока промис не разрешится.
  event.waitUntil(
    sendOrderEmail(order).catch((error) => console.error("Письмо о заказе не ушло", error)),
  );

  return order;
});

Этого достаточно для писем, потеря которых некритична. Если письмо должно пережить перезапуск или повториться при сбое, складывайте задачу в очередь или таблицу и отправляйте из задачи Nitro либо отдельного воркера.

Пресеты Nitro cloudflare, vercel-edge и netlify-edge работают там, где нет TCP-сокетов, поэтому Nodemailer на них не запустится. Деплойте стандартный node-server либо отправляйте через REST API — это обычный $fetch по HTTPS, он работает на любом пресете.

Разбор ошибок

ОшибкаПричина и решение
Module "net" has been externalized for browser compatibility Nodemailer импортирован вне server/. Перенесите его в server/utils/ и вызывайте серверный роут.
EAUTH — Invalid login: 535password: вместо pass: либо неверные доступы.
smtp.user — пустая строка на проде Переменная есть только в .env, а он не читается из собранного приложения. Задайте NUXT_SMTP_USER в окружении деплоя.
Доступы видны в исходном коде страницы Они объявлены в runtimeConfig.public. Перенесите их на верхний уровень и смените токен.
ETIMEDOUT / ECONNREFUSED после деплоя Хостинг блокирует SMTP. Попробуйте 8587 или перейдите на REST API.
node:net не поддерживается на воркере Выбран edge-пресет Nitro. Используйте node-server или REST API.
Первая отрисовка страницы стала медленной Отправка выполняется во время SSR. Отправляйте из POST-роута, а долгие отправки передавайте в event.waitUntil().

Частые вопросы

Где в Nuxt должен лежать код отправки писем?

Только в каталоге server/: сам роут в server/api/, транспорт в server/utils/. Этот код выполняется в Nitro на сервере и никогда не попадает в клиентский бандл — именно поэтому SMTP-доступы не утекают в браузер. Страницы, компоненты, композаблы и плагины для этого не подходят.

Можно ли отправлять письма из композабла или компонента?

Нет. Nodemailer открывает обычный TCP-сокет, чего браузер не умеет, а импорт вне server/ тянет node:net и node:dns в клиентский бандл: Vite предупреждает, что встроенные модули Node вынесены наружу, и вызов падает в браузере. Вызывайте серверный роут через $fetch и отправляйте письмо там.

Где хранить SMTP-доступы в Nuxt?

В приватной части runtimeConfig в nuxt.config.ts, а значения подставлять переменными окружения с именами NUXT_<KEY>: runtimeConfig.smtp.pass читается из NUXT_SMTP_PASS, и обращаться к process.env в коде не нужно. В runtimeConfig.public их класть нельзя — этот объект сериализуется в HTML каждой страницы.

Нужен ли модуль вроде nuxt-nodemailer или nuxt-mail?

Нет. Транспорт в server/utils/mailer.ts занимает десяток строк и автоимпортируется во всём серверном коде, так что модуль в основном добавляет зависимость, которая должна успевать за релизами Nuxt. Пользоваться модулем не запрещено, но ничего в Nuxt этого не требует.

Почему в разработке письма уходят, а после деплоя нет?

Обычно одна из трёх причин: файл .env нужен только для локальной разработки и не читается из собранного приложения, поэтому переменные NUXT_* должны быть заданы в окружении деплоя; хостинг блокирует исходящий порт 587; либо выбранный пресет Nitro вообще не умеет TCP. Проверьте эти три варианта до того, как править код.

Можно ли отправлять письма с Cloudflare Workers или edge-пресета?

По SMTP — нет. Пресеты cloudflare, vercel-edge и netlify-edge работают в окружении без TCP-сокетов, и Nodemailer там не запустится. Используйте REST API по HTTPS — это обычный $fetch, он работает на любом пресете, — либо деплойте на стандартный node-server.

Как отправить письмо, не задерживая ответ?

Передайте промис в event.waitUntil() и сразу верните ответ: Nitro дождётся его завершения уже после отдачи. Если письмо должно переживать перезапуск и повторяться при сбое, складывайте задачу в очередь или таблицу и отправляйте её из задачи Nitro или отдельного воркера.

Что дальше