В Nuxt нет собственного почтового слоя, поэтому отправка письма — это работа Nitro: серверный роут вызывает SMTP-клиент, а страница просто отправляет на него запрос. Ниже — подключение Nodemailer к fmailer для Nuxt 3 и Nuxt 4: доступы в runtimeConfig, транспорт в серверных утилитах, шаблоны Vue Email и пресеты деплоя, на которых SMTP не работает в принципе.
| Параметр | Значение |
|---|---|
| SMTP-хост | smtp.fmailer.ru |
| Порт | 587 |
| Шифрование | STARTTLS (соединение переводится в TLS до авторизации) |
| Логин | Логин SMTP-токена вашего домена |
| Пароль | Пароль этого токена — показывается один раз, при создании |
npm install nodemailer
npm install -D @types/nodemailer Ключи верхнего уровня runtimeConfig доступны только на сервере, и каждый при старте перекрывается переменной окружения с соответствующим именем NUXT_: runtimeConfig.smtp.pass берётся из NUXT_SMTP_PASS, и обращаться к process.env не нужно:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Приватная часть — только сервер. Ничего отсюда не попадает в браузер.
smtp: {
user: "", // подставится из NUXT_SMTP_USER
pass: "", // подставится из NUXT_SMTP_PASS
from: "", // подставится из NUXT_SMTP_FROM
},
public: {
// Почтовые доступы сюда класть нельзя: этот объект попадает в HTML.
},
},
});NUXT_SMTP_USER=<логин токена>
NUXT_SMTP_PASS=<пароль токена>
NUXT_SMTP_FROM="Магазин <noreply@mail.example.ru>"public, сериализуется в HTML каждой страницы и видно через «просмотр кода». Утёкший так SMTP-токен позволяет кому угодно отправлять письма от вашего домена. Приватная часть runtimeConfig сервер не покидает. Всё, что лежит в server/utils/, автоимпортируется в серверном коде, поэтому транспорт создаётся один раз, а не пересобирается на каждый запрос:
// 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.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.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 позволяет собрать письмо Vue-компонентом и отрисовать его в строку на сервере — та же модель компонентов, что и в остальном приложении:
<!-- 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>// 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.ts
export default defineEventHandler(async (event) => {
const order = await createOrder(event);
// Ответ уходит сразу; Nitro не завершит обработчик, пока промис не разрешится.
event.waitUntil(
sendOrderEmail(order).catch((error) => console.error("Письмо о заказе не ушло", error)),
);
return order;
});Этого достаточно для писем, потеря которых некритична. Если письмо должно пережить перезапуск или повториться при сбое, складывайте задачу в очередь или таблицу и отправляйте из задачи 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: 535 | password: вместо 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(). |
Только в каталоге server/: сам роут в server/api/, транспорт в server/utils/. Этот код выполняется в Nitro на сервере и никогда не попадает в клиентский бандл — именно поэтому SMTP-доступы не утекают в браузер. Страницы, компоненты, композаблы и плагины для этого не подходят.
Нет. Nodemailer открывает обычный TCP-сокет, чего браузер не умеет, а импорт вне server/ тянет node:net и node:dns в клиентский бандл: Vite предупреждает, что встроенные модули Node вынесены наружу, и вызов падает в браузере. Вызывайте серверный роут через $fetch и отправляйте письмо там.
В приватной части runtimeConfig в nuxt.config.ts, а значения подставлять переменными окружения с именами NUXT_<KEY>: runtimeConfig.smtp.pass читается из NUXT_SMTP_PASS, и обращаться к process.env в коде не нужно. В runtimeConfig.public их класть нельзя — этот объект сериализуется в HTML каждой страницы.
Нет. Транспорт в server/utils/mailer.ts занимает десяток строк и автоимпортируется во всём серверном коде, так что модуль в основном добавляет зависимость, которая должна успевать за релизами Nuxt. Пользоваться модулем не запрещено, но ничего в Nuxt этого не требует.
Обычно одна из трёх причин: файл .env нужен только для локальной разработки и не читается из собранного приложения, поэтому переменные NUXT_* должны быть заданы в окружении деплоя; хостинг блокирует исходящий порт 587; либо выбранный пресет Nitro вообще не умеет TCP. Проверьте эти три варианта до того, как править код.
По SMTP — нет. Пресеты cloudflare, vercel-edge и netlify-edge работают в окружении без TCP-сокетов, и Nodemailer там не запустится. Используйте REST API по HTTPS — это обычный $fetch, он работает на любом пресете, — либо деплойте на стандартный node-server.
Передайте промис в event.waitUntil() и сразу верните ответ: Nitro дождётся его завершения уже после отдачи. Если письмо должно переживать перезапуск и повторяться при сбое, складывайте задачу в очередь или таблицу и отправляйте её из задачи Nitro или отдельного воркера.