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/setupO 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.examplePreencha o .env (veja a referência de ambiente —
todo o resto tem padrões que funcionam localmente), depois:
docker compose up -dAs migrações rodam automaticamente no boot. Painel: http://localhost:3000.
API: http://localhost:3001.
Atualizações
docker compose pull
docker compose up -dAs 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 -dAtualizaçõ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 -dO 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.yamlAs 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ável | Propósito |
|---|---|
DATABASE_URL | String 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_PASSWORD | Senha 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_KEY | Chave 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_SECRET | Segredo de assinatura das sessões do painel. Gere com openssl rand -base64 32. |
APP_BASE_URL | URL 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_URL | Origem 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ável | Propósito |
|---|---|
AWS_REGION | Região do SES (padrão us-east-1); também a região dos clientes KMS e SQS. |
AWS_REGIONS | Regiõ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_KEY | Credenciais IAM com ses:SendEmail / ses:SendRawEmail. Omita para usar a cadeia padrão de credenciais da AWS (instance profile, SSO, …). |
SNS_TOPIC_ARNS | ARNs 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_URL | Fila 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_SET | Configuration set do SES aplicado a envios sem configuration set por domínio. Sem definir, envia sem (e sem eventos de entrega). |
SES_TENANTS | Um 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ável | Propósito |
|---|---|
MILLIONSEND_IMAGE | Só 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_SIGNUP | O 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_PROXIES | Proxies 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_LOCALHOST | Só 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_PROFILES | Serviç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). |
PORT | Porta da API (padrão 3001). No compose, move junto a porta interna do contêiner e a porta publicada no host. |
UNSUBSCRIBE_BASE_URL | Host 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_PORT | Porta 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_PORT | Porta do host em que o compose publica este site de documentação (o processo docs é sempre 3002 dentro do contêiner). |
SMTP_PORT | Porta do relay SMTP (padrão 2587). |
SMTP_PUBLIC_HOST | Host 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_PATH | Par 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_AUTH | Escape explícito para SMTP AUTH em texto puro apenas em rede local/privada. Mantenha false; nunca combine true com bind público. |
IS_CLOUD | Deixe false. true ativa comportamento de nuvem hospedada (KMS, cobrança via Stripe). |
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET / STRIPE_PORTAL_CONFIG | Somente 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_SECRET | Credenciais 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_SECRET | O mesmo, para GitHub. URL de callback: {APP_BASE_URL}/api/auth/callback/github. |
AUTH_EMAIL_FROM | Remetente 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_KEY | Chaves 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_FROM | Remetente 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_FROM | Remetente 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_DAY | Convites 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_EMAILS | Recusa 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_GEOLOCATION | Rede 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_RATE | Rastreamento 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ável | Propósito |
|---|---|
SEND_CONCURRENCY | Envios em paralelo no worker (padrão 16) — cerca de 1,2 por mensagem/segundo da taxa de envio do SES. |
WORKER_REPLICAS | Quantos 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_RESERVE | Percentual 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_CONCURRENCY | Loops paralelos de long polling no SQS para eventos do SES (padrão 4). |
WEBHOOK_DELIVERY_RETENTION_DAYS | Por 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_DAYS | Dias 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_SECONDS | Uma 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ável | Propósito |
|---|---|
S3_ENDPOINT / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY | UM 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_PROVIDER | Os 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_URL | Bucket 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_BUCKET | Bucket 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_DAYS | Ajustes 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_RECIPIENT | Chave 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ável | Propósito |
|---|---|
RECEIVING_MX_HOST | Hostname 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_SECRET | Segredo 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_DOMAIN | Domí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_HOST | clamd 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_URL | Processo MX: a origem da sua API mais /internal/mx (ex.: https://api.example.com/internal/mx). |
MX_HOSTNAME | Processo MX: o nome na saudação e no EHLO, normalmente igual ao RECEIVING_MX_HOST. |
MX_PORT | Processo MX: porta de escuta (padrão 2525). Publique a porta 25 do host nela. |
MX_TLS_CERT_PATH / MX_TLS_KEY_PATH | Processo 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_MINUTE | Processo 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_RECIPIENTS | Processo 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_MS | Processo 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_MB | Processo 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 setupPrefere 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-defaultAdicionando 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-1npx @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-defaultRelay 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
smtpestiver acessível (os arquivos compose o publicam no host do Docker). A aba Configurações → SMTP do painel mostra o host doAPP_BASE_URL; definaSMTP_PUBLIC_HOSTquando o relay atende por outro nome. Só essa aba lê a variável. - Porta:
2587(SMTP_PORTpara 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_PATHeSMTP_TLS_KEY_PATHapontam para um par PEM. Sem ele, o relay se recusa a iniciar, a menos queSMTP_ALLOW_INSECURE_AUTH=trueseja 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:roe aponte as variáveis no .env:
SMTP_TLS_CERT_PATH=/certs/fullchain.pem
SMTP_TLS_KEY_PATH=/certs/privkey.pemCom 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.
-
Dê um nome a ele. Aponte um registro A como
mx.example.compara 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. -
Ligue o recebimento no app. Defina
RECEIVING_MX_HOST=mx.example.comeMX_INGEST_SECRET(openssl rand -hex 32) no.enve rodedocker compose up -d. Os domínios já podem ativar o recebimento, e o registro de recebimento deles aponta paramx.example.com. -
Dê um certificado a ele. O serviço
mxfala com a API pela rede do compose, se apresenta comoRECEIVING_MX_HOSTe oferece STARTTLS com o par de chaves emmx-tls/, ao lado dodocker-compose.yml. Um deploy hook do certbot copia os arquivos para lá, com donoroot:1000e modo0640; 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--standaloneno lugar de--nginxquando 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.
-
Suba o MX. Adicione
mxaoCOMPOSE_PROFILESe rodedocker compose up -d: o serviçomxrodaPROCESS=mxe 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.
-
DNS. No provedor de DNS do domínio, um registro MX no próprio domínio recebe os e-mails de
postmaster@eabuse@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:Tipo Nome Valor MX agents.examplemx.example.com(o seuRECEIVING_MX_HOST), prioridade10MX *.agents.examplemx.example.com, prioridade10TXT _dmarc.agents.examplev=DMARC1; p=reject -
Identidade do SES. Verifique
agents.examplecomo 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 deAWS_REGIONS, ou aAWS_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 oSHARED_INBOX_TENANTSestá 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). -
Ligue. Defina
SHARED_INBOX_DOMAIN=agents.exampleno.enve rodedocker compose up -d. O app se recusa a subir quando o valor não é um hostname em minúsculas, ou quando falta oRECEIVING_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. -
Receba o postmaster@ e o abuse@. Os e-mails para
postmaster@eabuse@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 planosystem(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 equipesystem, eles são recusados com550 5.1.1, e enquanto essa equipe está suspensa, com550 5.7.1. -
Confira.
dig +short MX agents.exampleedig +short MX test.agents.examplemostram 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 paraagents.examplee o DMARC passando. Um e-mail parapostmaster@agents.exampledeve chegar em Todos os recebidos da sua equipesystem.
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ção | Padrão | Significado |
|---|---|---|
SHARED_INBOX_SEND | true | Se 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_OPEN | false | Se 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_TENANTS | false | Se o e-mail compartilhado sai com o tenant do SES da equipe que envia, com SES_TENANTS ligado |
SHARED_INBOX_TEAM_DAILY_CAP | 1000 | Para 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):
-
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.
-
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
TenantArnque ocreate-tenantmostrou, rode o mesmosend-emailde 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-probeO 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_TENANTSdesligado. -
Ligue o
SHARED_INBOX_TENANTS. -
Confira se o avaliador revisa os nomes: com o
ABUSE_JUDGEdefinido, criar uma caixa compartilhada com um nome de usuário responde201, não503 name_review_unavailable. -
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:
- Adicione
clamavaoCOMPOSE_PROFILESe definaCLAMAV_HOST=clamav:3310no.env. 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.comDepois 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 enableHosts 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_namedo painel e ao comando docertbot, 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_HOSTcomo 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-backupsDepois 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 backupRestauraçã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 smtpO 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=20000Uma 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:
-
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-5e depoisaws bedrock create-foundation-model-agreement --model-id anthropic.claude-haiku-5-5 --offer-token <token>. -
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-2ouus-west-2, são as três abaixo. A partir de outra região, liste-as comaws bedrock get-inference-profile --region <region> --inference-profile-identifier <geo>.anthropic.claude-haiku-5-5 --query 'models[].modelArn', em que<geo>éus,eu,auoujp, 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çãobedrock: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:CreateInferencepara 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. SeABUSE_VISION_REGIONnão for uma das regiões acima (São Paulo,sa-east-1, não tem esse perfil), defina uma delas ouABUSE_VISION=offantes de atualizar, senão a instância não inicia. -
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.
-
Defina
ABUSE_VISION=bedrock(eABUSE_VISION_REGIONpara 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ável | Padrão | Significado |
|---|---|---|
MONITOR_FIRST_SENDS | 1000 | As primeiras N mensagens aceitas de uma equipe são avaliadas por inteiro |
MONITOR_FIRST_HOURS | 72 | Tudo nas primeiras H horas após o primeiro envio da equipe é avaliado por inteiro |
MONITOR_RAMP_SENDS | 10000 | Até esta contagem acumulada vale a taxa da rampa |
MONITOR_RAMP_RATE | 0.25 | A taxa da rampa; a rampa termina na contagem acima ou no dia abaixo, o que vier primeiro |
MONITOR_RAMP_DAYS | 7 | |
MONITOR_PROBATION_RATE | 0.05 | Do fim da rampa ao dia 30 |
MONITOR_ESTABLISHED_RATE | 0.02 | Do dia 30 em diante |
MONITOR_TRUSTED_RATE | 0.005 | 120 dias, 50.000 envios e nenhuma sinalização em 90 dias |
MONITOR_BROADCAST_COPIES | 3 | Cópias renderizadas avaliadas por transmissão (mais o HTML da própria transmissão), equipes estabelecidas e confiáveis |
MONITOR_BROADCAST_COPIES_NEW | 10 | O mesmo para equipes novas, em rampa e em observação |
MONITOR_ANOMALY_MULTIPLIER | 20 | Uma 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_CAP | 600 | Sorteios 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_CAP | 50000 | Sorteios depois do envio na instância toda; além disso a amostragem por nível para, primeiros envios e anomalias continuam |
MONITOR_FLAG_RISK | 0.5 | A 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_RISK | 0.7 | O operador é avisado por e-mail, uma vez por equipe por dia |
MONITOR_PAUSE_RISK | 0.85 | Só equipes novas: as transmissões são pausadas, com uma pontuação de 90 ou mais no último dia |
MONITOR_AUTO_PAUSE | true | Se a política de pausa se aplica |
MONITOR_FLAG_SCORE | 70 | Uma 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_SCORE | 90 | Só equipes novas: uma avaliação do tipo phishing a partir desta pontuação pausa a equipe para revisão |
MONITOR_HOLD_REPEAT_COUNT | 5 | Só 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_SCORE | 80 | A pontuação a partir da qual uma avaliação do tipo phishing conta para o número acima |
MONITOR_AUTO_HOLD | true | Se 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ção | Padrão | Significado |
|---|---|---|
WARMUP_ENABLED | false (true com IS_CLOUD) | Se o aquecimento vale |
WARMUP_CAP_FIRST_DAY | 100 | E-mails por dia enquanto o domínio tem menos de 24 horas |
WARMUP_CAP_FIRST_WEEK | 300 | Do 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_MONTH | 2000 | Do 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:
- RDAP no servidor que o bootstrap da IANA
indica para o TLD (atualizado diariamente), ou um servidor fixo para
.ioe.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í. - WHOIS na porta TCP 43 do servidor para o qual
whois.iana.orgencaminha o TLD. - 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=onDesligado, 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=onRastreamento 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ável | Propósito |
|---|---|
SENTRY_DSN | DSN dos processos web, api e worker. Sem definir: rastreamento de erros desligado. |
SENTRY_BROWSER_DSN | Envia 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_ENVIRONMENT | Nome do ambiente em todo evento (padrão production). |
SENTRY_TRACES_SAMPLE_RATE | Fraçã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=productionPor 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_CONCURRENCYenvios em paralelo (padrão 16, cerca de 1,2 por mensagem/segundo da taxa do SES) eWORKER_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 porWORKER_REPLICAS; defina-o como o número de contêineres de worker em execução. - Para rodar processos em contêineres separados, defina
PROCESScomoapi,worker,web,smtp,mxoudocspor contêiner (padrãoall= api + worker + web). Atualize todos no mesmoup -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_KEYe apagados após a janela de retenção. Faça backup da chave junto com o banco.
Migrar do Resend
Um comando move sua conta do Resend; duas linhas de ambiente movem seu código — o formato da API é idêntico.
Cobrança (implantações hospedadas)
Como planos, Stripe Checkout e o webhook se encaixam em uma implantação hospedada do MillionSend, e como provisionar uma conta Stripe para ela.