useSend: enviando e-mails pela sua VPS com Docker

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.
📮 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 porta3000para o mundo inteiro. Na minha VPS a 3000 já era do Chatwoot, então usei a 3010. E o127.0.0.1na 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 oenv_file, tudo o que estiver no.envchega 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.
- No GitHub, clique na sua foto, depois em Settings, e no fim do menu da esquerda em Developer settings.
- Entre em OAuth Apps e clique em New OAuth App.
- Em Application name, um nome qualquer (
useSend). Em Homepage URL, o endereço do painel. - Em Authorization callback URL, o endereço do painel seguido de
/api/auth/callback/github. - 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:

- 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_callbackno 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:

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

🌍 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:

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:

São quatro registros:
| Tipo | Nome | Conteúdo | Para que serve |
|---|---|---|---|
| MX | mail.mail | feedback-smtp.us-east-1.amazonses.com (prioridade 10) | Recebe as devoluções de quem não existe |
| TXT | usesend._domainkey.mail | p=MIGfMA0GCSq... (uma chave longa) | A assinatura DKIM, que prova que o e-mail saiu de você |
| TXT | mail.mail | v=spf1 include:amazonses.com ~all | O SPF, que autoriza a Amazon a enviar por você |
| TXT | _dmarc | v=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:

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

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):

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:
- Na AWS, crie um usuário no IAM com as permissões
AmazonSESFullAccesseAmazonSNSFullAccess(é o que a documentação do useSend pede) e gere uma chave de acesso para ele. - No
.env, coloque a chave emAWS_ACCESS_KEY_IDeAWS_SECRET_ACCESS_KEYno lugar deteste, e apague as duas linhasAWS_SES_ENDPOINTeAWS_SNS_ENDPOINT. - No
docker-compose.yml, apague o serviçoses-testeinteiro e rodedocker compose up -d --remove-orphans(o--remove-orphansdesliga o contêiner que saiu do arquivo). - 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.comPor hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
O useSend é gratuito?
Preciso de conta na AWS para instalar o useSend?
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?
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?
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?
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?
Is event valid: false.Leia também
ClickHouse: instalando na VPS e usando com Node.js
Como instalar o ClickHouse numa VPS Ubuntu, proteger com senha, criar uma tabela de acessos e consultar 10 milhões de linhas pelo Node.js.
sslip.io: domínio com HTTPS para o IP da sua VPS
O sslip.io dá um nome de graça para qualquer IP. Veja as formas do nome, como pôr HTTPS do Let's Encrypt com Nginx e Certbot, e os limites do serviço.
OpenSEO: instalando na VPS e auditando um site
Como instalar o OpenSEO, alternativa aberta ao Semrush, numa VPS com Docker, auditar um site de verdade e entender a chave da DataForSEO.


