MillionSend Docs

Auto-hospedagem

Implante o MillionSend na sua própria infraestrutura com Docker Compose e sua própria conta AWS SES.

O MillionSend auto-hospedado envia pela sua própria conta AWS SES. Uma implantação são dois contêineres: Postgres e um contêiner de app rodando a API (porta 3001), o worker e o painel web (porta 3000). Um terceiro contêiner opcional roda o relay SMTP (porta 2587). (Prefere não operar infraestrutura? O MillionSend Cloud é a mesma plataforma, hospedada.)

Pré-requisitos: Docker com Compose; uma conta AWS com acesso ao SES na região escolhida (contas em sandbox só enviam para destinatários verificados — solicite acesso de produção para enviar a qualquer um); um domínio de envio que você controla. A verificação do domínio (registros DKIM) é feita pelo painel depois do boot.

Início rápido (sem clone)

Um comando, em um diretório vazio (Node 18+):

mkdir millionsend && cd millionsend
npx @millionsend/setup

O assistente detecta o que já existe e oferece cada etapa — criar o .env a partir de um template embutido com segredos gerados, provisionar os recursos da AWS e os buckets S3 para uploads e backups (ambos abaixo), baixar o arquivo compose standalone e rodar docker compose up -d. Toda etapa pode ser pulada e é segura de repetir; --dry-run imprime o plano completo sem tocar em nada. Em uma instalação já configurada, uma execução no terminal abre um menu de próximos passos em vez de percorrer cada etapa de novo.

O equivalente manual usa a mesma imagem multi-arch pré-construída:

mkdir millionsend && cd millionsend
curl -O https://raw.githubusercontent.com/MillionSend/millionsend/main/deploy/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/MillionSend/millionsend/main/.env.example

Preencha o .env (veja a referência de ambiente — todo o resto tem padrões que funcionam localmente), depois:

docker compose up -d

As migrações rodam automaticamente no boot. Painel: http://localhost:3000. API: http://localhost:3001.

Atualizações

docker compose pull
docker compose up -d

As migrações rodam no boot, então, para uma instância pequena, a atualização é só isso. O arquivo compose roda ghcr.io/millionsend/millionsend:latest, a release mais recente com tag (as tags :1.2.3 e :1.2 existem ao lado dela; :edge acompanha a main, onde todo build passou pela suíte de testes antes). Para fixar uma versão, defina MILLIONSEND_IMAGE no .env com uma tag de versão ou um digest imutável (ghcr.io/millionsend/millionsend@sha256:…; docker image ls --digests mostra o que está rodando) e rode docker compose up -d. Colocar de volta o pin anterior é o rollback — com a ressalva de que as migrações de schema só andam para frente, então faça um dump antes de um salto grande (Backups); uma imagem revertida pode não subir em um schema mais novo.

Quando as tabelas ficam grandes (milhões de e-mails ou contatos), uma migração que as reescreve ou indexa leva minutos. As migrações rodam em uma transação e seus bloqueios impedem leituras e escritas nas tabelas tocadas até o commit, então essa espera é indisponibilidade tanto no boot quanto antes da troca. Mesmo assim, rode-a antes da troca, a partir de um contêiner descartável, em um horário calmo: uma migração que falha deixa o contêiner antigo atendendo em vez de um contêiner que não sobe, e a passagem do boot então não encontra nada pendente:

docker compose pull && docker compose run --rm --no-deps millionsend migrate && docker compose up -d

Atualizações automáticas, para um host que só consegue sair para a internet (atrás de um firewall só-CDN, por exemplo): uma linha de cron basta, já que o up -d só recria um contêiner quando a imagem dele mudou.

( crontab -l 2>/dev/null; echo "*/5 * * * * cd /opt/millionsend && docker compose pull -q && docker compose up -d" ) | crontab -

A partir do código-fonte

Para quem contribui, ou quando você quer modificar o código:

git clone https://github.com/MillionSend/millionsend.git
cd millionsend
cp .env.example .env   # preencha como no início rápido
docker compose up --build -d

O docker-compose.yml da raiz constrói a imagem localmente a partir do Dockerfile. Para rodar um clone contra a imagem publicada: docker compose -f docker-compose.yml -f docker-compose.prebuilt.yml up -d.

Sem Docker (Node 24+, pnpm 11, Postgres local): pnpm install, aponte DATABASE_URL para o seu Postgres, pnpm --filter @millionsend/db db:migrate, depois rode pnpm --filter @millionsend/api dev, pnpm --filter @millionsend/worker dev e pnpm --filter @millionsend/web dev em terminais separados.

Kubernetes (Helm)

O chart em deploy/helm/millionsend roda a mesma imagem com um Deployment por processo (web, api, worker e, se você quiser, o relay smtp e a documentação). O Postgres fica fora do chart. Os segredos vêm de um Secret que já existe (kubectl, SealedSecrets, SOPS), de um ExternalSecret do External Secrets Operator ou, só para testes, do próprio arquivo de values. Cada release publica o chart em oci://ghcr.io/millionsend/charts/millionsend com a mesma versão da release (o chart X.Y.Z roda o MillionSend X.Y.Z), então atualizar é rodar o mesmo comando com uma --version mais nova:

helm upgrade --install millionsend oci://ghcr.io/millionsend/charts/millionsend \
  --version X.Y.Z -n millionsend --create-namespace -f my-values.yaml

As releases anteriores à publicação do chart não estão no registry; num clone do repositório, use deploy/helm/millionsend no lugar da referência oci://, sem o --version.

O README do chart traz um exemplo para k3s (Traefik, CloudNativePG, role IAM via web identity OIDC em vez de access keys) e outro para EKS (ALB, RDS, External Secrets com AWS Secrets Manager, IRSA). O exemplo de EKS ainda não rodou num cluster EKS de verdade: considere IRSA, as anotações do ALB e as notas sobre o RDS como não testados. O recebimento (os serviços mx e clamav) ainda não faz parte do chart. Toda variável desta página vai em config ou no Secret, menos as que o chart já define sozinho (WORKER_REPLICAS, SMTP_PORT, SMTP_PUBLIC_HOST e as SMTP_TLS_*; veja o README do chart).

Referência de ambiente

Do .env.example. Só os dois segredos são obrigatórios; todo o resto tem padrões que funcionam localmente. Cobrança (planos, Stripe) só existe em implantações hospedadas — veja Cobrança; uma instância auto-hospedada não tem limites de plano.

Obrigatórias

VariávelPropósito
DATABASE_URLString de conexão do Postgres. O padrão corresponde ao serviço postgres do compose. Se o seu Postgres gerenciado exige TLS (como o RDS para PostgreSQL 15 ou mais novo), coloque ?sslmode=require no final.
POSTGRES_PASSWORDSenha do serviço postgres do compose (padrão millionsend); o assistente de setup gera uma e a coloca também em DATABASE_URL. Mantenha as duas em sincronia.
MASTER_ENCRYPTION_KEYChave de criptografia dos corpos de e-mail em repouso. Gere com openssl rand -base64 32. Perdê-la torna os corpos armazenados irrecuperáveis; trocá-la deixa os corpos antigos ilegíveis. Faça backup junto com o banco.
BETTER_AUTH_SECRETSegredo de assinatura das sessões do painel. Gere com openssl rand -base64 32.
APP_BASE_URLURL base pública da implantação — a origem que os navegadores usam para acessar o painel (ex.: https://mail.example.com). O login só é aceito a partir dessa origem; assinaturas SNS, links de descadastro e links de rastreamento derivam dela. Precisa corresponder exatamente ao esquema+host+porta em que você abre o painel — inclusive um WEB_PORT personalizado — ou login e cadastro falham com erro de "invalid origin". Padrão http://localhost:3000.
PUBLIC_API_URLOrigem pública da API quando um reverse proxy a serve no próprio hostname (ex.: https://api.example.com). É o que o painel mostra como base da API e ao que os tokens MCP ficam vinculados; sem definir, a API é assumida na porta 3001 do host do painel.

AWS SES

VariávelPropósito
AWS_REGIONRegião do SES (padrão us-east-1); também a região dos clientes KMS e SQS.
AWS_REGIONSRegiões do SES pelas quais esta instalação envia, separadas por vírgula, a primeira sendo a padrão; sem definir, a única região em AWS_REGION. Cada região precisa do próprio tópico SNS e configuration set — veja Adicionando uma região.
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEYCredenciais IAM com ses:SendEmail / ses:SendRawEmail. Omita para usar a cadeia padrão de credenciais da AWS (instance profile, SSO, …).
SNS_TOPIC_ARNSARNs de tópicos SNS (separados por vírgula) autorizados a entregar eventos do SES. Sem definir, a ingestão de eventos fica desativada.
SQS_QUEUE_URLFila SQS que o worker consome por long polling para eventos do SES. O setup sempre a cria; mantenha definida mesmo quando o SNS também faz push em https://<seu-host>/ses/events (o app deduplica os dois).
SES_CONFIGURATION_SETConfiguration set do SES aplicado a envios sem configuration set por domínio. Sem definir, envia sem (e sem eventos de entrega).
SES_TENANTSUm tenant do SES por equipe, para o SES medir a reputação de bounces/reclamações por cliente e pausar um remetente sem pausar os demais; suspender uma equipe desativa o tenant dela. Padrão = IS_CLOUD; exige as ações IAM ses:*Tenant* e ses:UpdateReputationEntityCustomerManagedStatus.

Opcionais

VariávelPropósito
MILLIONSEND_IMAGESó no deploy standalone: qual imagem rodar. O padrão ghcr.io/millionsend/millionsend:latest é a release mais recente com tag (:edge acompanha a main), então docker compose pull é a atualização. Defina uma tag de versão ou um digest imutável ghcr.io/millionsend/millionsend@sha256:… para fixar uma versão (MILLIONSEND_BACKUP_IMAGE fixa o sidecar de backup do mesmo jeito).
ALLOW_SIGNUPO primeiro usuário sempre pode se registrar; depois disso o cadastro fica fechado a menos que isto seja true. Mantenha false quando o painel é acessível pela internet.
TRUSTED_PROXIESProxies que o processo web pula ao ler o endereço do cliente no X-Forwarded-For, percorrendo o header da direita para a esquerda (IPs ou CIDRs separados por vírgula, padrão 127.0.0.1,::1); o primeiro endereço fora da lista é o cliente. Atrás de um único reverse proxy que acrescenta o header, não precisa definir nada: liste só os proxies na frente dele que também acrescentam, como um CDN ou um load balancer, e nunca o CIDR inteiro de pods ou da VPC. Veja a seção de nginx.
WEBHOOK_ALLOW_LOCALHOSTSó para desenvolvimento local: permite que endpoints de webhook (disparos de teste incluídos) apontem para http:// e endereços de loopback/privados em qualquer porta. Mantenha false em qualquer instância acessível pela internet.
COMPOSE_PROFILESServiços opcionais do compose, separados por vírgula: smtp (o relay; monte um par de chaves STARTTLS antes), mx (o MX de entrada; veja Recebimento) e clamav (antivírus para os e-mails recebidos; veja Antivírus) e, no arquivo standalone, também docs (este site de documentação) e backup (dumps agendados).
PORTPorta da API (padrão 3001). No compose, move junto a porta interna do contêiner e a porta publicada no host.
UNSUBSCRIBE_BASE_URLHost próprio, opcional, para as páginas hospedadas de descadastro (ex.: https://unsubscribe.example.com), apontado para o mesmo processo web. Os links de descadastro nos e-mails e os redirecionamentos da página usam esse host, e ele serve só o fluxo de descadastro, então destinatários e scanners de links nunca alcançam a origem, os cookies nem a reputação do painel. Sem definir: APP_BASE_URL.
WEB_PORTPorta do host em que o compose publica o painel (o processo web é sempre 3000 dentro do contêiner). Mantenha APP_BASE_URL em sincronia.
DOCS_PORTPorta do host em que o compose publica este site de documentação (o processo docs é sempre 3002 dentro do contêiner).
SMTP_PORTPorta do relay SMTP (padrão 2587).
SMTP_PUBLIC_HOSTHost que a aba Configurações → SMTP do painel indica para os clientes se conectarem (ex.: smtp.example.com). Sem definir: o host do APP_BASE_URL. Defina quando o relay atende por outro nome; o próprio relay nunca lê essa variável. Com IS_CLOUD=true, a aba só aparece quando essa variável e o par de chaves STARTTLS estão definidos.
SMTP_TLS_CERT_PATH / SMTP_TLS_KEY_PATHPar de chaves STARTTLS do relay SMTP (caminhos PEM dentro do contêiner). Com ambos definidos: STARTTLS é oferecido e exigido antes do AUTH. Sem o par, o relay se recusa a iniciar (a menos que SMTP_ALLOW_INSECURE_AUTH=true).
SMTP_ALLOW_INSECURE_AUTHEscape explícito para SMTP AUTH em texto puro apenas em rede local/privada. Mantenha false; nunca combine true com bind público.
IS_CLOUDDeixe false. true ativa comportamento de nuvem hospedada (KMS, cobrança via Stripe).
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET / STRIPE_PORTAL_CONFIGSomente na nuvem hospedada; ignorados quando IS_CLOUD=false. Chave de API do Stripe, o segredo de assinatura do endpoint de webhook em /api/billing/webhook e um id opcional de configuração do portal do cliente.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETCredenciais OAuth do "Continuar com Google". O botão só aparece com ambas definidas. URL de callback: {APP_BASE_URL}/api/auth/callback/google.
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRETO mesmo, para GitHub. URL de callback: {APP_BASE_URL}/api/auth/callback/github.
AUTH_EMAIL_FROMRemetente dos e-mails de conta (redefinição de senha, confirmação de e-mail, o código de uso único do console), como Nome <usuario@dominio> ou um endereço simples; o domínio precisa ser uma identidade verificada na conta SES desta instância. A recuperação de senha e a confirmação no cadastro só existem com isto definido e credenciais SES configuradas — deixe vazio para pular as duas. Verifique o domínio em uma equipe em Domínios e esses e-mails passam a ser registrados lá, com as novas contas como seus contatos (veja E-mails de conta).
TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEYChaves do Cloudflare Turnstile, as duas ou nenhuma. Definidas, o login (inclusive com Google e GitHub), o cadastro, a redefinição de senha, o formulário de novidades do produto e o botão "Enviar e-mail" do onboarding verificam um token de desafio (widgets invisíveis ou gerenciados funcionam). Sem elas, todos os formulários funcionam sem captcha.
ONBOARDING_EMAIL_FROMRemetente do botão "Enviar e-mail" do onboarding, como Nome <usuario@dominio> ou um endereço simples em um domínio verificado nesta conta SES. Leva um único e-mail de teste fixo por equipe, para o proprietário da equipe (um endereço confirmado, onde a instância confirma e-mails), e nada mais: um envio a partir dele pela API, SMTP, MCP, uma transmissão ou um teste do painel é recusado (422 reserved_sender na API), mesmo para uma equipe que verificou o domínio. Deixe vazio para ocultar o botão.
NOTIFICATIONS_EMAIL_FROMRemetente das notificações de conta aos proprietários da equipe (cota quase ou totalmente usada, taxas de bounce/reclamação em risco ou pausadas) e dos e-mails de convite para a equipe, nos mesmos formatos de AUTH_EMAIL_FROM, que é o fallback. Sem nenhum dos dois, só os eventos de webhook saem e os convites ficam só por link. Verifique o domínio em uma equipe para registrar esses e-mails lá.
INVITES_PER_TEAM_PER_DAYConvites que uma equipe, e um membro somando todas as suas equipes, pode criar em um dia corrido, contando os revogados e os de equipes excluídas (padrão 50). Acima disso, novos convites são recusados até o dia virar.
BLOCK_DISPOSABLE_EMAILSRecusa endereços da lista disposable-email-domains (domínio público) no cadastro, nos convites e na troca de e-mail; um subdomínio conta como o domínio acima dele. Contas que já existem continuam funcionando, e o console as marca. Padrão = IS_CLOUD. Veja Política de cadastro.
IP_GEOLOCATIONRede e país dos IPs de cadastro que o console mostra, a partir das bases gratuitas da DB-IP (CC BY 4.0), que o worker baixa de download.db-ip.com uma vez por mês; as consultas ficam nesta instância. Padrão = IS_CLOUD. Veja Console.
SENTRY_DSN / SENTRY_BROWSER_DSN / SENTRY_ENVIRONMENT / SENTRY_TRACES_SAMPLE_RATERastreamento de erros opcional pelo SDK do Sentry; desligado enquanto SENTRY_DSN não estiver definido. Veja Rastreamento de erros.

Dimensionamento do worker

Os padrões atendem à taxa de envio de 14/s. O Postgres roda com max_connections=200 nos arquivos compose; cada processo (api, worker, web) mantém um pool de até 24 conexões, então contêineres separados e réplicas de worker cabem sem ajuste.

VariávelPropósito
SEND_CONCURRENCYEnvios em paralelo no worker (padrão 16) — cerca de 1,2 por mensagem/segundo da taxa de envio do SES.
WORKER_REPLICASQuantos processos de worker estão rodando (padrão 1). O limitador de taxa do SES é um token bucket por processo, então cada worker divide a taxa de envio por este número para manter a conta no total.
SES_TRANSACTIONAL_RESERVEPercentual da cota móvel de 24 horas do SES de cada região que as transmissões nunca usam (padrão 30, permitido 5–90). O e-mail transacional pode usar toda essa parte e tomar emprestado além dela; uma transmissão maior que o restante é distribuída pelos dias seguintes. Valor inicial apenas: Console → Regiões sobrescreve em tempo de execução.
SQS_POLL_CONCURRENCYLoops paralelos de long polling no SQS para eventos do SES (padrão 4).
WEBHOOK_DELIVERY_RETENTION_DAYSPor quanto tempo as linhas e payloads de entrega de webhook ficam legíveis no log de entregas (padrão 30); as mais antigas são apagadas.
EMAIL_METADATA_RETENTION_DAYSDias que as linhas inteiras de e-mail (destinatários, assunto, status, eventos) são mantidas (padrão 30, a norma do setor); os corpos saem antes pela configuração de retenção do painel, e contadores diários e resultados de transmissões são mantidos sempre. Versões anteriores à v0.6.30 usavam 365: defina explicitamente antes de atualizar se esse histórico precisa ficar.
OPEN_PREFETCH_WINDOW_SECONDSUma busca do pixel de rastreamento até esta quantidade de segundos após a entrega (ou antes dela) é registrada como pré-carregada, não como abertura (padrão 10); 0 mantém só as regras por user agent. Veja precisão da taxa de abertura.

Armazenamento de objetos (uploads e backups)

VariávelPropósito
S3_ENDPOINT / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEYUM conjunto de credenciais compatível com S3, compartilhado entre uploads de logo da equipe e backups do banco (o Cloudflare R2 funciona sem ajustes). Defina as três juntas.
S3_REGION / S3_PROVIDEROs padrões auto / Cloudflare servem para o R2. Outros serviços compatíveis com S3 definem uma região real se o endpoint exigir, e o nome do provider no rclone (AWS, Minio, …) para o job de backup.
S3_STORAGE_BUCKET / S3_STORAGE_PUBLIC_URLBucket público de uploads (logos das equipes) e a URL base pública de onde ele serve. Defina juntas; sem elas a UI de upload não aparece em lugar nenhum.
S3_BACKUP_BUCKETBucket PRIVADO para os dumps agendados do banco — nunca o bucket público de uploads. Sem ele o serviço backup fica desligado.
S3_BACKUP_PREFIX / BACKUP_CRON / BACKUP_RETENTION_DAYSAjustes do backup: prefixo das chaves (padrão backups), agenda diária dos dumps (<minuto> <hora> * * *, UTC, padrão 0 3 * * *; qualquer outro formato faz o serviço sair com código 1) e dias de dumps mantidos (padrão 14).
BACKUP_AGE_RECIPIENTChave pública age (age1…); defina para criptografar os dumps antes do upload. Para restaurar, rode age --decrypt -i <arquivo da chave> primeiro.

E-mail de entrada (recebimento)

Veja Recebimento. O app lê as quatro primeiras; o processo MX lê só as variáveis MX_*. O serviço mx do compose já define MX_INGEST_URL, MX_HOSTNAME (a partir do RECEIVING_MX_HOST) e os caminhos do TLS, então só defina essas para um MX em outro host.

VariávelPropósito
RECEIVING_MX_HOSTHostname em que o processo MX atende (ex.: mx.example.com); os domínios de recebimento apontam o registro MX para ele. Sem ela, o recebimento não existe na instância: nem aba nem opção de ativar no painel, nada de caixas de entrada, todo domínio informa capabilities.receiving: "disabled" e os endpoints internos do MX respondem 404.
MX_INGEST_SECRETSegredo bearer que o MX apresenta à API, com pelo menos 32 caracteres (openssl rand -hex 32). Defina junto com RECEIVING_MX_HOST: o app se recusa a subir com só uma das duas. O processo MX precisa do mesmo valor.
SHARED_INBOX_DOMAINDomínio opcional para caixas de entrada que não precisam de DNS: cada equipe ganha um subdomínio dele (veja Domínio compartilhado das caixas). O MX e o MX curinga dele precisam apontar para o RECEIVING_MX_HOST, e precisa existir uma identidade do SES para ele na região padrão. Exige RECEIVING_MX_HOST; sem ela, o domínio compartilhado não aparece. O que os endereços dele podem enviar é definido por SHARED_INBOX_SEND, SHARED_INBOX_OPEN, SHARED_INBOX_TENANTS e SHARED_INBOX_TEAM_DAILY_CAP, descritas lá.
CLAMAV_HOSTclamd opcional que verifica todo e-mail recebido, como host[:porta] (porta 3310 por padrão), ex.: clamav:3310 para o serviço do compose (veja Antivírus). Sem ela, os e-mails não passam por antivírus.
MX_INGEST_URLProcesso MX: a origem da sua API mais /internal/mx (ex.: https://api.example.com/internal/mx).
MX_HOSTNAMEProcesso MX: o nome na saudação e no EHLO, normalmente igual ao RECEIVING_MX_HOST.
MX_PORTProcesso MX: porta de escuta (padrão 2525). Publique a porta 25 do host nela.
MX_TLS_CERT_PATH / MX_TLS_KEY_PATHProcesso MX: par de chaves PEM para STARTTLS (caminhos dentro do contêiner), as duas ou nenhuma. O STARTTLS é oferecido quando os arquivos existem, e um certificado renovado é carregado sem reiniciar.
MX_MAX_CLIENTS / MX_MAX_CONNECTIONS_PER_IP / MX_CONNECTIONS_PER_IP_PER_MINUTEProcesso MX: limites de conexão (padrões 200 ao mesmo tempo, 10 ao mesmo tempo por IP, 60 novas por IP por minuto).
MX_MAX_RECIPIENTSProcesso MX: destinatários aceitos por transação SMTP (padrão 50, no máximo 100); o servidor de quem envia entrega o resto em outra.
MX_SOCKET_TIMEOUT_MS / MX_SESSION_TIMEOUT_MSProcesso MX: tempo parado antes de derrubar a conexão (padrão 300000) e a duração máxima de uma sessão (padrão 600000).
MX_MAX_BUFFERED_MBProcesso MX: megabytes de mensagens que todas as sessões juntas podem manter na memória (padrão 512, mínimo 64); acima disso, quem envia recebe 452 e tenta de novo.

Configuração da AWS

A etapa de AWS do npx @millionsend/setup cria tudo de que o MillionSend precisa na AWS — política IAM + usuário + chave de acesso, o tópico SNS de eventos, a fila SQS de eventos (millionsend-events) que o worker consome por long polling e o configuration set do SES. Um APP_BASE_URL HTTPS recebe adicionalmente os eventos por push; a fila funciona sem URL pública nenhuma.

Rode onde houver Node 18+ e suas credenciais de administrador da AWS — laptop ou servidor; o servidor do MillionSend nunca precisa de credenciais de administrador. Ele verifica sua identidade AWS, mostra o plano, cria tudo e escreve as linhas AWS_* no .env do diretório atual (sem .env lá → imprime para você colar onde o MillionSend roda). --dry-run imprime o plano completo e sai.

npx @millionsend/setup teardown apaga tudo que o setup criou, incluindo todas as chaves de acesso do usuário IAM millionsend, então um servidor em execução para de enviar. Repetir o setup é seguro, mas cada execução gera uma nova chave de acesso — apague as antigas no console do IAM.

Sem Node no servidor? O mesmo CLI vem dentro da imagem — rode-o a partir do diretório de deploy, que ele lê e escreve como /work (o assistente não escreve nada fora dele, então rode-o como o seu usuário e o .env criado fica seu, com modo 600):

docker run --rm -it --user "$(id -u):$(id -g)" -e HOME=/home/ms -v ~/.aws:/home/ms/.aws:ro -v "$PWD":/work -w /work ghcr.io/millionsend/millionsend:latest setup

Prefere não rodar um CLI? A página Configurações → SES do painel oferece um link de quick-create do CloudFormation e um shell script pré-preenchido que criam os mesmos recursos.

Para rodar com uma role IAM em vez das chaves de acesso do usuário (um instance profile do EC2, IRSA no EKS ou outra web identity OIDC), anexe a política millionsend-ses à role e deixe AWS_ACCESS_KEY_ID e AWS_SECRET_ACCESS_KEY vazias, para a cadeia padrão de credenciais do SDK da AWS encontrar a role. Defina também AWS_DEFAULT_CHAIN=true: sem as chaves e sem essa variável, os e-mails de conta (redefinição de senha, confirmação de e-mail) ficam desligados e o painel avisa que as credenciais da AWS não estão configuradas. A política inclui a leitura da fila millionsend-events pelo worker; a política da própria fila só libera essa leitura para o usuário millionsend. Uma política criada antes dessa permissão existir precisa de uma versão nova. O assistente publica uma sempre que roda a etapa de IAM (uma nova execução completa ou o add-region), a partir do @millionsend/setup 0.9.2; um assistente mais antigo grava de volta o próprio documento, sem essa permissão. Uma stack do CloudFormation recebe a permissão quando é atualizada com o template atual. Para fazer à mão, salve o JSON de Configurações → SES (Manual: passos no console) como millionsend-ses.json e rode:

aws iam create-policy-version --policy-arn arn:aws:iam::<account-id>:policy/millionsend-ses \
  --policy-document file://millionsend-ses.json --set-as-default

Adicionando uma região

Uma instalação pode enviar por várias regiões do SES. Um domínio vive em uma região, a escolhida ao adicioná-lo (para mudá-la, exclua o domínio e adicione de novo); identidades, cota de 24 horas, taxa de envio e status de sandbox são todos por região; os eventos de todas as regiões caem na única fila SQS, porque o SNS entrega entre regiões.

Rode o comando add-region do assistente onde o .env está — o diretório de deploy, ou no servidor pela imagem, com credenciais de administrador de curta duração no ambiente (nada fica gravado):

npx @millionsend/setup add-region us-east-1
# ou, no servidor, a partir do diretório de deploy:
docker run --rm -it --user "$(id -u):$(id -g)" -e HOME=/home/ms \
  -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
  -v "$PWD":/work -w /work ghcr.io/millionsend/millionsend:latest setup add-region us-east-1

npx @millionsend/setup add-region precisa do @millionsend/setup 0.9.0 ou mais novo; a imagem já o traz. Sem terminal (stdin via pipe), o argumento de região é obrigatório e a confirmação precisa de uma linha yes explícita: o comando se recusa a adivinhar uma região, e uma resposta vazia vale não.

O assistente interativo oferece a mesma etapa como Add a region na etapa de AWS. Rodado em qualquer outro lugar, sem .env, o comando pergunta SQS_QUEUE_URL, SNS_TOPIC_ARNS, AWS_REGIONS e o APP_BASE_URL opcional (vazio: só a fila), confirma antes de criar qualquer coisa e imprime as duas linhas em vez de gravá-las.

Ele mantém o usuário IAM, a política e a chave de acesso (nenhuma chave nova), cria na nova região o tópico SNS, o configuration set do SES com seu event destination e a supressão apenas de bounces, inscreve o tópico na fila existente e acrescenta ao .env:

AWS_REGIONS=sa-east-1,us-east-1   # a primeira entrada continua sendo a região padrão
SNS_TOPIC_ARNS=<ARN do primeiro tópico>,<ARN do novo tópico>

AWS_REGION e SQS_QUEUE_URL ficam como estão. Reinicie a stack (docker compose up -d): a região passa a aparecer no formulário de novo domínio e em Configurações → SES, marcada como Sandbox até a AWS conceder acesso de produção lá — peça por região, como na primeira. Enquanto uma região tem acesso de produção, uma região em sandbox aparece no formulário mas não pode ser escolhida; uma região em sandbox ritma os próprios envios a 1/s e segura só os próprios domínios quando sua cota de 24 horas acaba.

Preço: o SES cobra US$ 0,10 por 1.000 e-mails à la carte, mais US$ 0,12 por GB de anexos. Desde 2026-07-21 uma conta × região do SES sem envios anteriores começa no plano Essentials (US$ 0,16 por 1.000 mensagens). Depois de provisionar, o assistente lê o plano da região e, se for Essentials, pergunta se deve cancelá-lo; nada que o MillionSend usa precisa de plano, e o cancelamento de um plano atribuído por padrão vale na hora. À mão: aws sesv2 put-account-pricing-attributes --plan NONE --region <região>.

Equivalente manual: na nova região, o tópico SNS, o configuration set e a supressão exatamente como em Eventos do SES; uma inscrição sqs desse tópico no ARN da fila existente, e a política da fila estendida para permitir sqs:SendMessage também a partir do ARN do novo tópico; depois as duas linhas de .env acima e um reinício.

Eventos do SES (bounces, reclamações e entregas)

O CLI de setup sempre configura isto: uma fila SQS (millionsend-events) que o worker consome por long polling, com a URL no .env como SQS_QUEUE_URL. A fila segura os eventos durante reinícios e não precisa de acesso externo, por isso é o transporte que toda implantação recebe; um APP_BASE_URL HTTPS público recebe adicionalmente uma assinatura SNS com push para o seu host, e o app deduplica os dois. SNS_TOPIC_ARNS controla a ingestão nos dois casos: eventos só são aceitos de tópicos nessa lista. Mantenha SQS_QUEUE_URL definida mesmo depois de trocar para um APP_BASE_URL HTTPS — apagá-la deixa os eventos acumulando na fila.

Equivalente manual: um tópico SNS standard (mesma região do SES) inscrito em https://<seu-host>/ses/events (ou em uma fila SQS cuja política permita o envio pelo tópico e cuja URL esteja no .env como SQS_QUEUE_URL), com o ARN no .env como SNS_TOPIC_ARNS; um configuration set do SES com um event destination apontando para o tópico (tipos de evento: Delivery, Delivery Delay, Bounce, Complaint, Reject, Rendering Failure — NÃO inscreva Open nem Click, pois isso faz o SES reescrever todo link e injetar o próprio pixel, enquanto o MillionSend rastreia o engajamento por conta própria), com o nome no .env como SES_CONFIGURATION_SET. Reinicie depois de defini-los. Sem SES_CONFIGURATION_SET, os envios saem sem configuration set e não emitem eventos.

O assistente também configura a lista de supressão da conta do SES para apenas bounces. Essa lista é por região e compartilhada por todas as equipes da instância: uma caixa que deu hard bounce está morta para todo mundo, então o SES pode barrá-la para a conta inteira, mas uma denúncia de spam diz respeito ao e-mail de um remetente — o MillionSend a suprime só para aquela equipe, e deixada na lista do SES ela bloquearia também o recibo de outra equipe ou uma redefinição de senha para a mesma pessoa. Se você provisionou à mão ou com o template do CloudFormation, ajuste você mesmo no console do SES (Suppression list → Account-level settings) ou com aws sesv2 put-account-suppression-attributes --suppressed-reasons BOUNCE.

A assinatura SNS por HTTPS se confirma sozinha quando o app roda com SNS_TOPIC_ARNS definido; se ficar pendente, use "Request confirmation" nela no console do SNS. Assinaturas SQS na mesma conta não precisam de confirmação.

O endpoint da assinatura é {APP_BASE_URL}/ses/events, mas quem serve esse caminho é o processo da API, não o painel: um reverse proxy na frente do hostname do painel precisa encaminhar esse único caminho para a API (a seção de nginx faz isso), ou o POST de confirmação cai no painel, recebe 404, e a assinatura fica pendente com todo bounce e entrega perdidos.

Tenants do SES (reputação por equipe)

Com SES_TENANTS=true (padrão no MillionSend Cloud) cada equipe ganha o próprio tenant do SES, nomeado pelo id da equipe, em cada região onde tem um domínio. A identidade do domínio e o SES_CONFIGURATION_SET compartilhado são associados ao tenant na criação do domínio, e todo envio a partir dele nomeia o tenant, então o SES mantém as métricas de bounce e reclamação — e a própria pausa de envio — por cliente, não por conta. Domínios anteriores à flag, ou cuja associação falhou, são tratados pelo job tenants.sync de hora em hora. Suspender uma equipe pelo console coloca o status de envio do tenant em DISABLED em cada região onde a equipe tem um domínio ou uma associação do domínio compartilhado, e reativá-la volta para ENABLED (nunca REINSTATED, para que as constatações de reputação do próprio SES continuem valendo). Uma falha na chamada à AWS nunca segura a suspensão: o console avisa, o registro de auditoria anota a falha e o worker tenta de novo com espera crescente. A política IAM que o assistente e o template do CloudFormation instalam inclui as ações ses:CreateTenant, ses:GetTenant, ses:DeleteTenant, ses:CreateTenantResourceAssociation, ses:DeleteTenantResourceAssociation e ses:UpdateReputationEntityCustomerManagedStatus; uma implantação existente roda o assistente de novo (ou atualiza a política millionsend-ses) antes de ligar a flag.

Com o SHARED_INBOX_TENANTS também ligado (veja Domínio compartilhado das caixas), os e-mails dos endereços compartilhados de uma equipe também nomeiam o tenant dela. O primeiro envio compartilhado da equipe associa a identidade do domínio compartilhado e o SES_CONFIGURATION_SET ao tenant dela na região padrão, o mesmo tenant que os domínios dela lá usam, e uma região ou um conjunto de configuração diferente faz a associação de novo. Quando o SES recusa a associação, o e-mail sai sem tenant, como quando a opção está desligada, o worker registra um aviso, e aquele processo do worker deixa a equipe em paz por uma hora. Um envio que o SES recusa porque a identidade não está associada ao tenant sai mais uma vez sem ele; o e-mail de um tenant pausado ou desativado nunca sai assim.

Para atualizar a política sem recriar nada, publique o JSON que a página de configurações do SES mostra como nova versão padrão:

aws iam create-policy-version --policy-arn arn:aws:iam::<account-id>:policy/millionsend-ses \
  --policy-document file://millionsend-ses.json --set-as-default

Relay SMTP

Um relay SMTP pronto para software que fala SMTP em vez de HTTP — apps legados, plugins de CMS, qualquer coisa com um formulário de "configurações SMTP". As mensagens passam pelo mesmo pipeline de aceitação do POST /emails: mesma verificação de domínio, checagens de supressão, log de requisições e eventos de entrega. Enquanto os envios da equipe estão pausados pela taxa de bounce ou de reclamação, o relay responde 451 4.7.1 após o DATA, e o cliente guarda a mensagem e tenta de novo; uma equipe suspensa recebe 550. Dos headers da própria mensagem, o relay só leva In-Reply-To e References; headers personalizados como X-* ou OTP-Token precisam do POST /emails.

Cada mensagem conta no limite por minuto da sua chave de API, o mesmo usado pelas requisições dessa chave à API (API_RATE_LIMIT_PER_MINUTE, padrão 600), e, como a API, o relay limita cada equipe a 3.000 mensagens por minuto, somando todas as suas chaves. A contagem acontece no MAIL FROM, antes de a mensagem ser enviada: se passar de qualquer um dos limites, o relay responde 451 4.7.1 com os segundos que faltam para o próximo minuto, e o cliente guarda a mensagem e tenta de novo.

Um destinatário cujo domínio nunca recebe e-mail (veja Erros) recebe 550 5.1.2 no RCPT TO, com a mesma mensagem da API, e o relay continua aceitando os outros destinatários. Se ele era o único destinatário no To da mensagem e outro destinatário foi aceito, a mensagem depois falha no DATA com 553; se nenhum destinatário foi aceito, o DATA responde 503.

Dados de conexão:

  • Host: onde o serviço smtp estiver acessível (os arquivos compose o publicam no host do Docker). A aba Configurações → SMTP do painel mostra o host do APP_BASE_URL; defina SMTP_PUBLIC_HOST quando o relay atende por outro nome. Só essa aba lê a variável.
  • Porta: 2587 (SMTP_PORT para mudar).
  • Usuário: millionsend (fixo).
  • Senha: uma chave de API ms_ do painel.
  • Criptografia: STARTTLS é oferecido (e exigido antes do AUTH) quando SMTP_TLS_CERT_PATH e SMTP_TLS_KEY_PATH apontam para um par PEM. Sem ele, o relay se recusa a iniciar, a menos que SMTP_ALLOW_INSECURE_AUTH=true seja habilitado explicitamente para uma rede privada confiável.

STARTTLS com os certificados que você já tem

Antes de expor o relay à internet, dê a ele um certificado — sem isso, o SMTP AUTH envia a chave de API em texto puro. Qualquer par PEM funciona, e se você seguiu o guia de nginx acima já tem um: reutilize o certificado Let's Encrypt que o certbot emitiu para o seu domínio. Monte-o no contêiner smtp com um docker-compose.override.yml:

services:
  smtp:
    volumes:
      - /etc/letsencrypt/live/mail.example.com:/certs:ro

e aponte as variáveis no .env:

SMTP_TLS_CERT_PATH=/certs/fullchain.pem
SMTP_TLS_KEY_PATH=/certs/privkey.pem

Com ambos definidos, o STARTTLS passa a ser exigido antes do AUTH — as credenciais nunca cruzam a rede sem criptografia. Monte o diretório live/<domínio> (um symlink que o certbot mantém atualizado), não uma cópia dos arquivos, para que a renovação caia no mesmo caminho — e reinicie o relay depois de cada renovação, já que ele lê o par de chaves ao iniciar (certbot: --deploy-hook 'docker compose -f /opt/millionsend/docker-compose.yml restart smtp'). Um wildcard ou qualquer outro PEM emitido por CA funciona da mesma forma.

Exemplo com Nodemailer:

import nodemailer from "nodemailer";

const transport = nodemailer.createTransport({
  host: "localhost",
  port: 2587,
  auth: { user: "millionsend", pass: "ms_..." },
});

await transport.sendMail({
  from: "you@yourdomain.com",
  to: "success@simulator.amazonses.com",
  subject: "Hello",
  html: "<p>Sent over SMTP.</p>",
});

O serviço smtp está definido nos dois arquivos compose atrás do profile smtp, então fica desligado até ser pedido: com o par de chaves montado, adicione smtp a COMPOSE_PROFILES no .env (separado por vírgula de outros) e rode docker compose up -d.

Recebimento (beta)

O recebimento precisa de mais um processo: o MX, que atende SMTP na porta 25 para os domínios de recebimento das suas equipes e entrega cada mensagem à API por HTTP. Ele não tem banco de dados nem chaves, só as variáveis MX_*, então pode rodar ao lado do app ou numa máquina pequena separada com a porta 25 aberta, que é como o MillionSend Cloud o roda. Ele nunca repassa e-mail, não oferece AUTH e nunca envia bounce: recusa durante a sessão SMTP.

  1. Dê um nome a ele. Aponte um registro A como mx.example.com para o IPv4 público do host que vai rodar o MX e garanta que a porta TCP 25 de entrada chegue até ele; alguns provedores bloqueiam a porta 25 até você pedir. Não crie registro AAAA: a porta 25 é publicada só em IPv4, porque em IPv6 o proxy do Docker troca o endereço de cada remetente pelo dele, o que quebra o SPF e os limites por endereço.

  2. Ligue o recebimento no app. Defina RECEIVING_MX_HOST=mx.example.com e MX_INGEST_SECRET (openssl rand -hex 32) no .env e rode docker compose up -d. Os domínios já podem ativar o recebimento, e o registro de recebimento deles aponta para mx.example.com.

  3. Dê um certificado a ele. O serviço mx fala com a API pela rede do compose, se apresenta como RECEIVING_MX_HOST e oferece STARTTLS com o par de chaves em mx-tls/, ao lado do docker-compose.yml. Um deploy hook do certbot copia os arquivos para lá, com dono root:1000 e modo 0640; o compose coloca o usuário não root do contêiner no grupo 1000, para ele ler os arquivos sem que fiquem legíveis para todo mundo. Com o nginx configurado na porta 80 (ou --standalone no lugar de --nginx quando a porta 80 estiver livre):

    sudo install -d -m 0750 -o root -g 1000 /opt/millionsend/mx-tls
    sudo certbot certonly --nginx -d mx.example.com --deploy-hook \
      'install -m 0640 -o root -g 1000 "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" /opt/millionsend/mx-tls/'

    O certbot roda o hook a cada renovação, e o MX carrega os arquivos novos em até cinco minutos, sem reiniciar. Sem certificado, o e-mail chega do mesmo jeito, só que sem criptografia.

  4. Suba o MX. Adicione mx ao COMPOSE_PROFILES e rode docker compose up -d: o serviço mx roda PROCESS=mx e publica a porta 25 do host na porta 2525 dele.

De outra máquina, nc mx.example.com 25 deve responder com uma saudação 220 com o nome mx.example.com. Depois ative o recebimento num domínio verificado, ligue o pega-tudo dele ou crie uma caixa de entrada nele, publique o registro MX dele e mande uma mensagem para ele.

Para rodar o MX em outro host, use o deploy/mx do repositório com MX_INGEST_URL (a origem da sua API mais /internal/mx), MX_HOSTNAME e o mesmo MX_INGEST_SECRET. Aí as entregas chegam à API pelo seu proxy reverso: uma mensagem pode ter 40 MB, então o limite de tamanho de requisição do proxy para a API tem que permitir isso (o exemplo de nginx permite 60 MB), e o envelope dela vai num único cabeçalho de até uns 13 KB, acima dos 8 KB padrão do nginx (o exemplo sobe para 16 KB). Os endpoints internos do MX respondem 404 a não ser que RECEIVING_MX_HOST esteja definida, e recusam qualquer requisição sem o segredo.

Quando uma versão muda o que o MX manda para o app, como os resultados de autenticação detalhados que o MX registra para cada mensagem, atualize primeiro o app e depois um MX separado. Qualquer ordem mantém os e-mails chegando: um app mais antigo ignora os detalhes novos, e um app mais novo aceita e-mails sem eles. O serviço mx do compose roda a própria imagem do app, então acompanha o app.

Configurações → Recebimento mostra para quem opera o que a instância recebe: o host MX, a última mensagem, o total de hoje, o antivírus e o domínio compartilhado das caixas.

Domínio compartilhado das caixas

Opcional. Com um domínio compartilhado, as equipes criam caixas de entrada que não precisam de DNS próprio: cada equipe ganha um subdomínio, como acme.agents.example, e cada caixa um endereço nele, com um nome de usuário que a equipe escolhe ou aleatório. Essas caixas nunca expiram, a não ser que alguém peça, e uma que ninguém usa há 90 dias é excluída. O que elas podem enviar é você quem decide (abaixo); de fábrica, elas só respondem dentro das próprias conversas, para no máximo 10 destinatários por e-mail e 50 por dia por caixa.

Use um domínio só para isso, não um subdomínio de um domínio pelo qual as suas equipes enviam: assim os e-mails que os agentes dos clientes mandam por ele, e qualquer abuso dele, ficam longe da reputação do seu domínio principal. Depois de definido, nenhuma equipe consegue adicioná-lo, nem um nome abaixo dele, como domínio de envio.

  1. DNS. No provedor de DNS do domínio, um registro MX no próprio domínio recebe os e-mails de postmaster@ e abuse@ dele, e um registro MX curinga cobre o subdomínio de todas as equipes, então equipes novas não precisam de mudança no DNS:

    TipoNomeValor
    MXagents.examplemx.example.com (o seu RECEIVING_MX_HOST), prioridade 10
    MX*.agents.examplemx.example.com, prioridade 10
    TXT_dmarc.agents.examplev=DMARC1; p=reject
  2. Identidade do SES. Verifique agents.example como identidade de domínio com Easy DKIM, e publique os três registros DKIM dela, na região padrão do SES da instância: a primeira de AWS_REGIONS, ou a AWS_REGION. O e-mail dos endereços compartilhados não tem domínio de equipe, então sai dessa região e do conjunto de configuração padrão dela, com o tenant do SES da equipe que envia quando o SHARED_INBOX_TENANTS está ligado. Uma identidade de domínio verificada deixa o SES enviar por qualquer subdomínio dela sem verificar um por um (AWS: Creating and verifying identities).

  3. Ligue. Defina SHARED_INBOX_DOMAIN=agents.example no .env e rode docker compose up -d. O app se recusa a subir quando o valor não é um hostname em minúsculas, ou quando falta o RECEIVING_MX_HOST. A partir daí, cada equipe ganha o subdomínio dela quando é criada, e as equipes que já existem ganham o delas quando o worker subir de novo, com uma revisão de nomes para cada uma quando há um avaliador configurado.

  4. Receba o postmaster@ e o abuse@. Os e-mails para postmaster@ e abuse@ no domínio, ou no subdomínio de qualquer equipe, vão para a sua própria equipe, nunca para a equipe dona do subdomínio: a equipe mais antiga no plano system (Console → Equipes → Alterar plano → System). Eles chegam em Todos os recebidos dessa equipe como e-mails do pega-tudo, com o antivírus e a avaliação de spam de qualquer e-mail recebido, mas sem regras de caixa, sem análise e sem limite. Sem uma equipe system, eles são recusados com 550 5.1.1, e enquanto essa equipe está suspensa, com 550 5.7.1.

  5. Confira. dig +short MX agents.example e dig +short MX test.agents.example mostram o seu host MX. Crie uma caixa no domínio compartilhado pelo painel, deixando o nome de usuário vazio, mande uma mensagem para ela e responda pela caixa para um endereço seu: os cabeçalhos da resposta devem mostrar o DKIM passando para agents.example e o DMARC passando. Um e-mail para postmaster@agents.example deve chegar em Todos os recebidos da sua equipe system.

O que os endereços compartilhados podem enviar

Quatro configurações decidem isso, editadas no console em Confiança e segurança → Configurações de monitoramento (Domínio compartilhado), ou definidas nas variáveis de ambiente até serem editadas lá; o console prevalece:

ConfiguraçãoPadrãoSignificado
SHARED_INBOX_SENDtrueSe os endereços compartilhados enviam alguma coisa. Desligada, nada sai deles, nem respostas, e o que já está na fila espera até ela voltar (as cópias do forward_to, por até 2 dias); os e-mails para eles continuam chegando
SHARED_INBOX_OPENfalseSe os endereços compartilhados das equipes pagas (de todas, na auto-hospedagem) podem começar conversas. Desligada, sem avaliador (ABUSE_JUDGE) ou com o MONITOR_AUTO_HOLD desligado, eles só respondem dentro das próprias conversas
SHARED_INBOX_TENANTSfalseSe o e-mail compartilhado sai com o tenant do SES da equipe que envia, com SES_TENANTS ligado
SHARED_INBOX_TEAM_DAILY_CAP1000Para quantos destinatários uma equipe pode enviar por dia UTC, somando todos os endereços compartilhados dela, envios aprovados incluídos

Com o envio aberto, a caixa compartilhada de uma equipe segue o próprio send_policy (o allowlist nunca existe lá), e cada mensagem para alguém de fora da equipe que nunca escreveu para a caixa é avaliada antes do SES, em todo nível menos o da equipe de sistema: uma avaliação do tipo phishing a partir de MONITOR_HOLD_REPEAT_SCORE deixa a mensagem em espera na lista de revisão, uma avaliação dessas pausa para revisão uma equipe no nível Nova, e quando o avaliador não responde, o e-mail espera e depois fica em espera como o de um domínio novo.

Para abrir o envio compartilhado numa instância com SES_TENANTS ligado (sem ele, pule para o passo 4):

  1. Confira a cota de tenants do SES na região padrão: cada equipe que envia pelo domínio compartilhado precisa de um lá, o mesmo que os domínios dela nessa região usam.

  2. Teste o SES uma vez com um tenant descartável que tenha só a identidade do domínio compartilhado e o conjunto de configuração, enviando de um endereço num subdomínio:

    aws sesv2 create-tenant --region <region> --tenant-name shared-probe
    aws sesv2 create-tenant-resource-association --region <region> --tenant-name shared-probe \
      --resource-arn arn:aws:ses:<region>:<account-id>:identity/agents.example
    aws sesv2 create-tenant-resource-association --region <region> --tenant-name shared-probe \
      --resource-arn arn:aws:ses:<region>:<account-id>:configuration-set/<SES_CONFIGURATION_SET>
    aws sesv2 send-email --region <region> --tenant-name shared-probe \
      --configuration-set-name <SES_CONFIGURATION_SET> \
      --from-email-address probe@test.agents.example \
      --destination ToAddresses=success@simulator.amazonses.com \
      --content '{"Simple":{"Subject":{"Data":"Probe"},"Body":{"Text":{"Data":"Probe"}}}}'

    Depois desative o tenant com o TenantArn que o create-tenant mostrou, rode o mesmo send-email de novo e exclua o tenant:

    aws sesv2 update-reputation-entity-customer-managed-status --region <region> \
      --reputation-entity-type RESOURCE --reputation-entity-reference <TenantArn> \
      --sending-status DISABLED
    aws sesv2 delete-tenant --region <region> --tenant-name shared-probe

    O primeiro envio precisa passar, e o segundo precisa falhar com um erro que não fale de associação: só com esse erro o e-mail compartilhado é enviado de novo sem o tenant. Se algum dos dois for diferente, deixe o SHARED_INBOX_TENANTS desligado.

  3. Ligue o SHARED_INBOX_TENANTS.

  4. Confira se o avaliador revisa os nomes: com o ABUSE_JUDGE definido, criar uma caixa compartilhada com um nome de usuário responde 201, não 503 name_review_unavailable.

  5. Ligue o SHARED_INBOX_OPEN.

Antivírus

Opcional. Com CLAMAV_HOST definida, todo e-mail recebido passa pelo clamd do ClamAV antes de ser guardado: o e-mail infectado fica retido e os downloads dele ficam bloqueados para sempre (veja Recebimento). Os dois arquivos do compose trazem um serviço clamav:

  1. Adicione clamav ao COMPOSE_PROFILES e defina CLAMAV_HOST=clamav:3310 no .env.
  2. docker compose up -d.

O serviço roda a imagem oficial do ClamAV em Debian, clamav/clamav-debian:1.4, publicada para amd64 e arm64 (a imagem clamav/clamav, baseada em Alpine, só existe para amd64). Ela mantém as assinaturas atualizadas sozinha no volume clamav-db. O clamd escuta na porta 3310 só dentro da rede do compose: o protocolo dele não tem autenticação nem criptografia, então nunca publique essa porta. Garanta memória no servidor: o clamd guarda as assinaturas na memória, mais de 1 GiB e cerca do dobro por um instante quando as recarrega a cada dia, e o ClamAV recomenda 4 GiB de RAM para ele. Depois de cada início, ele leva até um minuto para carregá-las.

Um clamd em outro lugar também funciona, numa rede privada: CLAMAV_HOST=scanner.internal:3310. As mensagens vão para ele pelo comando INSTREAM do clamd, então o StreamMaxLength dele precisa ser de pelo menos 40 MB.

Quando o clamd não responde (parado, ainda carregando, mais lento que 30 segundos), o e-mail é entregue com virus_verdict: "processing_failed" e um aviso, e a falha vai para o rastreamento de erros: uma queda do antivírus nunca segura e-mails.

Análise das caixas de agentes

As caixas de agente analisam os e-mails de remetentes de fora em busca de injeção de prompt com o avaliador do monitoramento de conteúdo: com ABUSE_JUDGE=typesafe a análise roda, e com o avaliador desligado as caixas decidem só pelas próprias regras. Ela conta à parte do monitoramento de conteúdo, com os mesmos valores de MONITOR_TEAM_DAILY_CAP e MONITOR_INSTANCE_DAILY_CAP, mais 200 mensagens por caixa por dia; acima de qualquer um deles, o e-mail que precisa de análise vai para a quarentena em vez de ser entregue sem análise. Os resultados dela nunca mexem no risco de uma equipe nem seguram os envios dela.

Cada mensagem analisada manda para a TypeSafe o remetente e os resultados de autenticação dele, o assunto e o texto visível com os códigos mascarados e os endereços reduzidos ao domínio, o texto oculto que foi removido, o texto e o domínio dos links (nunca acessados) e os nomes dos anexos. São e-mails de terceiros para os seus usuários: inclua isso no seu aviso de privacidade antes de ligar o avaliador.

Site de documentação

A imagem também pode servir este site de documentação: um serviço docs do compose roda com PROCESS=docs e publica a porta 3002 (ajustável no host via DOCS_PORT). Não precisa de banco e é totalmente opcional; no arquivo standalone ele fica atrás do profile docs (COMPOSE_PROFILES=docs).

Produção: nginx + TLS

O formato recomendado de produção: o nginx no host termina o TLS e faz proxy de um hostname por serviço, e as portas do compose ficam vinculadas ao loopback para que o nginx seja o único caminho de entrada. A API precisa do próprio hostname (ou de uma porta exposta): as rotas dela (/emails, /domains, …) dividem caminhos com páginas do painel, então os dois não conseguem repartir um hostname por caminho. Defina PUBLIC_API_URL com esse hostname — é o que o painel mostra como base da API e ao que os tokens MCP ficam vinculados; sem definir, a API é assumida na porta 3001 do host do painel.

/etc/nginx/conf.d/millionsend.conf:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      close;
}

# Painel.
server {
    listen 80;
    server_name mail.example.com;

    # O editor de broadcasts envia corpos HTML completos pelo painel.
    client_max_body_size 25m;

    # Eventos do SES: o SNS é inscrito em {APP_BASE_URL}/ses/events, e quem
    # serve esse caminho é o processo da API, não o painel.
    location = /ses/events {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Host $host;
        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 $connection_upgrade;
    }
}

# API.
server {
    listen 80;
    server_name api.example.com;

    # A maior requisição é uma mensagem recebida de até 40 MiB que o MX
    # entrega em /internal/mx; a API limita todo o resto a 25 MiB. 60m cobre
    # isso sem permitir uploads ilimitados.
    client_max_body_size 60m;
    # O MX manda o envelope de cada entrega num cabeçalho de até uns 13 KB,
    # acima dos 8 KB padrão do nginx; um 400 ali faz esse e-mail ser
    # adiado até o remetente desistir.
    large_client_header_buffers 4 16k;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Docs (opcional).
server {
    listen 80;
    server_name docs.example.com;

    location / {
        proxy_pass http://127.0.0.1:3002;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Host $host;
        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 $connection_upgrade;
    }
}

TLS e o redirecionamento http→https em uma linha — o certbot reescreve os blocos acima para escutar na 443 com certificados Let's Encrypt, adiciona o redirecionamento e instala a renovação automática:

sudo certbot --nginx --redirect -d mail.example.com -d api.example.com -d docs.example.com

Depois defina APP_BASE_URL=https://mail.example.com e PUBLIC_API_URL=https://api.example.com no .env e reinicie. APP_BASE_URL precisa ser a origem https pública exata do painel — qualquer outro valor faz login e cadastro falharem com erro de "invalid origin". Encaminhe Host e X-Forwarded-Host para os upstreams do painel e da documentação como acima, para que qualquer URL absoluta que os apps derivem da requisição use o hostname público em vez de localhost.

Os endereços dos clientes (limites de tentativas de login, os IPs gravados nas sessões e nos cadastros, aberturas e cliques) vêm de X-Forwarded-For. Com proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, cada proxy acrescenta o endereço de quem se conectou a ele, e o processo web percorre o header da direita para a esquerda pulando os endereços de TRUSTED_PROXIES (IPs ou CIDRs separados por vírgula; padrão 127.0.0.1,::1): o primeiro que não estiver na lista é o cliente. Atrás de um único reverse proxy como este nginx, não precisa definir nada, onde quer que ele rode. Liste só os proxies na frente dele que também acrescentam ao header, como um CDN ou um load balancer; fora da lista, todo cliente que passa por eles aparece com o endereço deles. Nunca liste o CIDR inteiro de pods ou da VPC: todo mundo que chama de dentro dele passaria a dividir o mesmo limite de tentativas. O processo web aceita o header de quem se conectar, então deixe web e api acessíveis só pelo reverse proxy. O limite de falhas de autenticação da API não lê o header: ele conta pelo endereço da conexão.

Os arquivos compose vinculam toda porta de aplicação ao loopback por padrão (WEB_BIND_ADDRESS, API_BIND_ADDRESS, DOCS_BIND_ADDRESS, SMTP_BIND_ADDRESS, todas 127.0.0.1), então só um reverse proxy local as alcança. O Docker publica portas editando o iptables diretamente, então não conte com um firewall de host para compensar um bind público: defina um *_BIND_ADDRESS como 0.0.0.0 só para um serviço que precise ser alcançado diretamente.

O relay SMTP (:2587) é TCP, não HTTP — um bloco server de http não consegue fazer proxy dele. Ou publique-o diretamente (SMTP_BIND_ADDRESS=0.0.0.0 e abra o firewall), ou mantenha-o no loopback e passe o stream TCP pelo módulo stream do nginx — os bytes passam intactos, então o STARTTLS continua terminando no relay via SMTP_TLS_CERT_PATH/SMTP_TLS_KEY_PATH:

# /etc/nginx/nginx.conf — nível superior, fora do bloco http {}
stream {
    server {
        listen 2587;
        proxy_pass 127.0.0.1:2587;
    }
}

Firewall: libere 80 e 443, mais a 2587 só se o relay SMTP for usado de fora; todo o resto fechado:

sudo ufw default deny incoming
sudo ufw allow 80,443/tcp
sudo ufw allow 2587/tcp   # só se o relay SMTP estiver exposto
sudo ufw enable

Hosts de rastreamento com marca

Uma equipe pode servir os links rastreados e o pixel de abertura de um subdomínio do próprio domínio, como links.exemplo.com.br (veja a configuração por domínio). O CNAME dele aponta para TRACKING_EDGE_HOST, ou para o host deste app enquanto ele não estiver definido, e os links passam para o subdomínio assim que o CNAME resolve. Quem responde HTTPS pelo subdomínio precisa ter um certificado para ele, senão os links da equipe falham:

  • nginx com certbot, como acima, só tem certificados para os nomes que você lista: acrescente o host de rastreamento de cada equipe ao server_name do painel e ao comando do certbot, ou rode o edge de rastreamento.
  • Atrás do Cloudflare (um registro com proxy ou um Tunnel), o Cloudflare não tem certificado para os hostnames das suas equipes. Rode o edge de rastreamento num hostname somente DNS, ou adicione cada host de rastreamento à sua zona como hostname personalizado do Cloudflare for SaaS (os 100 primeiros vêm incluídos nos planos Free, Pro e Business) e defina TRACKING_EDGE_HOST como o hostname para onde os CNAMEs deles devem apontar.

Os links de uma equipe sem subdomínio de rastreamento passam pelo APP_BASE_URL, que funciona atrás de qualquer proxy.

O edge de rastreamento (deploy/tracking-edge/ no repositório) é um contêiner Caddy numa máquina com IPv4 público e as portas 80 e 443 livres. Ele emite um certificado na primeira vez que cada host de rastreamento é pedido, depois de perguntar ao app se esse host pertence a um domínio verificado, e só encaminha /t/c/* e /t/o/* de volta para o app. O README dele explica a máquina passo a passo. No .env do app:

TRACKING_EDGE_HOST=track.example-dns.com    # o hostname do próprio edge, um registro A somente DNS
TRACKING_ASK_SECRET=<openssl rand -hex 32>  # o mesmo valor vai no ASK_URL do edge
TRUSTED_PROXIES=<o que já lista>,<o endereço do edge>

O TRUSTED_PROXIES precisa do endereço do edge: toda abertura e todo clique chegam ao app por ele e, sem essa entrada, cada um registra o edge como o destinatário.

Armazenamento de objetos (logos das equipes)

Opcional. Com um bucket compatível com S3 configurado, administradores da equipe podem enviar uma logo pelo painel; ela também marca as páginas hospedadas de descadastro quando a marca do MillionSend está oculta. UM conjunto de credenciais S3_* é compartilhado com o job de backup abaixo — cada recurso é então habilitado pela sua própria variável de bucket.

A etapa de armazenamento do npx @millionsend/setup pergunta o endpoint e as chaves, cria (ou adota) os dois buckets — millionsend-storage e millionsend-backups por padrão — e escreve as linhas S3_* no .env. A única coisa que ela não consegue fazer pela API S3 é tornar público o bucket de uploads: no R2, habilite o acesso público no bucket (ou conecte um domínio personalizado) e defina essa URL — os uploads são endereçados como ${S3_STORAGE_PUBLIC_URL}/<chave>:

S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_STORAGE_BUCKET=millionsend-storage
S3_STORAGE_PUBLIC_URL=https://<url-publica-do-bucket-ou-dominio-customizado>

Mantenha os dois buckets separados: o acesso público do R2 vale para o bucket inteiro, então um dump do banco no bucket público de uploads ficaria legível para o mundo todo.

Backups

O serviço backup do compose faz um pg_dump agendado do Postgres e o envia para qualquer bucket compatível com S3 via rclone — o Cloudflare R2 funciona sem ajustes. Vem desligado por padrão: sem S3_BACKUP_BUCKET o contêiner imprime backups disabled — set S3_BACKUP_BUCKET to enable e depois fica ocioso, sem fazer nada.

Ative definindo as credenciais S3 compartilhadas e um bucket de backup no .env (a etapa de armazenamento do assistente de setup cria o bucket e escreve essas linhas). O bucket precisa existir antes do primeiro dump e deve permanecer privado — os dumps contêm o banco de dados inteiro, e o acesso público do R2 vale para o bucket inteiro, então nunca reutilize o bucket público de uploads. Para o R2 os padrões S3_PROVIDER=Cloudflare e S3_REGION=auto já estão corretos:

S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_BACKUP_BUCKET=millionsend-backups

Depois adicione backup a COMPOSE_PROFILES no .env e rode docker compose up -d: o serviço faz um dump imediatamente e, depois disso, um por dia no BACKUP_CRON (padrão 0 3 * * *, UTC). Esse primeiro dump é pulado quando o bucket já tem um das últimas 26 horas, para que reinícios e atualizações não façam outro: um dump mantém um bloqueio em todas as tabelas até terminar, e as migrações do app ficariam esperando por ele. Só a forma diária <minuto> <hora> * * * é aceita — o sidecar roda sem privilégios como postgres, com todas as capabilities removidas, então a agenda é um loop de sleep em vez de crond, e qualquer outro formato faz o serviço sair com código 1. Cada dump é um pg_dump -Fc (formato custom comprimido, nomeado millionsend-YYYYMMDD-HHMMSS.dump), o tamanho enviado é verificado contra o bucket antes de qualquer outra coisa, e dumps mais antigos que BACKUP_RETENTION_DAYS (padrão 14) são removidos. S3_BACKUP_PREFIX (padrão backups) define o prefixo das chaves. Outros serviços compatíveis com S3 funcionam definindo S3_PROVIDER com o nome do provider no rclone (AWS, Minio, …) e uma região real se o endpoint exigir.

Defina BACKUP_AGE_RECIPIENT com uma chave pública age (age1…) para criptografar cada dump antes do upload (.dump.age); o bucket nunca guarda uma cópia legível do banco. Mantenha a chave privada correspondente junto com MASTER_ENCRYPTION_KEY, e na restauração rode age --decrypt -i <arquivo da chave> antes do pg_restore.

Os dumps contêm corpos de e-mail criptografados com MASTER_ENCRYPTION_KEY — faça backup dessa chave separadamente, ou os corpos restaurados ficam irrecuperáveis.

O deploy/docker-compose.yml standalone roda a imagem publicada ghcr.io/millionsend/backup (MILLIONSEND_BACKUP_IMAGE a fixa, como MILLIONSEND_IMAGE fixa o app); um clone do repositório a constrói a partir de scripts/backup. A restauração usa o mesmo contêiner nos dois casos.

Para um dump avulso (antes de um salto grande de versão, por exemplo), rode um ciclo à mão; ele faz o dump mesmo que o bucket já tenha um recente:

docker compose run --rm --entrypoint /usr/local/bin/backup.sh backup

Restauração

Pare o app antes para que nada escreva no meio da restauração:

docker compose stop millionsend smtp
# liste o bucket, escolha um dump
docker compose run --rm --entrypoint /usr/local/bin/backup.sh backup \
  sh -c 'rclone lsl ":s3:$S3_BACKUP_BUCKET/${S3_BACKUP_PREFIX:-backups}"'
# baixe-o e restaure por cima do banco atual
docker compose run --rm --entrypoint /usr/local/bin/backup.sh backup \
  sh -c 'rclone copyto ":s3:$S3_BACKUP_BUCKET/${S3_BACKUP_PREFIX:-backups}/millionsend-YYYYMMDD-HHMMSS.dump" /tmp/restore.dump \
    && pg_restore --clean --if-exists -d "$DATABASE_URL" /tmp/restore.dump'
docker compose start millionsend smtp

O pg_restore termina com código 1 mesmo quando a restauração dá certo: o --clean não consegue apagar as chaves primárias que as partições das tabelas do pg-boss herdam. Ele mostra um cannot drop inherited constraint para cada uma e, no fim, errors ignored on restore; essas tabelas são apagadas e restauradas mesmo assim. Se esses forem os únicos erros, a restauração funcionou.

Política de cadastro

O primeiro usuário a se registrar vira a conta inicial — sem configuração nenhuma. Depois disso o registro fica fechado: qualquer pessoa com conta pode criar chaves de API que enviam pela sua conta SES, então o cadastro fica desligado a menos que você opte por abri-lo com ALLOW_SIGNUP=true. Mantenha a porta 3000 fora da internet pública a menos que tenha aberto o cadastro deliberadamente.

Com BLOCK_DISPOSABLE_EMAILS=true (o padrão na nuvem), endereços da lista disposable-email-domains são recusados no cadastro, nos convites e na troca de e-mail, e um subdomínio conta como o domínio acima dele (sub.mailinator.com é mailinator.com). Contas que já usam um desses endereços continuam funcionando; a janela da equipe no console as marca. O app já vem com uma cópia da lista e, enquanto o bloqueio está ligado, o worker baixa uma nova de raw.githubusercontent.com todo dia, mantendo a última cópia boa quando um download falha. Uma lista do dia que cresce ou encolhe mais de um quinto também não é aplicada: o worker registra isso no log e fica com a cópia que tem. Diga a lista o que disser, um provedor de e-mail pessoal (Gmail, Outlook, Yahoo, iCloud, Proton, UOL, BOL, Terra e parecidos) e um sufixo público (com.br, github.io) nunca são recusados.

Com IS_CLOUD=true, uma mesma rede abre no máximo 3 contas por dia: um endereço IPv4 ou um /64 IPv6. Contam os cadastros das últimas 24 horas, inclusive os de contas já excluídas, e o 4º é recusado até o mais antigo deles completar um dia. Cadastros pelo Google e pelo GitHub contam do mesmo jeito; entrar em uma conta que já existe nunca é recusado. Uma instância auto-hospedada não tem esse limite.

E-mails de conta, contatos e novidades

Os e-mails do próprio MillionSend — redefinição de senha, confirmação de e-mail, convites para equipes e os avisos de cota e entregabilidade aos proprietários das equipes — saem de AUTH_EMAIL_FROM e NOTIFICATIONS_EMAIL_FROM. Verifique o domínio do remetente em Domínios em uma equipe e, a partir daí, esses e-mails são registrados e medidos nessa equipe como qualquer outro: aparecem na lista de E-mails com a tag millionsend_system, contam nas Métricas e as Supressões se preenchem com os bounces deles. O corpo é descartado assim que o SES aceita a mensagem, já que um link de redefinição é uma credencial válida, e os links nunca são reescritos para rastreio de cliques. Enquanto nenhuma equipe tiver o domínio, eles saem direto pelo SES sem deixar rastro, como antes.

O que um cliente digitou (nome de equipe, de pessoa, de domínio, de chave ou de transmissão, a URL de um webhook, um endereço) nunca aparece no assunto. No corpo, sai como texto que nenhum cliente de e-mail transforma em link: quebras de linha e caracteres invisíveis ou que mudam a direção do texto são removidos (os caracteres de junção e de variação dentro de um emoji ou de uma palavra em persa ou em escrita indiana ficam), o texto para em 64 caracteres e um espaço finíssimo (hair space) segue cada ., @, : e \, então acme.dev continua legível como acme.dev sem virar link. Uma sequência de sete ou mais dígitos mantém só os quatro primeiros, para nenhum app de e-mail oferecer a ligação, a menos que seja exatamente uma data (09/10/2026, 2026-10-09) ou um intervalo de até dez anos (2025-2026), que sai como foi digitado. Nomes de equipe e de pessoa com um esquema de link, um @, uma quebra de linha ou qualquer outro caractere invisível, uma sequência de oito ou mais dígitos (fora uma data ou um intervalo de anos como esses), ou com mais de 64 caracteres, são recusados ao serem definidos; um nome salvo antes continua como está. Um nome de equipe parecido com uma marca que golpistas costumam imitar (PayPal, Itaú, Correios e outras, inclusive escritas com letras parecidas, como PayPa1) é aceito, e abre uma sinalização brand em Confiança e segurança quando a equipe é criada ou renomeada.

A confirmação de e-mail, a redefinição de senha e o convite podem chegar a qualquer endereço que alguém digitou, por isso não cumprimentam ninguém pelo nome. Um convite identifica quem convidou pelo endereço confirmado dessa pessoa, ou não identifica ninguém quando o endereço não foi confirmado, e só diz o nome da equipe depois que ela sai do nível novo do monitor de conteúdo; até lá, diz "uma equipe" (sem ABUSE_JUDGE, toda equipe continua nova). Uma equipe tem no máximo um convite pendente por endereço, um reenvio espera dois minutos, e a equipe, e cada membro somando todas as suas equipes, cria no máximo INVITES_PER_TEAM_PER_DAY convites por dia (padrão 50, contando os revogados e os de equipes excluídas). Não importa quantas contas e equipes peçam: um endereço recebe no máximo 5 e-mails de confirmação e de convite por hora cheia e 10 por dia UTC; passando disso, nada mais vai para ele até a hora ou o dia virar, e a janela do convite avisa que o e-mail não pôde ser enviado e mostra o link.

Essa equipe é a da própria instância, e o operador pode marcá-la como tal: no plano system ela nunca tem limite nem cobrança, o selo diz System e a aba Cobrança mostra um aviso no lugar dos planos. Em uma instância auto-hospedada os planos não impõem limites, mas a marca faz mais que rotulá-la: o monitor de conteúdo nunca a pausa, o aquecimento de domínio novo nunca limita os domínios dela, uma pausa pela taxa de bounce ou de reclamação recusa os envios novos dela mas nunca segura o que ela já pôs na fila, e os contatos dela são a lista da qual uma suspensão descadastra os membros de uma equipe. Os e-mails de conta também ficam fora dessa pausa, seja qual for o plano da equipe deles: ela não recusa nem segura o que sai pelo caminho de e-mails de conta da própria instância, o único que pode pôr a tag millionsend_system em um e-mail.

A mesma equipe é a audiência das novidades do produto. Em uma instância com ALLOW_SIGNUP=true, toda conta nova vira um contato ali com a propriedade source: signup (nome, endereço, data do cadastro e idioma do painel) assim que o endereço é confirmado — um login social já chega confirmado, um cadastro por senha conta quando o link enviado é aberto. A tela de cadastro avisa, e excluir a conta exclui o contato e apaga o endereço do histórico dessa equipe. Uma instância fechada não inscreve ninguém. Para inscrever contas que existiam antes de o domínio ser verificado, rode uma vez (contas que nunca confirmaram se inscrevem sozinhas no próximo login):

insert into contacts (team_id, email, first_name, last_name, properties)
select '<id da equipe>', email,
       split_part(name, ' ', 1),
       nullif(substr(name, length(split_part(name, ' ', 1)) + 2), ''),
       jsonb_build_object('source', 'backfill', 'signed_up_at', to_char(created_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
from "user"
where email_verified
on conflict do nothing;

Os envios a esses contatos seguem as regras de sempre: crie um tópico (por exemplo, "Novidades") para que um descadastro valha para ele e nunca para os e-mails de conta, e envie transmissões pelo domínio verificado da equipe.

A confirmação de e-mail fica ligada sempre que AUTH_EMAIL_FROM está definido e há credenciais SES — a mesma condição da recuperação de senha. Um cadastro por senha não recebe sessão até o link enviado ser aberto; contas anteriores confirmam no próximo login. Onde a instância confirma e-mails, o e-mail do onboarding só vai para um proprietário que confirmou.

Nada em uma instância auto-hospedada contata millionsend.com por conta própria. O assistente de configuração mostra onde se inscrever nas notas de lançamento do MillionSend, app.millionsend.com/updates?source=self-host, e Configurações → Instância leva à mesma página; o source marca você como auto-hospedado. Um link de confirmação chega antes de qualquer coisa ser armazenada.

Console

/console é a visão do operador sobre toda a implantação: uma visão geral (envios, entregabilidade, equipes, contatos, domínios, fila, uma tabela das regiões do SES com a cota, as transmissões, a situação da conta, a fila, as taxas de bounce e de reclamações e o custo de cada uma, e as checagens de saúde com histórico), uma página de Regiões, uma página de Equipes com ações do operador (alterar plano ou tipo, teto diário de envio, pausar transmissões, suspender e reativar), uma página de Confiança e segurança construída sobre a proteção de envio, a nota da conta, os insights de conteúdo armazenados (nunca o corpo dos e-mails) e, quando o monitor de conteúdo opcional está ligado, as avaliações das amostras feitas pelo modelo, e um registro de auditoria da instância.

Só o operador da instância (o primeiro usuário cadastrado) consegue abri-lo; todos os outros recebem 404, e nada no app leva até ele. Em uma instância auto-hospedada, Configurações → SES mostra ao operador um cartão "Console da instância" com o botão "Abrir console"; a URL direta https://<seu-host>/console também funciona. Todo número vem do Postgres ou de uma leitura gratuita de GetAccount do SESv2 por região, exceto a taxa de reclamações que o próprio SES mede: o worker a lê no CloudWatch (cloudwatch:GetMetricData, uma métrica por região a cada 15 minutos, alguns centavos de dólar por mês). Uma política millionsend-ses anterior a essa permissão deixa a taxa sem leitura, e a pausa regional passa a estimá-la pela contagem da própria instância, sem contar os e-mails para provedores que nunca repassam reclamações ao SES, como o Gmail, com o mesmo limite de 0,1% usado para a taxa do SES. Para adicioná-la, rode a configuração de novo (o que também cria uma nova chave de acesso) ou atualize a política sem recriar nada. O custo por região é uma estimativa local.

A pausa regional segura as transmissões de uma região do SES, nunca o e-mail transacional, antes que o SES aja sobre a conta inteira ali. Ela entra quando a taxa de hard bounces da plataforma na região chega a 4%, e aí segura as transmissões de todas as equipes até a taxa voltar para baixo do limite. Também entra quando a taxa de reclamações do SES chega à linha de revisão dele, de 0,1%, e aí segura só as transmissões das equipes cuja própria taxa de reclamações nos últimos 30 dias, contados pela data de envio, é de 0,1% ou mais; as outras equipes seguem enviando, e a pausa termina quando a taxa volta para baixo de 0,08%. Pausada, a região aparece no console como "pausadas · N equipes", com os nomes das equipes ao passar o mouse. O operador recebe um e-mail quando uma região é pausada ou retomada, uma vez por dia enquanto a taxa de reclamações do SES em uma região está em 0,08% ou mais, pausada ou não, e quando o SES muda a situação da conta em uma região (uma primeira leitura HEALTHY não gera aviso). Esses e-mails trazem as equipes com mais reclamações nos últimos 30 dias, ou com mais hard bounces quando a pausa é por bounces, e contam as reclamações que o SES informou sobre e-mails que já não têm registro: registros que passaram da retenção, de uma equipe excluída ou de e-mails enviados fora do MillionSend.

No MillionSend Cloud, mais duas proteções valem para as equipes do plano Free. Quando uma região é pausada por reclamações, cada equipe Free cujos e-mails receberam uma reclamação ali fica em pausa para revisão; liberada, ela só volta a ser pausada por uma reclamação mais nova. E a pausa do Free entra quando os e-mails das equipes Free enviados nos últimos 7 dias, inclusive os de equipes já excluídas, recebem 3 reclamações: até o operador liberá-la em Confiança e segurança, todo e-mail de equipe Free para um provedor que repassa reclamações fica em espera, enquanto os e-mails para Gmail, Apple Mail e T-Online, os e-mails de conta e os das equipes pagas seguem saindo. A liberação envia o que estava em espera e recomeça a contagem. Uma instância auto-hospedada não tem planos, então nenhuma das duas vale nela.

Uma equipe suspensa mantém os dados e as chaves continuam autenticando, mas todo envio responde 403 team_suspended (SMTP 550; em uma pausa para revisão, o e-mail pela API ou pelo SMTP é aceito e fica em espera, e só o envio de transmissão responde, com 403 sending_paused); uma pausa de transmissões segura as transmissões enquanto o e-mail transacional continua; um teto diário limita o dia da equipe abaixo do plano. Os proprietários são avisados por e-mail a cada ação (exceto suspensão por phishing ou pausa para revisão), e cada ação entra no registro de auditoria com o motivo.

Enquanto uma equipe está suspensa, os únicos e-mails sobre ela que ainda chegam aos proprietários são os avisos de suspensão e de reativação, os e-mails de cobrança e os recibos de segurança (uma nova chave de API, um segredo de webhook rotacionado, um membro que entrou). Os avisos de domínio, webhook, transmissão, cota e entregabilidade param, assim como qualquer outro aviso sobre a equipe. Em uma suspensão por phishing ou em uma pausa para revisão nada sobre a equipe sai, nem a cobrança (o Stripe continua enviando os próprios recibos). Os e-mails sobre a própria conta de uma pessoa, como uma redefinição de senha ou uma verificação de e-mail, e os e-mails para o operador, como os alertas do monitor de conteúdo sobre a equipe, continuam saindo.

Uma suspensão por qualquer motivo que não seja uma pausa para revisão ou falta de pagamento também descadastra todos os membros da equipe da lista de contatos da própria instância (os contatos da equipe no plano system, para onde vão os anúncios do produto), mesmo um membro que também pertença a uma equipe em situação regular. É o mesmo descadastro do painel, e a linha do tempo de cada contato diz o motivo; nenhum webhook é disparado. Uma pausa para revisão que vira suspensão por phishing os descadastra nesse momento. Reativar a equipe não os inscreve de novo: só a própria inscrição deles faz isso. Só uma equipe no plano system conta como essa lista: se a equipe que tem o domínio do remetente não estiver nele, uma suspensão não descadastra ninguém. Ao atualizar para esta versão, os membros das equipes já suspensas por um desses motivos são descadastrados uma vez, exceto um contato que mudou depois da suspensão da equipe, como quem se inscreveu de novo.

Ninguém em uma equipe suspensa pode excluí-la, remover os domínios dela (a API também responde 403 team_suspended) nem convidar ninguém, e um membro de uma equipe suspensa por phishing ou aguardando revisão não pode criar novas equipes. Com esses dois motivos, cada uma dessas recusas é uma única linha neutra que nunca diz suspensa ("Isso não está disponível para esta equipe no momento. Fale com o suporte se precisar de ajuda."; a exclusão de domínio pela API responde 403 forbidden), e o registro de auditoria da própria equipe mostra a suspensão sem a nota do operador.

O registro de auditoria da própria equipe nunca lista sinalizações de confiança e segurança (abertas, encerradas ou reabertas) nem as ações do monitor de conteúdo (a pausa de transmissões, a retomada e um ajuste de amostragem), que continuam no console. Ele mostra uma pausa de transmissões sobre a qual o proprietário não recebeu e-mail sem o motivo nem a nota, e toda ação do operador como MillionSend, nunca o nome ou o e-mail do operador.

O IP e o navegador de cada cadastro ficam em um registro que só o console lê, separado das sessões de login, que expiram. Ele sobrevive à conta: a limpeza de retenção, que roda de hora em hora, o apaga 90 dias depois da exclusão da conta, ou 12 meses depois quando a conta estava em uma equipe suspensa por phishing, por motivo manual ou por reputação e que não foi reativada. A janela de uma equipe lista o IP de cadastro de cada membro, o IP da sessão mais recente e as outras contas vistas nesses IPs, incluindo as excluídas, com as suspensas marcadas. Com IP_GEOLOCATION ligado (o padrão na nuvem), o worker mantém as bases gratuitas de país e de ASN da DB-IP, baixadas uma vez por mês de download.db-ip.com (um download que falha mantém a última cópia boa), e a janela mostra a rede e o país de cada IP de cadastro, consultados localmente.

Uma equipe suspensa por phishing deixa suas impressões digitais, que ficam guardadas depois da equipe: os endereços de e-mail das pessoas (ignorando os pontos do Gmail e qualquer +tag) e os IPs de onde elas se cadastraram, os domínios verificados, o cartão na nuvem e, dos e-mails mais recentes guardados, os nomes de remetente, assuntos (cortados em 256 caracteres), domínios dos links, telefones e um hash do texto visível. Um IP de uma rede de saída compartilhada, em que um endereço representa muita gente (Cloudflare WARP, Retransmissão Privada do iCloud, Vivo, Claro e TIM), fica de fora, pela base de ASN que o IP_GEOLOCATION mantém. O que a própria instância envia também fica de fora: o e-mail do onboarding e qualquer domínio sob o domínio compartilhado das caixas, sob os domínios dos remetentes de onboarding, de conta e de notificações ou sob o do app, que todas as equipes compartilham. Uma equipe nova é comparada com elas quando é criada, adiciona um domínio, faz o primeiro pagamento e envia. Uma coincidência abre uma sinalização linked na lista de confiança e segurança e avisa o operador por e-mail sobre os tipos de evidência que coincidiram; a janela da equipe e a página de revisão mostram os valores. Nada que a equipe vê muda até ela enviar: aí, com a pausa automática ligada, a coincidência a pausa para revisão e os e-mails ficam em espera sem aviso, quando a evidência de uma equipe suspensa inclui um endereço de e-mail ou um cartão, ou dois tipos. Um IP, um domínio ou um tipo de conteúdo sozinho nunca pausa uma equipe, e uma equipe pausada uma vez, ou liberada de qualquer pausa para revisão, não é pausada de novo. Só contam equipes que continuam suspensas por phishing: reativar uma equipe apaga as impressões digitais dela, e a limpeza de retenção as apaga 12 meses depois da suspensão, e os IPs depois de 90 dias. IPs, endereços, telefones (os nove últimos dígitos, lidos só ao lado de um +, de um número 0800 ou 0300, de um formato brasileiro (11) 9xxxx-xxxx ou de uma palavra como "tel" ou "WhatsApp"), cartões e hashes de texto precisam ser iguais. Nomes de remetente coincidem quando as palavras marcantes de um, pelo menos duas, estão todas no outro, que tem no máximo uma a mais; assuntos, quando os conjuntos de palavras, com quatro palavras ou mais e sem os números, se sobrepõem em 75%; nomes de domínio, quando as palavras de um, pelo menos duas com quatro letras ou mais e fora as que todo hostname usa ("mail", "secure"…), estão todas no outro e ocupam 80% dele, então vexto-cadeaux.example coincide com cadeaux-vexto.example.

Monitoramento de conteúdo (opcional)

Desligado por padrão. Com um avaliador configurado, o TypeSafe Jev dá ao e-mail uma pontuação de 0 a 100, e cada avaliação é incorporada a um risco por equipe que o operador vê em Confiança e segurança.

Avaliado antes do SES. O e-mail de uma equipe na sua janela inicial (os primeiros 1.000 envios e 72 horas, contados do primeiro envio) é avaliado antes de o SES recebê-lo: o e-mail espera na fila, o envio segue com os outros e a avaliação, primeira na fila do avaliador, o libera em cerca de meio segundo. Uma transmissão de uma equipe no nível Nova não grava nenhuma cópia até o próprio HTML ter uma avaliação, e a primeira cópia renderizada espera a sua. Uma equipe usa no máximo duas das quatro vias do avaliador, e um e-mail avaliado antes do envio tem timeout de 5 s e uma nova tentativa. Se o avaliador ainda assim não responder, o e-mail de um domínio de envio com menos de 7 dias, ou de idade desconhecida, espera: o avaliador é chamado de novo a cada 2 minutos, 3 vezes, e depois o e-mail fica em espera para o operador, que fica sabendo pelo aviso de saúde do avaliador; qualquer outro e-mail é enviado e avaliado depois. Nenhum limite diário deixa sair sem avaliação um e-mail que espera por ela: os limites contam e param só os sorteios feitos depois do envio. Uma equipe com 20 avaliações esperando o avaliador tem o próximo e-mail esperando a vez. O resto do e-mail aceito é sorteado depois que o SES o recebe, na taxa do seu nível. E-mails da conta e o e-mail do onboarding nunca são avaliados.

Uma falha do avaliador de qualquer tipo (recurso desligado, credenciais ausentes, limitação de taxa, timeout, erro do provedor, resposta que não parseia, corpo já removido pela retenção) registra a amostra como sem avaliação e não muda mais nada. As verificações determinísticas de conteúdo (os insights, a proteção de envio, a nota da conta) rodam em todo envio com ou sem o avaliador. Quem hospeda por conta própria pode deixá-lo desligado.

O que ele faz. Abre a sinalização monitor em Confiança e segurança quando o risco de uma equipe cruza o limite de sinalização e o avaliador considerou abuso pelo menos uma das mensagens dela, a partir da pontuação de sinalização, nos últimos 7 dias (um risco que só avaliações limpas levaram acima do limite não abre nada; uma sinalização aberta fica até o risco voltar abaixo do limite), avisa o operador por e-mail uma vez por dia por equipe acima do limite de alerta e, só para uma equipe no nível Nova (nos primeiros 1.000 envios ou 72 horas, ou abaixo de 10.000 envios em 7 dias), pausa as transmissões quando o risco passa do limite de pausa e uma mensagem amostrada teve pontuação 90 ou mais no último dia, ou na hora quando o próprio HTML de uma transmissão ou uma cópia renderizada recebe uma avaliação do tipo phishing a partir de 80 (o e-mail transacional continua saindo; a equipe vê as transmissões "pausadas aguardando revisão"; o operador retoma pela página de revisão). A política de pausa é uma configuração e pode ser desligada.

A pausa para revisão. Quando uma mensagem amostrada de uma equipe no nível Nova é avaliada como abuso na pontuação de revisão (90 por padrão), seja como phishing de credenciais, imitação de marca ou desvio de pagamento, seja com uma constatação de falsificação ou coleta de segredos, ou na pontuação de repetição (80) quando o domínio de envio da equipe foi registrado menos de 7 dias antes, ou tem idade desconhecida, a equipe é suspensa na hora com o motivo review: o e-mail que ela envia pela API (POST /emails e /emails/batch, que o MCP também usa) ou pelo SMTP é aceito como qualquer envio e fica em espera (park_reason = 'review'), o e-mail na fila e as transmissões ficam em espera, uma nova transmissão é recusada com 403 sending_paused e, com SES_TENANTS ligado, o tenant do SES dela é desativado. Uma sequência de avaliações com pontuação menor também pausa a equipe: por padrão, a quinta dessas avaliações a partir de 80 em quaisquer sete dias enquanto a equipe está no nível Nova; uma avaliação que poupou a equipe (veja abaixo) e cujo e-mail o operador liberou deixa de contar, enquanto todos os domínios desse e-mail continuarem liberados. Um link para outro domínio, sozinho, não conta para nenhuma das duas regras: e-mail legítimo costuma ter links para outros lugares. O operador é avisado por e-mail na hora, com a regra que pausou a equipe; o proprietário não. O painel mostra um aviso neutro de "envio pausado aguardando revisão", enquanto a API e o SMTP respondem como para qualquer envio aceito, então quem envia nunca fica sabendo que foi pego. A equipe fica em Confiança e segurança com a sinalização aberta até o operador decidir na página de revisão: Liberar equipe a reativa, o e-mail em espera sai, uma pausa de transmissões que o monitor aplicou antes da pausa para revisão é levantada como Retomar transmissões a levanta (uma pausa que o operador aplicou à mão continua) e a sinalização do monitor que a pausa para revisão manteve aberta é encerrada em nome do operador (a verificação automática não a reabre enquanto o mesmo gatilho persistir); Suspender por phishing transforma a pausa para revisão em uma suspensão por phishing, e o que a pausa deixou em espera continua em espera: o quota.drain nunca libera o e-mail de uma equipe suspensa. Enquanto a equipe está aguardando revisão, as avaliações seguintes ainda movem o risco e avisam o operador como para qualquer outra equipe, mas não pausam as transmissões, então liberar a equipe não deixa pausa do monitor para trás. Uma equipe é pausada para revisão uma vez só, por qualquer das regras: depois de liberada, suas avaliações seguintes só geram alerta, então o e-mail liberado, avaliado de novo na saída, não dispara uma nova pausa para revisão nem pausa suas transmissões. Uma equipe já suspensa mantém a suspensão, e equipes nos níveis Em observação, Estabelecida e Confiável nunca são pausadas para revisão: ficam só no alerta. A equipe de sistema fica isenta: o monitor nunca a amostra. Uma mensagem avaliada antes do SES que pausa a equipe fica em espera com o resto do e-mail dela; uma avaliada depois do envio já tinha saído, e a pausa barra o que vem depois. Na janela inicial, uma mensagem avaliada como do tipo phishing a partir de 80 que não pausa nada espera 2 minutos antes de sair: uma rajada chega à contagem de repetição dentro deles. Um e-mail que só chega às pessoas da própria equipe (todo destinatário é o login de um membro ou o simulador de caixas do SES; transmissões à parte) nunca espera os 2 minutos, mas fica em espera como qualquer e-mail, porque uma chave roubada da equipe poderia mirar uma isca nas próprias pessoas dela; a pausa para revisão continua considerando esse e-mail, porque testar uma isca primeiro em si mesmo é o primeiro sinal dela. Depois que a única pausa de uma equipe já foi usada, nada espera por uma pausa que não pode mais vir. MONITOR_AUTO_HOLD=false desliga a pausa para revisão, e a avaliação antes do SES junto com ela.

Destinos novos, em qualquer nível. Uma mensagem também é avaliada antes do SES, seja qual for o nível da equipe (Confiável incluído), quando leva um destino que a equipe ainda não conhece: o domínio dono de um link que um cliente de e-mail seguiria (uma âncora, uma área de mapa de imagem, uma ação de formulário, um link em SVG ou um URL do texto, lido como o navegador lê e passando por até três redirecionadores; um nome como x.pages.dev, de um serviço gratuito, conta sozinho) ou do seu Reply-To, menos os domínios verificados da equipe. Um link que não dá para ler também conta como destino; um link para um endereço de loopback (localhost e seus subdomínios, 127.0.0.1, ::1) nunca conta: ali não tem nenhuma página de quem envia, e ele nunca se torna conhecido. Um endereço de rede privada conta como qualquer outro destino. Uma avaliação abaixo de 70 que não aponta nada do tipo phishing torna os destinos da mensagem conhecidos da equipe, quando ela levava no máximo 5 destinos novos; liberar um e-mail em espera por um destino novo, ou esperando depois de uma avaliação, também torna, e liberar um e-mail em espera pelas imagens ou arquivos, ou por uma falha do avaliador, não torna nenhum. Um encurtador de links, ou um link que não dá para ler, nunca se torna conhecido. Por 7 dias depois que um destino se torna conhecido, todo e-mail para ele é avaliado depois do envio, e uma avaliação do tipo phishing a partir de 80 num deles desfaz isso: e-mails para ele voltam a ser avaliados antes do SES, nenhuma avaliação o torna conhecido de novo (a liberação do operador torna), e o operador recebe e-mail no máximo uma vez por dia por equipe. Uma mensagem com mais de 20 destinos novos não é avaliada: fica em espera para o operador. Uma transmissão é avaliada antes de gravar as cópias quando o próprio HTML leva um destino novo, e uma cópia renderizada quando um campo do contato colocou um nela; e-mail com links só para os domínios da própria equipe nunca espera por isso. Quando um destino novo recebe uma avaliação do tipo phishing a partir de 80, as datas de registro dos domínios dos links são consultadas, num cache comum a todas as equipes: 3 por avaliação, o resto em segundo plano, e uma consulta que falhou não se repete por uma hora (nomes de serviços gratuitos e encurtadores não são datados, e uma data que nenhum registro tem não conta como recente). Uma idade ainda desconhecida, porque a consulta falhou, passou de 10 segundos ou espera a vez, conta como recente. Para uma equipe no nível Nova, essa avaliação com um domínio de link registrado há menos de 7 dias pausa a equipe para revisão, e uma de idade desconhecida faz o e-mail esperar 2 minutos. Depois do nível Nova, o e-mail fica em espera na lista de revisão com uma avaliação do tipo phishing a partir de 90, ou a partir de 80 com um domínio de link com menos de 7 dias ou de idade desconhecida, e o operador recebe e-mail no máximo uma vez por dia por equipe; uma equipe depois do nível Nova nunca é pausada para revisão automaticamente. Uma equipe no nível Nova é poupada da pausa que uma avaliação traria quando a avaliação se apoia só num link de login, troca de senha ou cobrança para o nome do próprio produto numa plataforma de apps (ref.supabase.co, x.workers.dev) ou para um endereço de loopback, num e-mail avaliado antes do SES que não é transmissão, não se passa por outra marca (pode, se todos os links dele, inclusive os lidos nas imagens, forem para um endereço de loopback e ele não tiver Reply-To, porque o modelo padrão de um framework assina com o nome do próprio framework) e não traz meio de pagamento: a mensagem fica em espera na lista de revisão no lugar da pausa. Um link de loopback sozinho não faz o e-mail ser avaliado antes do SES; por isso, depois da janela inicial da equipe, essa avaliação num e-mail avaliado depois do envio ainda pausa a equipe. Do mesmo jeito, uma equipe no nível Nova é poupada da pausa quando a avaliação só vê imitação de marca num aviso que uma plataforma de loja ou de agendamento manda em nome de um cliente: um e-mail que não é transmissão, não traz código Pix nem arquivo, não tem a mensagem em imagens nem num QR code desenhado com caracteres e sai de um domínio de envio com pelo menos 7 dias, cujo nome de remetente (From) é o nome do domínio para onde vão o Reply-To e um link; além desse domínio, o e-mail só leva a domínios registrados (como a página de rastreio de uma transportadora, mas nunca a um serviço de arquivos, formulários, sites ou chat em que todos os usuários dividem o mesmo nome), sem link que não seja de página web e sem nenhuma marca que o monitor conheça no nome, nos domínios ou no assunto. Até o operador liberar um e-mail para esses domínios, isso só vale para e-mail avaliado antes do SES e para domínios cuja data de registro mostra pelo menos 7 dias (sem data, a pausa continua); depois da liberação, esses avisos para eles saem sem ficar em espera nem esperar os 2 minutos, e sem pausar a equipe, e a mesma mensagem com outro nome ainda pausa a equipe. Um aviso que traz um domínio novo ao lado do site já liberado mantém a liberação do site enquanto o novo é avaliado; outro domínio liberado no aviso, como a página da transportadora, perde a liberação como antes. Essas amostras não contam para nenhum limite diário; uma equipe tem 200 delas por dia, e depois disso o e-mail dela com um destino novo espera o próximo dia UTC.

A lista de revisão. Confiança e segurança lista, por equipe, todo e-mail não enviado que a avaliação antes do envio segurou: em espera atrás de uma pausa para revisão, cumprindo os seus 2 minutos, em espera por um destino novo ou pelas imagens ou arquivos, em espera por ser e-mail de um endereço compartilhado para alguém que nunca escreveu para ele, ou em espera depois de o avaliador falhar repetidas vezes. Liberar envia o e-mail sem avaliá-lo de novo (o quota.drain o cobra como qualquer e-mail em espera); Cancelar faz com que ele nunca saia. Liberar todos e Cancelar todos pedem confirmação, com a contagem, e só pegam e-mails em espera desde antes de a lista ser carregada. O e-mail de uma equipe pausada para revisão é liberado junto com a equipe, e uma suspensão por qualquer outro motivo o cancela, para que nenhuma reativação depois o envie. A página de revisão da equipe também lista, entre os sinalizados, os e-mails não enviados que foram segurados, para que um acesso ao conteúdo possa citá-los. O que a avaliação leu de um e-mail em espera abre num painel de Mídia, com um acesso ao conteúdo (veja Acesso ao conteúdo abaixo).

Para ligar, no .env da instância, lido pelo worker e pelo app (um reinício aplica):

ABUSE_JUDGE=typesafe
ABUSE_JUDGE_API_KEY=...
# Opcional; jev-1.13.0 é o padrão.
ABUSE_JUDGE_MODEL=jev-1.13.0
ABUSE_JUDGE_TIMEOUT_MS=20000

Uma chave de API ausente falha a inicialização. O modelo padrão é uma versão fixa, não o alias jev-latest, porque os limites de pontuação são calibrados contra as respostas de uma versão: mude para uma versão mais nova de propósito, depois de conferir as pontuações dela. Cada amostra registra o id versionado do modelo que respondeu. ABUSE_JUDGE_BASE_URL (padrão https://api.typesafe.ai) aponta o avaliador para outro endpoint que serve a mesma API; ele faz o POST em <base>/v1/systemone. As perguntas que o Jev responde estão em packages/core/src/abuse-judge/questions.ts, e as sobre os nomes no domínio compartilhado das caixas em names.ts, ao lado. Quando o assunto ou o texto visível citam o MillionSend como palavra, uma frase entra na política: uma equipe que cita o MillionSend como o próprio provedor de envio, como fazem os testes de configuração, não está se passando por ele, e essa menção não libera mais nada no e-mail. Todas as outras mensagens são avaliadas pela política de sempre.

Para onde vão as amostras. Toda mensagem sorteada é enviada à TypeSafe, um subprocessador que hospeda o serviço nos Estados Unidos. Pelos termos publicados, o acordo de processamento de dados (DPA) dela guarda dados pessoais pelo tempo necessário à finalidade do tratamento; o contrato de cliente lhe dá uma licença perpétua para usar os dados enviados no monitoramento de fraude e abuso, em telemetria e no cumprimento da lei; retenção zero de dados só é oferecida nos planos enterprise; os dados enviados não são usados para treinar os modelos dela. O DPA oferece as cláusulas contratuais padrão da UE e o UK Addendum como mecanismos de transferência, e nenhuma cláusula brasileira (ANPD). Antes de ligar o avaliador, aceite o acordo de processamento de dados da TypeSafe, liste-a como subprocessador e descreva-a no aviso de privacidade da instância (veja também o contrato de cliente e a política de privacidade dela).

Exatamente o que o Jev vê, montado em memória a cada chamada e nunca armazenado pelo MillionSend: o nome da equipe, os domínios verificados (mais o domínio registrável de cada um, a forma em que os links aparecem, quando o sufixo é inequívoco), os dias desde o primeiro envio e o plano; os cabeçalhos From e Reply-To como enviados; o Subject; a mensagem como o leitor a vê, lida da parte HTML (ou da parte em texto puro, quando o HTML falta ou está em branco): o texto visível renderizado (os primeiros 4.000 e os últimos 2.000 caracteres, quando ele é mais longo), sem o que um estilo inline, uma regra de <style> ou o atributo hidden escondem e sem texto em 1px ou menos, transparente ou da cor do fundo; o texto escondido desse jeito, numa seção própria (até 1.000 caracteres), separado da linha de prévia que aparece na caixa de entrada e abre a mensagem (até 200); a parte em texto puro, quando menos de 80% das palavras dela aparecem no texto visível (até 1.500 caracteres); uma tabela com todo link que um cliente de e-mail seguiria (âncoras, áreas de mapa de imagem, ações de formulário, links em SVG e os URLs do texto), cada um com o seu texto e o domínio registrável onde ele chega, lido como o navegador lê e passando por até três redirecionadores conhecidos, como o /url?q= do Google, e os links para domínios que a equipe nunca usou, os que não dá para ler e os que saem dos domínios verificados dela listados primeiro (até 30 linhas), com um link para um endereço de loopback (localhost, 127.0.0.1) marcado (local address) e listado por último; uma linha que avisa quando a mensagem mostra menos de 100 caracteres de texto ao lado de uma imagem, de um anexo ou de um QR code desenhado com caracteres ou células de tabela; o número de imagens que ela mostra (<img>, fundos em CSS, VML do Outlook, SVG); os nomes e tipos de conteúdo dos anexos; e a contagem de caracteres ocultos (entre eles os de largura zero, os bidirecionais e os caracteres de tag do Unicode). Todas as partes da mensagem (o assunto, cada seção do corpo, os textos dos links e os nomes dos anexos) passam antes pelo mesmo mascaramento que o acesso ao conteúdo abaixo usa (links cortados ao domínio e a um trecho curto do caminho; sequências com cara de credencial, códigos de 4 a 8 dígitos nos 40 caracteres depois de palavras como code, OTP, senha ou token, e números de CPF e CNPJ com dígitos verificadores válidos mascarados); além disso, endereços de e-mail neles ficam só com o domínio. Os espaços em branco de cada campo, quebras de linha incluídas, viram um único espaço. Outros dados pessoais escritos nesses campos (nomes, telefones, endereços postais) não são removidos: nada os detecta de forma confiável. Nunca um endereço ou cabeçalho de destinatário, nunca o HTML bruto, nunca as imagens ou os arquivos em si. Uma amostra avaliada guarda a pontuação, a avaliação, as categorias, os códigos de motivo, o idioma, a versão do modelo, a latência e a classe de erro; a página de revisão mostra isso e nunca um assunto ou um corpo. As linhas de amostra são removidas após 90 dias.

Nomes no domínio compartilhado. Com um domínio compartilhado das caixas, a API, o painel e o worker também mandam para a TypeSafe os nomes que uma equipe publica nele, para revisão antes de serem usados: um nome de usuário escolhido, o nome de exibição (from_name) de uma caixa compartilhada e o subdomínio de cada equipe, feito a partir do nome dela (em minúsculas, sem acentos, com um hífen no lugar de cada sequência de outros caracteres e até 30 caracteres), cada um como seria publicado, sem mais nada sobre a equipe ou a caixa. O subdomínio é enviado quando a equipe é criada, quando uma mudança de nome muda o subdomínio, e uma vez para cada equipe que já existe e ainda não tem um, na primeira vez que o worker sobe na v0.14.0 ou depois que o domínio compartilhado é definido, mesmo que a equipe não use o domínio compartilhado. Só fica guardada uma contagem dos nomes revisados e recusados por equipe e dia. Inclua isso também no aviso de privacidade.

Imagens e anexos. A triagem abre o que o e-mail traz, dentro do worker: o texto e os links de anexos PDF (até 50 páginas), Word, Excel, PowerPoint, OpenDocument, de calendário, HTML e SVG, e os QR codes em imagens, nas primeiras páginas de um PDF e em imagens dentro de arquivos do Office. Um código Pix, num QR code ou colado como texto, aparece como recebedor, cidade, valor e tipo de chave, nunca a chave. O Jev passa a ver também até 4.000 caracteres de texto dos anexos, até 20 linhas de link a mais (QR codes, links dentro de arquivos, códigos Pix) e o tipo real, o tamanho, o número de páginas e os alertas de cada anexo. Arquivos de programa são recusados na API para todas as equipes, e o e-mail de uma equipe nova com um arquivo arriscado fica em espera até a revisão (veja Anexos). Nada disso sai da instância: a leitura roda em duas threads do worker, com 4 segundos por e-mail.

Para uma equipe no nível Nova, o e-mail que traz um arquivo, ou cuja mensagem vem em imagens, arquivos ou num QR code desenhado com caracteres (menos de 100 caracteres de texto legível), é avaliado antes do SES, e as imagens do corpo também são lidas. Um arquivo que a política sinaliza, recusa ou não consegue abrir deixa o e-mail em espera na hora; depois da avaliação, também deixam um QR code que o leitor de imagens viu e nenhum decodificador leu, ou imagens que o leitor se recusou a ler. Imagens que o Bedrock recusa (um ValidationException que a mesma requisição sem as imagens não recebe) deixam em espera o e-mail ainda não enviado de qualquer equipe, nenhuma prévia fica guardada, e o operador recebe e-mail no máximo uma vez por dia por equipe. Quando uma imagem do corpo não pode ser baixada ou o Bedrock não responde, o e-mail de uma equipe no nível Nova enviado de um domínio com menos de 7 dias espera como numa falha do avaliador; os outros são avaliados sem essas imagens, e o Jev fica sabendo o que não foi lido. Links em arquivos e QR codes contam como destinos em qualquer nível. Depois do nível Nova, o e-mail sorteado tem as imagens do corpo lidas quando tem menos de 100 caracteres de texto legível, e esse e-mail, como um com arquivos, conta como anomalia no sorteio.

Leitura de imagens (opcional). Desligada por padrão. Ligada, o Claude Haiku 5.5 no Amazon Bedrock lê o texto e as marcas que aparecem nas imagens do e-mail e nas páginas de anexos que são imagens, e o Jev recebe esse texto (até 3.000 caracteres) junto com o resto. O worker manda as imagens para o Bedrock em ABUSE_VISION_REGION (padrão us-east-1) com as credenciais AWS dele. O Bedrock só oferece esse modelo ali por perfis de inferência entre regiões, e o worker usa o da área geográfica dessa região: a partir de us-east-1, o us.anthropic.claude-haiku-5-5, que lê as imagens no Norte da Virgínia, em Ohio ou no Oregon. O perfil global, que pode ler as imagens em qualquer lugar do mundo, nunca é usado. O Bedrock não guarda cópia, exceto que a AWS pode guardar, na região que leu, uma imagem que as verificações automáticas dela marquem como material de abuso sexual infantil. Desligada, o e-mail é avaliado sem o texto das imagens e o avaliador fica sabendo. Recusar arquivos de programa, deixar em espera os e-mails com arquivos arriscados e ler PDFs, arquivos do Office e QR codes: tudo isso funciona sem o Bedrock.

ABUSE_VISION_REGION precisa ser uma região com esse perfil, fora do GovCloud; com a leitura de imagens ligada, qualquer outra região impede a instância de iniciar: us-east-1, us-east-2, us-west-1, us-west-2, ca-central-1 e ca-west-1 (perfil dos EUA; a partir do Canadá, as imagens também podem ser lidas nos EUA), eu-central-1, eu-central-2, eu-north-1, eu-south-1, eu-south-2, eu-west-1, eu-west-2 e eu-west-3 (perfil da UE; a partir de Londres ou Zurique, as imagens também podem ser lidas em países da UE), ap-southeast-2 e ap-southeast-4 (Austrália), ap-northeast-1 e ap-northeast-3 (Japão). Para ligar:

  1. Aceite o Claude Haiku 5.5 no Amazon Bedrock nessa região: na página de acesso a modelos do console, ou com aws bedrock list-foundation-model-agreement-offers --model-id anthropic.claude-haiku-5-5 e depois aws bedrock create-foundation-model-agreement --model-id anthropic.claude-haiku-5-5 --offer-token <token>.

  2. Dê às credenciais AWS do worker estas duas permissões, com a sua região e o id da sua conta. A segunda lista o modelo em cada região para onde o perfil pode mandar as imagens: a partir de us-east-1, us-east-2 ou us-west-2, são as três abaixo. A partir de outra região, liste-as com aws bedrock get-inference-profile --region <region> --inference-profile-identifier <geo>.anthropic.claude-haiku-5-5 --query 'models[].modelArn', em que <geo> é us, eu, au ou jp, e use esse prefixo nas duas permissões. Uma política de controle de serviço (SCP) que bloqueie qualquer uma dessas regiões faz as leituras falharem, a menos que abra uma exceção para esse perfil com a chave de condição bedrock:InferenceProfileArn (veja "Geographic cross-Region inference" na documentação da AWS).

    [
      {
        "Effect": "Allow",
        "Action": "bedrock:InvokeModel",
        "Resource": "arn:aws:bedrock:<region>:<account-id>:inference-profile/us.anthropic.claude-haiku-5-5"
      },
      {
        "Effect": "Allow",
        "Action": "bedrock:InvokeModel",
        "Resource": [
          "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-haiku-5-5",
          "arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-haiku-5-5",
          "arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-haiku-5-5"
        ],
        "Condition": {
          "StringEquals": {
            "bedrock:InferenceProfileArn": "arn:aws:bedrock:<region>:<account-id>:inference-profile/us.anthropic.claude-haiku-5-5"
          }
        }
      }
    ]

    Se você deu às credenciais do worker a permissão bedrock-mantle:CreateInference para a leitura de imagens, troque essa permissão por estas duas. Até lá, o Bedrock nega toda leitura (HTTP 403), e os e-mails são tratados como quando o Bedrock não responde. Confira também o passo 3. Se ABUSE_VISION_REGION não for uma das regiões acima (São Paulo, sa-east-1, não tem esse perfil), defina uma delas ou ABUSE_VISION=off antes de atualizar, senão a instância não inicia.

  3. Em Service Quotas, confira a cota do Amazon Bedrock "Cross-region model inference tokens per minute for Anthropic Claude Haiku 5.5" nessa região. O padrão da AWS é 10.000.000, mas algumas contas começam com 0, e aí nenhuma leitura passa: peça um aumento, que precisa ser maior que 10.000.000.

  4. Defina ABUSE_VISION=bedrock (e ABUSE_VISION_REGION para outra região) e reinicie. Antes, inclua o Amazon Bedrock no aviso de privacidade da instância.

Amostragem. Depois de cada mensagem aceita, um sorteio com chave decide se ela é avaliada. Cada valor abaixo é editado no console em Confiança e segurança → Configurações de monitoramento, ou definido na variável de ambiente MONITOR_* até lá; o console prevalece.

VariávelPadrãoSignificado
MONITOR_FIRST_SENDS1000As primeiras N mensagens aceitas de uma equipe são avaliadas por inteiro
MONITOR_FIRST_HOURS72Tudo nas primeiras H horas após o primeiro envio da equipe é avaliado por inteiro
MONITOR_RAMP_SENDS10000Até esta contagem acumulada vale a taxa da rampa
MONITOR_RAMP_RATE0.25A taxa da rampa; a rampa termina na contagem acima ou no dia abaixo, o que vier primeiro
MONITOR_RAMP_DAYS7
MONITOR_PROBATION_RATE0.05Do fim da rampa ao dia 30
MONITOR_ESTABLISHED_RATE0.02Do dia 30 em diante
MONITOR_TRUSTED_RATE0.005120 dias, 50.000 envios e nenhuma sinalização em 90 dias
MONITOR_BROADCAST_COPIES3Cópias renderizadas avaliadas por transmissão (mais o HTML da própria transmissão), equipes estabelecidas e confiáveis
MONITOR_BROADCAST_COPIES_NEW10O mesmo para equipes novas, em rampa e em observação
MONITOR_ANOMALY_MULTIPLIER20Uma anomalia (uma checagem de domínio de link, encurtador ou padrão de phishing falhando, menos de 100 caracteres de texto com imagens ou arquivos levando a mensagem, um anexo) multiplica a taxa; duas forçam a amostra
MONITOR_TEAM_DAILY_CAP600Sorteios depois do envio por equipe por dia UTC; além disso param em silêncio. E-mail avaliado antes do SES não conta nem tem limite
MONITOR_INSTANCE_DAILY_CAP50000Sorteios depois do envio na instância toda; além disso a amostragem por nível para, primeiros envios e anomalias continuam
MONITOR_FLAG_RISK0.5A equipe é amostrada quatro vezes mais e recebe a sinalização monitor com uma avaliação de abuso a partir de MONITOR_FLAG_SCORE nos últimos 7 dias; a sinalização fica até o risco voltar abaixo deste valor
MONITOR_ALERT_RISK0.7O operador é avisado por e-mail, uma vez por equipe por dia
MONITOR_PAUSE_RISK0.85Só equipes novas: as transmissões são pausadas, com uma pontuação de 90 ou mais no último dia
MONITOR_AUTO_PAUSEtrueSe a política de pausa se aplica
MONITOR_FLAG_SCORE70Uma amostra conta como sinalizada no console a partir desta pontuação, e a sinalização monitor pede uma avaliação de abuso a partir dela nos últimos 7 dias
MONITOR_HOLD_SCORE90Só equipes novas: uma avaliação do tipo phishing a partir desta pontuação pausa a equipe para revisão
MONITOR_HOLD_REPEAT_COUNT5Só equipes novas: este número de avaliações do tipo phishing a partir da pontuação abaixo, em até sete dias do primeiro envio da equipe, também a pausa para revisão; 0 desliga
MONITOR_HOLD_REPEAT_SCORE80A pontuação a partir da qual uma avaliação do tipo phishing conta para o número acima
MONITOR_AUTO_HOLDtrueSe a pausa para revisão se aplica

O risco é uma média das avaliações em que as mais antigas pesam menos (meia-vida de 7 dias), com um prior que começa mais alto para equipes novas. O cartão Monitoramento da visão geral mostra a contagem de amostras por hora, e o operador é avisado por e-mail, no máximo a cada seis horas, quando mais de 20% das amostras de uma hora (pelo menos 20 delas) ficaram sem avaliação, ou assim que a TypeSafe recusa a chave de API.

Aquecimento de domínio novo (opcional)

Este é o limite de domínio novo que as equipes veem. O aquecimento que uma equipe liga para um domínio é outra coisa e não precisa de nenhuma configuração aqui.

Desligado por padrão em uma instância auto-hospedada e ligado por padrão com IS_CLOUD. Ligado, cada domínio de envio recebe um limite diário pela idade do seu domínio registrável (a parte ICANN da Public Suffix List: acme.com.br para mail.acme.com.br), contado por dia UTC entre todas as equipes que enviam por ele:

ConfiguraçãoPadrãoSignificado
WARMUP_ENABLEDfalse (true com IS_CLOUD)Se o aquecimento vale
WARMUP_CAP_FIRST_DAY100E-mails por dia enquanto o domínio tem menos de 24 horas
WARMUP_CAP_FIRST_WEEK300Do dia 1 ao dia 7, e para um domínio de idade desconhecida enquanto a equipe é nova ou está em observação
WARMUP_CAP_FIRST_MONTH2000Do dia 7 ao dia 30; a partir do dia 30 vale só o plano

Cada valor é editado no console em Confiança e segurança → Configurações de monitoramento, ou definido na sua variável de ambiente até ser editado lá; o console prevalece. O limite efetivo é o menor entre o do plano e o do aquecimento, e comprar um plano nunca o libera. Os domínios em aquecimento de uma equipe (verificados, sob um limite e sem confiança) também compartilham um único limite diário, o maior entre os deles, contado por equipe e dia UTC: um segundo domínio novo nunca acrescenta volume, e cada domínio continua dentro do seu limite. O que passa de qualquer um fica em espera como queued_quota (com park_reason = 'warmup') e o quota.drain de 15 minutos libera, do mais antigo para o mais novo, conforme os dois permitem. A API responde 429 daily_quota_exceeded, com uma mensagem que cita o limite de domínio novo e um Retry-After até as 00:00 UTC (SMTP 452), só quando a fila de espera da equipe está cheia (três dias do volume do plano, ou do limite do domínio quando ele é maior). Um domínio sobe um degrau antes quando os envios desde o último degrau estão limpos: pelo menos 50 entregues (e-mails para o simulador do SES nunca contam), o primeiro enviado há um dia ou mais, bounces permanentes abaixo de 2% dos envios entregues ou com bounce permanente, reclamações abaixo de 0,1% dos entregues e nenhuma avaliação do tipo phishing do monitoramento de conteúdo (verificado pelo job safety.flags de 15 minutos). Um operador pode confiar em uma equipe, ou em um domínio, na página de revisão de Confiança e segurança, o que libera o aquecimento e fica no registro de auditoria.

Uma equipe que vai trazer para um domínio novo e-mails que já têm histórico de envio pede um limite maior na página do domínio. O operador recebe um e-mail a cada pedido, e os pedidos abertos aparecem em Pedidos de limite, em Confiança e segurança. Na página de revisão da equipe, Confiar atende o pedido e Recusar mantém o limite. Nos dois casos, os donos da equipe e quem fez o pedido recebem a resposta por e-mail.

A consulta de idade roda no job domain.age do worker, nunca no caminho de envio: quando um domínio é adicionado, quando é verificado, e pela varredura domains.reverify de 15 minutos para qualquer domínio nunca consultado (inclusive os de antes da atualização). Ela tenta, nesta ordem:

  1. RDAP no servidor que o bootstrap da IANA indica para o TLD (atualizado diariamente), ou um servidor fixo para .io e .me (rdap.identitydigital.services), .co (rdap.registry.co) e .us (rdap.nic.us), que o bootstrap não lista. A data é a mais recente entre o registro, o novo registro e a transferência do domínio, então um nome registrado de novo depois de expirar, ou comprado e transferido, conta a partir daí.
  2. WHOIS na porta TCP 43 do servidor para o qual whois.iana.org encaminha o TLD.
  3. O primeiro certificado emitido para o nome no Certificate Transparency (crt.sh, no máximo uma requisição a cada 12 segundos).

Um nome sob um sufixo da parte privada da lista, como shop.eu.org de um serviço gratuito de subdomínios, é um domínio próprio para o aquecimento: só o passo 3 o data, já que nenhum registro o vendeu, e ele conta separado dos outros nomes do serviço.

Senão a idade fica desconhecida: .de, .eu e .ch, por exemplo, não publicam data de criação. As requisições levam o User-Agent MillionSend-domain-age/1.0 (+https://millionsend.com), um 429 é respeitado até o seu Retry-After, e uma consulta que falha tenta de novo com espera crescente antes de o domínio ficar como desconhecido. O worker precisa, então, de HTTPS de saída para data.iana.org, os servidores RDAP dos registros e crt.sh, e de TCP 43 de saída, enquanto o aquecimento ou a pausa para revisão do monitoramento de conteúdo (um avaliador configurado e MONITOR_AUTO_HOLD ligado) está ligado; com os dois desligados, nenhuma consulta é feita. A data, a sua origem (rdap, whois, ct ou unknown) e o horário da consulta ficam na linha do domínio; a data de um certificado nunca substitui a de um registro encontrada em uma consulta anterior.

Acesso ao conteúdo (emergência)

Desligado por padrão. Com ele ligado, um operador autorizado pode ler o assunto e o texto visível renderizado de mensagens específicas de qualquer equipe, e o que a avaliação leu nas imagens e anexos dos e-mails em espera, por um motivo de segurança que ele nomeia e justifica antes de qualquer coisa ser descriptografada, por no máximo 30 minutos. A mensagem não precisa ter sido sinalizada pelo monitor de conteúdo: um golpista habilidoso escapa da sinalização automática (sem links, um golpe em outro idioma), então qualquer mensagem de qualquer equipe pode ser aberta sob uma autorização. É o caminho de emergência para os casos que os metadados armazenados não resolvem: as métricas sabem que um link aponta para um encurtador, não se o texto ao redor é um golpe bancário ou uma newsletter.

O que um operador vê. O assunto e o texto visível renderizado do HTML com os elementos ocultos removidos (ou a parte em texto puro, quando não há HTML), cortado em 20.000 caracteres e ocultado na saída: todo link — escrito com esquema ou como um host www. puro — é reduzido ao esquema, ao domínio registrável e a no máximo 24 caracteres de caminho, sem a query nem o fragmento, de modo que um link de uso único não possa ser seguido; qualquer coisa com forma de credencial (um JWT, 32 ou mais caracteres hexadecimais, 40 ou mais de base64, uma das chaves de API ms_ da própria instância) é mascarada, assim como uma sequência de 4 a 8 dígitos nos 40 caracteres depois de uma palavra como código, code, OTP, PIN, token, senha, password ou verification, e qualquer número de CPF ou CNPJ com dígitos verificadores válidos. Nunca o HTML bruto, os endereços dos destinatários, os cabeçalhos, os próprios arquivos anexados ou os destinos de rastreamento de cliques, e a tela não oferece copiar nem baixar. Um corpo que a retenção já apagou não pode ser revelado por ninguém.

O painel de mídia. Um e-mail que a avaliação deixou em espera pelas imagens ou arquivos, e qualquer outro e-mail em espera de que ela guardou uma leitura, ganha a ação Mídia na lista de revisão. Com a mesma autorização, ele mostra por que o e-mail está em espera; o texto lido nas imagens (transcrito) e nos anexos (extraído); os links encontrados neles, cada um com o seu domínio registrável e a idade desse domínio quando a avaliação já o datou; o nome, o tipo, o tamanho, as páginas e os alertas de cada arquivo; e até quatro prévias. Todos esses textos, nomes de arquivo incluídos, passam pelo mesmo ocultamento descrito acima. As prévias são JPEGs pequenos que o worker redesenhou a partir do que leu, com no máximo 600 px de lado: elas chegam à tela só como imagens JPEG data:, qualquer outra coisa é descartada, e um arquivo original, um SVG ou um HTML nunca é mostrado nem servido. O que o painel mostra fica criptografado junto com o e-mail e é apagado com o corpo dele. Liberar os N com as mesmas imagens e anexos envia todo e-mail em espera da equipe cujas imagens e arquivos a avaliação leu exatamente como os deste (as cópias de um disparo), como o Liberar faz, com a mesma linha de auditoria.

Por quanto tempo. Uma autorização dura 30 minutos a partir do momento em que é criada e nunca é estendida; uma nova olhada é uma nova autorização, com um novo motivo e uma nova linha de auditoria. Cada visualização é contada na autorização.

O que fica registrado. A linha da autorização (content_access_grants) guarda o operador, o motivo, a justificativa que ele escreveu, o alcance, os ids das mensagens, a contagem de visualizações e os horários. Nada apaga essas linhas: elas são o inventário de quem leu o quê. Uma linha na auditoria da instância (content.revealed) é escrita antes de qualquer coisa ser descriptografada, com o id da autorização, o motivo e a quantidade de mensagens — nunca o texto da justificativa e nunca qualquer conteúdo. A primeira olhada de uma autorização num painel de mídia escreve uma segunda linha content.revealed, com campos que nomeiam o que a avaliação leu nas imagens e anexos, e marca a autorização (media_viewed_at).

O que a equipe fica sabendo. Nada, a cada acesso. A equipe não recebe e-mail, e nenhuma linha é acrescentada ao registro de auditoria dela, quando o conteúdo é lido. Que os operadores da plataforma podem ler o conteúdo das mensagens por esses motivos de segurança precisa estar nos seus termos e no seu aviso de privacidade antes de você ligar isso (veja abaixo), e não mensagem por mensagem. As autorizações que uma versão anterior já mostrou continuam com as linhas content.accessed no registro de auditoria da equipe, do jeito que estavam. Uma autorização que ainda estiver esperando esse aviso quando você atualizar (feita nos últimos sete dias, ou adiada por uma pausa para revisão) nunca vai aparecer; se o seu aviso de privacidade prometia avisar a cada acesso, mude isso antes de atualizar.

Como ligar, no .env da instância, lido pelo worker e pelo app (um restart aplica):

CONTENT_REVEAL=on

Desligado, os botões do console aparecem desabilitados com uma dica nomeando a variável e todo procedimento de conteúdo recusa. Ler as mensagens dos clientes só é lícito como uma medida de segurança estreita, registrada na auditoria do operador e informada nos seus termos e aviso de privacidade: diga isso lá antes de ligar.

Modo de suporte (opcional)

Desligado por padrão; SUPPORT_VIEW=on liga. Na lista de Equipes do console, "Ver como proprietário" abre o painel de uma equipe como o proprietário o vê, em modo somente leitura, por 30 minutos, depois que o operador informa um motivo (chamado de suporte, contestação de cobrança, outro) e uma referência: o chamado, a contestação ou o caso. Abre em qualquer equipe, inclusive uma suspensa; para o texto de uma mensagem, o caminho continua sendo a revelação de conteúdo. A sessão usa o login do próprio operador; nenhuma sessão é criada em nome do proprietário.

O que o operador vê. O painel sob uma faixa ("Modo de suporte de <equipe> · somente leitura · termina em mm:ss"): e-mails e seus eventos (um link clicado mostra só a origem, porque o caminho e a query podem conter um token), contatos, domínios, transmissões, templates, nomes de chaves de API, endpoints de webhook, configurações e uso.

O que fica oculto. O conteúdo do que já foi enviado: corpos de e-mail (o detalhe diz "O conteúdo do e-mail fica oculto no modo de suporte"); o corpo e o preheader de uma transmissão que começou a sair, já saiu ou foi cancelada no meio do envio; o corpo de todo template, já que o texto de um template é copiado para as transmissões enviadas a partir dele e nada registra quais; corpos de requisição e resposta dos logs de API; exportações CSV (a rota de exportação responde 403); e todo segredo, então chaves de API, segredos de assinatura de webhook e credenciais SMTP nunca são devolvidos. Um rascunho ou transmissão agendada continua legível, já que nada dele chegou a ninguém. Toda alteração é recusada: o servidor responde FORBIDDEN a qualquer mutação enquanto o modo está ativo, independentemente do que a tela mostra.

Por quanto tempo. 30 minutos, verificados a cada requisição. Um modo ativo por operador: iniciar outro, pelo console ou de dentro de um modo, encerra antes o que está ativo, exatamente como Encerrar sessão, e é assim que termina um modo deixado aberto em uma aba fechada. O operador encerra pela faixa, e a expiração encerra na requisição seguinte.

Como iniciar. O diálogo envia um código de 6 dígitos ao e-mail do próprio operador, a partir de AUTH_EMAIL_FROM. Ele vale uma vez, por 10 minutos; cinco tentativas erradas o anulam, um código novo substitui o anterior, e cada operador recebe no máximo cinco por hora. Só fica guardado um hash com chave derivada de BETTER_AUTH_SECRET. Um código confirmado vale para os inícios daquela sessão do navegador por 15 minutos, então abrir vários modos pede um código só; sair da conta encerra isso. Quando a instância não consegue enviar o código (sem AUTH_EMAIL_FROM ou sem credenciais do SES) ou o e-mail dele falha, seja no próprio envio ou, quando uma equipe detém o domínio do remetente, depois na fila (recusado, com falha, devolvido ou em espera antes do SES), o diálogo diz isso, e por 10 minutos um login feito nos últimos 15 minutos vale no lugar do código. De um jeito ou de outro, uma sessão antiga roubada não consegue abrir um modo.

O que é registrado. support.view_started e support.view_ended, só na auditoria da instância: quem, a equipe, o motivo, a referência, como terminou, os minutos e quantos procedimentos distintos foram lidos. O registro guarda uma contagem por nome de procedimento e nunca o que um procedimento devolveu.

O que a equipe vê. Nada: nenhum e-mail, nenhuma linha no próprio registro de auditoria, nenhum cartão em Configurações. Um modo não deixa rastro do lado da equipe, então uma equipe em revisão não fica sabendo de nada; é no aviso de privacidade da instância que as equipes ficam sabendo que esses modos existem. Linhas gravadas por versões anteriores continuam no registro de auditoria da equipe.

SUPPORT_VIEW=on

Rastreamento de erros (opcional)

Desligado por padrão. Com um DSN definido, os processos web, api e worker enviam seus erros pelo SDK do Sentry ao seu projeto Sentry. Sem um DSN o SDK nunca é carregado, nem no servidor nem no navegador. Qualquer mudança vale após reiniciar.

VariávelPropósito
SENTRY_DSNDSN dos processos web, api e worker. Sem definir: rastreamento de erros desligado.
SENTRY_BROWSER_DSNEnvia também os erros do painel no navegador, ao mesmo projeto ou a outro. A página o recebe em tempo de execução e envia pela própria origem deste app.
SENTRY_ENVIRONMENTNome do ambiente em todo evento (padrão production).
SENTRY_TRACES_SAMPLE_RATEFração das requisições rastreadas, de 0 a 1 (padrão 0: só erros).

Crie um projeto para a instância (e, para os erros do painel no navegador, um segundo, ou reutilize o primeiro), copie o DSN, defina no .env e rode docker compose up -d:

SENTRY_DSN=https://<chave>@<host>/<id-do-projeto>
SENTRY_BROWSER_DSN=https://<chave>@<host>/<id-do-projeto>
SENTRY_ENVIRONMENT=production

Por padrão só erros são enviados (sem sessões, traces, replays, profiles nem client reports). Uma taxa de traces acima de 0 envia também essa fração dos traces de requisição, limpos como os erros; headers de trace nunca são adicionados a requisições de saída, então os endpoints de webhook dos clientes nunca os veem. Um rastreador operado por terceiros é um subprocessador: liste-o no aviso de privacidade da instância antes de apontá-la para ele. Um que você mesmo hospeda não é.

O que é reportado. Erros de servidor do painel pelo hook onRequestError do Next.js, incluindo falhas de renderização de server components (o navegador só as vê como o erro minificado #441 do React e um digest, que o evento também leva); procedimentos tRPC que falham com INTERNAL_SERVER_ERROR, nunca uma resposta escolhida pelo procedimento como UNAUTHORIZED ou NOT_FOUND; os erros não tratados da API (a resposta 500 internal_server_error); todo job, dead letter ou cron do worker que lança erro; rejeições não tratadas e exceções não capturadas nos três processos; e, com SENTRY_BROWSER_DSN, erros não capturados nas páginas do painel. Os eventos levam as tags process, team_id quando há um, e o padrão de rota, o procedimento tRPC ou a fila; a release é a revisão git da imagem, a mesma que o /health da API informa.

O que nunca é enviado. Nenhum corpo de requisição, query string, cookie ou header além do user agent; nenhum usuário nem endereço IP; nenhuma variável local; nenhum input de tRPC ou payload de job. Em toda mensagem e breadcrumb, endereços de e-mail mantêm só o domínio (••••••@example.com), chaves de API ms_ são mascaradas, e os caminhos de URL perdem todo segmento que possa ser um token ou um endereço. Eventos de servidor nomeiam o padrão de rota (/invite/[token]), nunca o caminho da requisição. Os relatos do navegador chegam ao rastreador por /api/client-errors, na própria origem do painel, então o endereço IP de um visitante nunca chega.

Source maps (opcional). Uma imagem construída com o segredo SENTRY_AUTH_TOKEN envia os source maps do painel no navegador (associados por debug id) e os apaga da imagem. Para um rastreador auto-hospedado, adicione --build-arg SENTRY_URL=https://<host>/. Sem o segredo o build não muda.

docker build --secret id=SENTRY_AUTH_TOKEN,env=SENTRY_AUTH_TOKEN \
  --build-arg SENTRY_ORG=<slug-da-org> \
  --build-arg SENTRY_PROJECT=<slug-do-projeto> -t millionsend .

Operações

  • Taxa de envio e retenção de e-mails são gerenciadas no painel: Configurações → Instância (proprietário/administrador). Os padrões são 14/s e 30 dias até serem alterados lá; o worker aplica mudança de taxa em até um minuto, e a retenção na próxima limpeza.
  • Dimensionamento do worker: SEND_CONCURRENCY envios em paralelo (padrão 16, cerca de 1,2 por mensagem/segundo da taxa do SES) e WORKER_REPLICAS (padrão 1). O limitador de taxa do SES vive em cada processo do worker, então cada worker divide a taxa da conta por WORKER_REPLICAS; defina-o como o número de contêineres de worker em execução.
  • Para rodar processos em contêineres separados, defina PROCESS como api, worker, web, smtp, mx ou docs por contêiner (padrão all = api + worker + web). Atualize todos no mesmo up -d: o gráfico de Métricas só conta o que processos atualizados escrevem, então um processo deixado em uma imagem antiga durante a troca some do gráfico daquele dia (os números de uso diário não são afetados).
  • Os corpos dos e-mails são comprimidos com gzip, criptografados em repouso com MASTER_ENCRYPTION_KEY e apagados após a janela de retenção. Faça backup da chave junto com o banco.

Nesta página