Pular para o conteúdo
Docker e VPS

useSend: enviando e-mails pela sua VPS com Docker

Um unicórnio de crina colorida carimba envelopes numa esteira que sai de um servidor com uma baleia de contêineres, enquanto corujas levam as cartas voando para um castelo com velas flutuantes

Olá meus Unicórnios! 🦄✨

Todo sistema que eu faço, mais cedo ou mais tarde, precisa mandar e-mail: a confirmação do cadastro, o "seu pedido saiu para entrega", o link de trocar a senha. Aqui no blog eu já mostrei como fazer isso com o Resend, que é um serviço pronto, e com o Amazon SES direto, que é barato mas não tem painel nenhum.

O useSend fica no meio do caminho: é um painel no estilo do Resend (domínios, chaves de API, lista de e-mails enviados, gráfico de entregas), só que open source e rodando na sua VPS. Quem entrega as mensagens continua sendo o Amazon SES, por baixo.

Eu instalei na minha VPS e achei que seria o clássico "copia o compose, sobe e pronto". 😅 Pois é. A tela de login apareceu com uma caixa vazia no meio, sem botão nenhum, e o SES de mentira que o próprio projeto oferece quebrou a tela do domínio. Neste artigo eu mostro o caminho inteiro, com os dois tropeços e o que resolveu cada um, explicando cada comando mesmo que você nunca tenha mexido num servidor.

useSend no GitHubO código-fonte do projeto, com o compose oficial em docker/prod e o .env.selfhost.example que serviram de base para este artigo.github.com

📮 O que é o useSend

Pense no useSend como a "central de correio" do seu sistema. O seu código não fala com a AWS: ele chama a API do useSend com a chave dele, e o useSend cuida do resto.

  • Domínios: ele gera os registros de DNS (DKIM, SPF, MX) que provam que você é dona do domínio e acompanha a verificação.
  • Chaves de API: uma por sistema, e você pode limitar cada chave a um domínio.
  • Fila: a API responde na hora e o envio acontece em segundo plano, respeitando o limite de envios por segundo da sua conta no SES.
  • Histórico: cada e-mail enviado aparece no painel com o status (na fila, enviado, entregue, devolvido).

Por baixo ele usa três peças: um Postgres (o banco de dados), um Redis (a fila) e a conta da AWS, com o SES para entregar e o SNS para receber a notícia do que aconteceu com cada e-mail. As duas primeiras sobem junto no Docker. A terceira é o motivo de ter um SES de mentira neste artigo, e eu explico lá na frente.

🖥️ O que a VPS precisa ter

Usei uma VPS com Ubuntu 24.04, 4 vCPU e 7,8 GB de RAM, que já tinha o Docker e o Nginx de outros projetos (inclusive um Chatwoot rodando na porta 3000, o que vai importar daqui a pouco). Com o useSend de pé, eu medi isto de memória:

usesend-ses-teste-1 36.4MiB / 7.755GiB
usesend-usesend-1 125.4MiB / 7.755GiB
usesend-postgres-1 44.64MiB / 7.755GiB
usesend-redis-1 5.637MiB / 7.755GiB

Somando, uns 210 MB. É leve: cabe em qualquer VPS pequena. As imagens ocupam perto de 1,6 GB de disco (o useSend tem 509 MB e o Postgres, 642 MB).

Para entrar na VPS, abra o terminal do seu computador (no Windows, o PowerShell; no Mac e no Linux, o Terminal) e conecte com o ssh, trocando pelo IP da sua máquina:

ssh root@SEU_IP

Ele pede a senha. E ela não aparece enquanto você digita, nem asterisco. Não travou: digite e aperte Enter.

Se a sua VPS ainda não tem Docker, o próprio Docker mantém um script de instalação (no artigo do Chatwoot eu mostro esse passo com calma):

curl -fsSL https://get.docker.com | sh

📁 A pasta e o docker-compose.yml

Cada programa que eu instalo na VPS ganha uma pasta própria dentro de /opt. O mkdir -p cria a pasta, e o cd entra nela:

mkdir -p /opt/usesend
cd /opt/usesend

Agora o arquivo que diz ao Docker quais "caixinhas" (contêineres) subir. O nano é um editor de texto que roda dentro do terminal; se o arquivo não existe, ele cria:

nano docker-compose.yml

Cole o bloco inteiro abaixo. Ele parte do compose oficial do projeto (docker/prod/compose.yml), com três mudanças que eu explico logo depois:

services:
  postgres:
    image: postgres:16
    restart: always
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    volumes:
      - banco:/var/lib/postgresql/data

  redis:
    image: redis:7
    restart: always
    command: ["redis-server", "--maxmemory-policy", "noeviction"]
    volumes:
      - fila:/data

  usesend:
    image: usesend/usesend:latest
    restart: always
    env_file: .env
    ports:
      - "127.0.0.1:3010:3000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started

  ses-teste:
    build:
      context: .
      dockerfile_inline: |
        FROM usesend/local-ses-sns:latest
        USER root
        # O emulador manda a data como texto e o painel espera um numero
        RUN sed -i 's/Timestamp: new Date()/Timestamp: Math.floor(Date.now() \/ 1000)/' src/index.ts
        USER bun
    restart: always
    environment:
      WEBHOOK_URL: http://usesend:3000/api/ses_callback

volumes:
  banco:
  fila:

Para salvar no nano: aperte Ctrl+O (a letra O), confirme o nome com Enter e saia com Ctrl+X. O rodapé do nano mostra esses atalhos com um ^ na frente, e o ^ quer dizer Ctrl.

As três mudanças em relação ao arquivo oficial:

  • 127.0.0.1:3010:3000: o oficial publica a porta 3000 para o mundo inteiro. Na minha VPS a 3000 já era do Chatwoot, então usei a 3010. E o 127.0.0.1 na frente faz a porta atender só de dentro da própria VPS: quem fala com a internet vai ser o Nginx, com HTTPS.
  • env_file: .env: o oficial lista as variáveis uma por uma, e esquece algumas de que vamos precisar. Com o env_file, tudo o que estiver no .env chega ao useSend.
  • ses-teste: o SES de mentira, com um remendo de uma linha. Ele ganha seção própria mais abaixo.

Também tirei o MinIO que vem no oficial: ele guarda as imagens que você sobe nos modelos e nas campanhas do painel, e o envio de e-mail pela API não precisa dele.

🔐 O arquivo .env

O .env guarda as senhas e os endereços. Antes de escrevê-lo, gere duas sequências aleatórias com o openssl, que já vem no Ubuntu. A primeira é a senha do banco; a segunda, o segredo que o painel usa para assinar o login:

openssl rand -hex 16
openssl rand -base64 32

Copie as duas respostas para um bloco de notas. A senha do banco sai em -hex (só letras e números) de propósito: ela vai dentro de um endereço, o DATABASE_URL, e uma / ou um + ali no meio confundiriam o endereço.

nano .env
# Endereço do painel, o mesmo que você vai abrir no navegador
NEXTAUTH_URL="https://usesend.SEU_IP.sslip.io"
# O painel consulta a si mesmo por dentro do contêiner, onde a porta é 3000
NEXTAUTH_URL_INTERNAL="http://localhost:3000"
# Faz o Next.js ouvir em todas as interfaces do contêiner
HOSTNAME="0.0.0.0"
NEXTAUTH_SECRET="cole_aqui_o_resultado_do_base64"

# Banco de dados
POSTGRES_USER="usesend"
POSTGRES_PASSWORD="cole_aqui_o_resultado_do_hex"
POSTGRES_DB="usesend"
DATABASE_URL="postgresql://usesend:cole_aqui_o_resultado_do_hex@postgres:5432/usesend"

# Fila
REDIS_URL="redis://redis:6379"

# Login pelo GitHub
GITHUB_ID="coloque_o_client_id_aqui"
GITHUB_SECRET="coloque_o_client_secret_aqui"

# Amazon SES
AWS_DEFAULT_REGION="us-east-1"
AWS_ACCESS_KEY_ID="teste"
AWS_SECRET_ACCESS_KEY="teste"

# SES e SNS de mentira (emulador oficial do useSend). Apague estas duas linhas para usar a AWS real
AWS_SES_ENDPOINT="http://ses-teste:3000/api/ses"
AWS_SNS_ENDPOINT="http://ses-teste:3000/api/sns"

A mesma senha hex aparece duas vezes: no POSTGRES_PASSWORD (que cria o banco) e dentro do DATABASE_URL (que o useSend usa para entrar nele). Se as duas forem diferentes, o banco nasce com uma senha e o painel tenta outra. O GITHUB_ID e o GITHUB_SECRET ficam assim por enquanto; vamos preenchê-los na seção do login.

Salve (Ctrl+O, Enter, Ctrl+X) e feche o arquivo para os outros usuários da máquina: o chmod 600 deixa só o dono ler e escrever.

chmod 600 .env
ls -la

Repare que usei ls -la, e não só ls. No Linux, arquivo cujo nome começa com ponto é oculto: o ls comum não mostra o .env, e parece que ele sumiu. O -a mostra todos.

Antes de subir, peça ao Docker para conferir os dois arquivos juntos:

docker compose config -q

Se não aparecer nada, está tudo certo. Um erro de alinhamento no YAML aparece aqui com o número da linha, antes de qualquer contêiner ser criado.

🧪 O SES de mentira (e o remendo de uma linha)

Para enviar de verdade, o useSend precisa de uma conta na AWS com o SES liberado. Só que eu queria conhecer o painel inteiro antes, e o projeto tem uma saída para isso: a imagem usesend/local-ses-sns, um emulador oficial que os próprios desenvolvedores usam. Ele responde às chamadas do SES e do SNS no lugar da AWS, aceita tudo e não entrega nada a ninguém.

É por isso que o .env tem as duas linhas AWS_SES_ENDPOINT e AWS_SNS_ENDPOINT: elas mandam o useSend falar com o contêiner ses-teste em vez da Amazon. E as chaves da AWS são só a palavra teste, porque o emulador não confere nada.

Aí veio o primeiro tropeço. Com o emulador puro, cadastrei o domínio e a tela dele ficou vazia, com a tabela de DNS sem nenhuma linha. Chamando a API interna do painel pelo navegador, apareceu o motivo:

Expected real number, got implicit NaN

O emulador devolve a data da verificação como texto (2026-09-28T12:57:39.688Z), e a biblioteca da AWS que o useSend usa hoje espera um número (os segundos desde 1970). O emulador ficou para trás em relação ao painel. O remendo está no próprio docker-compose.yml: o dockerfile_inline pega a imagem oficial e troca, com o sed, a data em texto por Math.floor(Date.now() / 1000). Com isso a tela do domínio voltou a abrir.

🚀 Subindo o useSend

docker compose up -d

O up baixa as imagens, cria os contêineres e liga tudo; o -d devolve o terminal para você em vez de ficar preso mostrando o log. Na primeira vez demora um ou dois minutos. Depois, confira:

docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"
SERVICE     STATUS                    PORTS
postgres    Up 54 minutes (healthy)   5432/tcp
redis       Up 54 minutes             6379/tcp
ses-teste   Up 5 minutes              3000/tcp
usesend     Up 10 minutes             127.0.0.1:3010->3000/tcp

Os quatro em Up, e só o useSend com porta para fora (e mesmo assim só em 127.0.0.1). O docker compose logs usesend mostra o que o painel escreveu ao ligar. Este é um trecho de uma subida depois da primeira:

docker compose logs usesend
usesend-1  | 41 migrations found in prisma/migrations
usesend-1  | 
usesend-1  | 
usesend-1  | No pending migrations to apply.
usesend-1  | + echo \x27Starting web server\x27
usesend-1  | + node apps/web/server.js
usesend-1  | Starting web server
usesend-1  |    ▲ Next.js 15.5.18
usesend-1  |    - Local:        http://localhost:3000
usesend-1  |    - Network:      http://0.0.0.0:3000
usesend-1  | 
usesend-1  |  ✓ Starting...
usesend-1  | {"level":30,"time":1790599829871,"service":"next-app","msg":"Creating Prisma client"}
usesend-1  |  ✓ Ready in 1808ms

As migrations são as mudanças na estrutura do banco. Na primeira subida ele aplica todas e escreve All migrations have been successfully applied.; nas seguintes, confere e segue. O Ready quer dizer que o painel está no ar.

🙈 As duas linhas que salvam a tela de login

Se você só for ler um pedaço deste artigo, leia este. 🙏

Antes de ter o Nginx, eu abri o painel por um túnel SSH (um jeito de trazer a porta 3010 da VPS para o meu computador), então o endereço do painel era http://localhost:3010. A página de login abriu com o título, o logotipo e uma caixa vazia no meio. Nenhum botão, nenhum erro na tela. No log, a cada visita:

[next-auth][error][CLIENT_FETCH_ERROR] 
https://next-auth.js.org/errors#client_fetch_error fetch failed {
  error: {
    message: 'fetch failed',
    stack: 'TypeError: fetch failed\n' +
      '    at node:internal/deps/undici/undici:13510:13\n' +
      '    at processTicksAndRejections (node:internal/process/task_queues:105:5)',
    name: 'TypeError'
  },
  url: 'http://localhost:3010/api/auth/providers',
  message: 'fetch failed'
}

Repare no url. Para montar a tela, o painel pergunta a si mesmo quais logins existem, usando o endereço do NEXTAUTH_URL. Só que essa pergunta sai de dentro do contêiner, e lá dentro a porta é 3000, não 3010. A pergunta falha, a lista vem vazia, e a caixa fica sem botão.

A correção é a linha NEXTAUTH_URL_INTERNAL="http://localhost:3000": ela diz ao painel por onde se consultar lá dentro, sem depender do endereço de fora. Com ela, o botão Continue with GitHub apareceu e o erro sumiu do log.

E ela depende da outra linha, a HOSTNAME="0.0.0.0". Sem ela, o servidor do painel escuta no nome do contêiner, e não no localhost. Foi o que o log mostrou na minha primeira subida, antes de eu acrescentar a linha:

usesend-1  |    ▲ Next.js 15.5.18
usesend-1  |    - Local:        http://ffa36359df75:3000
usesend-1  |    - Network:      http://ffa36359df75:3000

Nesse estado, um localhost:3000 de dentro do contêiner respondia Connection refused. O compose oficial define o HOSTNAME; como troquei a lista de variáveis pelo env_file, a linha tinha de ir para o .env. Com as duas, o log fica como o da seção anterior: Local: http://localhost:3000.

🌐 Nginx e HTTPS: o endereço de verdade

O painel precisa ser alcançado de fora por dois motivos: você usa pelo navegador, e a AWS precisa avisar o useSend quando um e-mail é entregue ou devolvido, chamando um endereço público com HTTPS. Quem faz essa ponte é o Nginx, que recebe as visitas nas portas 80 e 443 e repassa para o 127.0.0.1:3010. O certificado vem de graça do Let's Encrypt, pelo Certbot. Se a sua VPS ainda não tem os dois:

apt-get update
apt-get install -y nginx certbot python3-certbot-nginx

E o endereço? Se você tem um domínio, crie um subdomínio (usesend.seudominio.com.br) apontando para o IP da VPS. Eu usei um atalho: o sslip.io, um serviço gratuito em que qualquer nome que contém um IP aponta para esse IP. O nome usesend.SEU_IP.sslip.io já chega na sua VPS, sem cadastrar nada.

nano /etc/nginx/sites-available/usesend
server {
    listen 80;
    server_name usesend.SEU_IP.sslip.io;

    # Anexos viajam dentro do JSON da API; o padrão do Nginx é 1 MB
    client_max_body_size 20m;

    location / {
        proxy_pass http://127.0.0.1:3010;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Troque o SEU_IP pelo seu, salve e saia. A linha que mais importa aqui é a server_name, e vale entender por quê. Na minha VPS, o Nginx já atendia o Chatwoot nas mesmas portas 80 e 443. Não há briga: quando uma visita chega, o Nginx olha o nome que o navegador pediu e escolhe o bloco server com aquele server_name. Um nome que não tem bloco próprio cai no site padrão da porta, que no meu caso era o Chatwoot. Então, se o endereço do useSend abrir outro site, o server_name está diferente do nome que você digitou.

O Nginx só lê o que está na pasta sites-enabled. O ln -s cria um "atalho" do arquivo para lá, o nginx -t confere se não tem erro de digitação, e o reload aplica sem derrubar os outros sites:

ln -s /etc/nginx/sites-available/usesend /etc/nginx/sites-enabled/usesend
nginx -t
systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Agora o certificado. O Certbot confere que o nome aponta para esta VPS, pede o certificado e edita o arquivo do Nginx sozinho para ligar o HTTPS e mandar quem entrar por http:// para o https://:

certbot --nginx -d usesend.SEU_IP.sslip.io --agree-tos --register-unsafely-without-email -n

O --agree-tos aceita os termos do Let's Encrypt, o --register-unsafely-without-email dispensa cadastrar um e-mail (o nome assusta, mas a renovação continua automática) e o -n faz tudo sem perguntas. Por último, confira que o NEXTAUTH_URL do .env é exatamente esse endereço, com https://.

🐙 O login pelo GitHub

O useSend não tem usuário e senha: o login é pelo GitHub (ou pelo Google). Para isso você cria um "aplicativo" na sua conta do GitHub, que diz ao GitHub para onde devolver a pessoa depois do login.

  1. No GitHub, clique na sua foto, depois em Settings, e no fim do menu da esquerda em Developer settings.
  2. Entre em OAuth Apps e clique em New OAuth App.
  3. Em Application name, um nome qualquer (useSend). Em Homepage URL, o endereço do painel.
  4. Em Authorization callback URL, o endereço do painel seguido de /api/auth/callback/github.
  5. Clique em Register application. Na tela seguinte, copie o Client ID e clique em Generate a new client secret para gerar o segredo.

A URL de retorno do passo 4, para o meu endereço, fica assim:

https://usesend.SEU_IP.sslip.io/api/auth/callback/github

Ela precisa bater letra por letra com o NEXTAUTH_URL mais o final /api/auth/callback/github, porque é assim que o painel monta o endereço de volta (o parâmetro redirect_uri) que ele manda ao GitHub no login. Se o GitHub reclamar de redirect_uri, é quase sempre um http no lugar de https, ou uma barra sobrando.

Coloque o Client ID e o segredo no .env (nano .env, nas linhas GITHUB_ID e GITHUB_SECRET) e suba de novo. O Docker percebe que o .env mudou e recria só o contêiner do useSend:

docker compose up -d

⚙️ As primeiras telas: SES e time

Depois do login pelo GitHub, o painel abre direto nesta tela, pedindo os dados do SES:

A tela Add SES Settings do useSend com os campos Region us-east-1, Callback URL vazio, Send Rate 1 e Transactional Quota 50
  • Region: a região da AWS onde fica a sua conta do SES. Deixei o padrão, us-east-1.
  • Callback URL: o endereço do painel, só a raiz (https://usesend.SEU_IP.sslip.io). O próprio useSend acrescenta o /api/ses_callback no fim. É por aqui que a AWS avisa o que aconteceu com cada e-mail, e é por isso que o painel precisava de HTTPS.
  • Send Rate e Transactional Quota: quantos e-mails por segundo e que fatia disso vai para os e-mails "de sistema" (o resto fica para campanhas). Deixei os valores que vieram.

Ao clicar em Create, o useSend testa o endereço, cria na AWS (aqui, no emulador) um tópico do SNS e o inscreve no Callback URL. No log aparece o Checking if URL is valid seguido da confirmação da inscrição. Em seguida ele pede o nome do time:

A tela Create Team do useSend com o nome Loja Exemplo digitado no campo

E pronto, o painel. Os números estão zerados porque nada foi enviado ainda:

O painel Analytics do useSend com os contadores Total, Delivered, Bounced, Complained, Clicked e Opened zerados e o menu lateral com Emails, Domains e Developer settings

🌍 Cadastrando o domínio

O useSend só envia em nome de um domínio que você provou ser seu. Em Domains, clique em Add domain. A própria tela sugere usar um subdomínio, e é uma boa ideia: se um dia um envio der problema, a reputação manchada é a do mail.seudominio.com.br, não a do domínio principal. No exemplo eu usei o mail.example.com:

A janela Add a new domain do useSend com mail.example.com no campo Domain e o botão Add

Ao adicionar, ele mostra os registros que você precisa criar no DNS do domínio. Com o emulador, o domínio já aparece como Verified; com a AWS de verdade, ele fica em Pending até os registros existirem:

A página do domínio mail.example.com no useSend, marcado como Verified, com a tabela DNS records: um MX e três TXT com os nomes mail.mail, usesend._domainkey.mail, mail.mail e _dmarc

São quatro registros:

TipoNomeConteúdoPara que serve
MXmail.mailfeedback-smtp.us-east-1.amazonses.com (prioridade 10)Recebe as devoluções de quem não existe
TXTusesend._domainkey.mailp=MIGfMA0GCSq... (uma chave longa)A assinatura DKIM, que prova que o e-mail saiu de você
TXTmail.mailv=spf1 include:amazonses.com ~allO SPF, que autoriza a Amazon a enviar por você
TXT_dmarcv=DMARC1; p=none;Recomendado: a política DMARC

Repare no detalhe que confunde: o nome é mail.mail, e não mail. Os nomes da tabela são relativos ao domínio principal (example.com). O useSend usa um "sub-subdomínio" mail. na frente do seu subdomínio como endereço de devolução, então o nome completo do MX é mail.mail.example.com. No painel de DNS do example.com, você digita exatamente o que está na coluna Name.

A chave do DKIM é gerada pelo próprio useSend e aparece cortada na tabela, com reticências. No DNS vai o valor inteiro, que é bem mais longo (começa em p=MIGfMA0 e termina em IDAQAB).

🔑 A chave de API

Em Developer settings, clique em Add API Key. Dê um nome que diga qual sistema vai usá-la. O campo Domain access limita a chave a um domínio só, o que é útil quando você tem vários:

A janela Create a new API key do useSend com o nome site da loja e Domain access em All Domains

Ao criar, ele mostra a chave numa janela, escondida atrás dos pontinhos. O olho mostra, o ícone ao lado copia:

A janela Copy API key do useSend com a chave mascarada por pontos, o ícone de mostrar, o de copiar e o botão Close

Copie agora e guarde num lugar seguro. A chave começa com us_, e quem tiver ela envia e-mail em seu nome.

📨 Enviando pela API com Node.js

Agora o seu sistema. O envio é uma chamada POST para /api/v1/emails, com a chave no cabeçalho Authorization. O script abaixo não precisa instalar nada: o fetch já vem no Node.js a partir da versão 18. Crie um arquivo enviar.js no seu computador:

// Envia um e-mail pela API do useSend instalado na sua VPS.
// Antes de rodar, defina USESEND_URL e USESEND_API_KEY no terminal.

const endereco = process.env.USESEND_URL;
const chave = process.env.USESEND_API_KEY;

async function enviarEmail(para, assunto, texto) {
    const resposta = await fetch(endereco + "/api/v1/emails", {
        method: "POST",
        headers: {
            "Authorization": "Bearer " + chave,
            "Content-Type": "application/json",
        },
        body: JSON.stringify({
            // O remetente tem de ser do domínio verificado no painel,
            // senão a API recusa com erro 400
            from: "Loja Exemplo <[email protected]>",
            to: para,
            subject: assunto,
            text: texto,
        }),
    });

    const dados = await resposta.json();

    // A API responde com JSON também no erro: sem este if,
    // o script diria "enviado" com o id vazio
    if (!resposta.ok) {
        throw new Error("a API respondeu " + resposta.status + ": " + dados.error.message);
    }

    return dados.emailId;
}

async function main() {
    try {
        const id = await enviarEmail(
            "[email protected]",
            "Seu pedido foi enviado",
            "Oi! O seu pedido 1234 saiu para entrega."
        );
        console.log("E-mail aceito pelo useSend. Id:", id);
    } catch (erro) {
        console.error("Não consegui enviar:", erro.message);
    }
}

main();

O if (!resposta.ok) é o trecho que parece bobo e não é. O fetch só dá erro quando a rede cai; uma resposta 400 ou 403 chega "com sucesso", com um JSON de erro dentro. Sem esse if, o script leria dados.emailId (que não existe no erro) e diria que enviou.

O endereço e a chave ficam fora do código, em variáveis de ambiente: assim você não publica a chave sem querer junto com o script. No PowerShell do Windows:

$env:USESEND_URL="https://usesend.SEU_IP.sslip.io"
$env:USESEND_API_KEY="SUA_CHAVE_AQUI"
node enviar.js

No Mac e no Linux, troque as duas primeiras linhas por export USESEND_URL="..." e export USESEND_API_KEY="...". A resposta:

E-mail aceito pelo useSend. Id: cmul9c8jt0008lt2bnv6se9fa

E os dois jeitos mais comuns de errar, para você reconhecer quando acontecerem. Com a chave errada:

Não consegui enviar: a API respondeu 403: Invalid API token

E com um remetente de outro domínio (troquei o from para [email protected]):

Não consegui enviar: a API respondeu 400: Domain: outraloja.example.org of from email is wrong. Use the domain verified by useSend

Esse segundo é o que mais aparece na prática: o from precisa terminar exatamente no domínio cadastrado. Com o domínio mail.example.com, o remetente [email protected] também é recusado, porque o domínio verificado é o subdomínio.

📬 Por que o e-mail ficou em Queued

Abrindo Emails no painel, lá estava ele. Com o status Queued (na fila):

A lista Emails do useSend com o e-mail para cliente@example.net, assunto Seu pedido foi enviado e status Queued

E ficou em Queued para sempre. 😳 O log mostrava que o envio tinha acontecido:

{"level":30,"time":1790600338513,"service":"next-app","teamId":1,"queueId":"cmul97jkx0006lt2bga2wso90","messageId":"655cc650-af63-4dc8-a661-cc4b2e2de4c2","msg":"Email sent!"}

O motivo ensina como o useSend funciona. Ele não muda o status sozinho depois de entregar o e-mail ao SES. Quem conta o que aconteceu é a AWS: o SES publica cada acontecimento (enviado, entregue, devolvido, marcado como spam) no tópico do SNS criado naquela primeira tela, e o SNS chama o /api/ses_callback do painel. O useSend confere se o aviso veio do tópico dele e só então atualiza o status.

O emulador manda os avisos, mas sempre com um tópico de nome fixo, diferente do que o useSend criou. O painel compara, não reconhece e descarta:

  TopicArn: 'arn:aws:sns:us-east-1:000000000000:ses-notifications',
Is event valid:  false

Com a AWS de verdade, o tópico é o mesmo dos dois lados, e é assim que o status anda de Queued para Sent e Delivered. É também o motivo de a Callback URL precisar ser pública e com HTTPS: se a AWS não consegue chamar o seu painel, todo e-mail fica em Queued, do mesmo jeito que aqui.

☁️ Trocando o emulador pela AWS de verdade

Quando for enviar para gente de verdade, a troca é no .env e no compose, sem reinstalar nada:

  1. Na AWS, crie um usuário no IAM com as permissões AmazonSESFullAccess e AmazonSNSFullAccess (é o que a documentação do useSend pede) e gere uma chave de acesso para ele.
  2. No .env, coloque a chave em AWS_ACCESS_KEY_ID e AWS_SECRET_ACCESS_KEY no lugar de teste, e apague as duas linhas AWS_SES_ENDPOINT e AWS_SNS_ENDPOINT.
  3. No docker-compose.yml, apague o serviço ses-teste inteiro e rode docker compose up -d --remove-orphans (o --remove-orphans desliga o contêiner que saiu do arquivo).
  4. Crie os registros de DNS do domínio e espere o Pending virar Verified.

E um detalhe da AWS que pega todo mundo: conta nova do SES começa no sandbox, em que você só consegue enviar para endereços que você mesma verificou. Para mandar para os seus clientes, é preciso pedir a saída do sandbox no console do SES. Eu contei essa parte da AWS no artigo de autenticar o remetente no SES.

useSend: self hostingO guia oficial de instalação, com as permissões da AWS, o login pelo GitHub e a saída do sandbox do SES.docs.usesend.com

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

O useSend é gratuito?
O código é aberto e você hospeda na sua própria VPS, sem pagar nada ao projeto. Quem entrega os e-mails é o Amazon SES, então a conta de envio é a da AWS. Na minha VPS, os quatro contêineres somaram uns 210 MB de memória.
Preciso de conta na AWS para instalar o useSend?
Para enviar e-mail de verdade, sim: o useSend usa o Amazon SES para entregar e o SNS para saber o que aconteceu com cada mensagem. Para conhecer o painel antes, o próprio projeto tem um SES de mentira (usesend/local-ses-sns) que responde no lugar da AWS e não entrega nada a ninguém.
Por que a tela de login do useSend aparece sem o botão do GitHub?
Porque o painel busca a lista de logins em NEXTAUTH_URL/api/auth/providers de dentro do próprio contêiner, e esse endereço não respondia lá dentro. O log mostra CLIENT_FETCH_ERROR fetch failed. Coloque NEXTAUTH_URL_INTERNAL="http://localhost:3000" e HOSTNAME="0.0.0.0" no .env.
Como o useSend faz login sem usuário e senha?
Pelo GitHub (ou Google). Você cria um OAuth App no GitHub com a URL de retorno https://SEU_ENDERECO/api/auth/callback/github e coloca o Client ID e o Client Secret no .env. A primeira pessoa que entra vira dona da instalação; as outras precisam de convite.
Por que a API do useSend recusa o meu remetente?
O from precisa ser de um domínio cadastrado e verificado no painel. Com outro domínio, a API responde 400 com Domain: ... of from email is wrong. Use the domain verified by useSend. Chave errada dá 403 com Invalid API token.
Por que o e-mail fica em Queued no painel do useSend?
O status muda quando o SES avisa o useSend pelo SNS (enviado, entregue, devolvido). Sem esse aviso, ele fica em Queued mesmo depois do envio. Com o SES de mentira, o aviso chega com um tópico que o useSend não conhece, e o log mostra Is event valid: false.

Leia também