Integração com o Postfix

O Papéli deve atuar exclusivamente nos serviços de envio de usuários autenticados — as portas submission (587) e submissions (465). Nunca na porta 25, para não alterar emails recebidos de terceiros.

Fluxo recomendado

Porta Serviço Milters (em ordem) Responsabilidade
25 smtp (entrada) sem papeli Emails externos recebidos
587 submission papeli.sock → opendkim Envio autenticado STARTTLS
465 submissions (SMTPS) papeli.sock → opendkim Envio autenticado SSL/TLS

Configuração do master.cf

# Porta 587 — STARTTLS Submission
submission inet n       -       y       -       -       smtpd
  -o syslog_name=postfix/submission
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_milters=unix:/run/papeli/papeli.sock,inet:localhost:8891
  -o milter_default_action=accept

# Porta 465 — SMTPS Implícito
submissions inet n       -       y       -       -       smtpd
  -o syslog_name=postfix/submissions
  -o smtpd_tls_wrappermode=yes
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_milters=unix:/run/papeli/papeli.sock,inet:localhost:8891
  -o milter_default_action=accept
⚠️
Mantenha a porta 25 (smtp inet) sem o unix:/run/papeli/papeli.sock, garantindo que emails externos recebidos não sejam alterados.

Permissões do Socket UNIX

sudo mkdir -p /run/papeli /etc/papeli/templates
sudo chown -R postfix:postfix /run/papeli /etc/papeli
sudo chmod 770 /run/papeli
ℹ️
Se o Postfix estiver com chroot habilitado (padrão Debian/Ubuntu), aponte o socket para dentro do chroot: /var/spool/postfix/papeli/papeli.sock, ou desative o chroot mudando a coluna chroot de y para n no master.cf.

Instalação do serviço systemd

sudo cp target/release/papeli /usr/local/bin/papeli
sudo cp deploy/papeli.example.toml /etc/papeli/papeli.toml
sudo cp deploy/papeli.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now papeli
sudo postfix reload

Validando com swaks

swaks --to destinatario@externo.com \
      --from carlos@suaempresa.com \
      --server localhost \
      --port 587 \
      --auth LOGIN \
      --auth-user carlos@suaempresa.com \
      --auth-password 'suasenha' \
      --tls \
      --body "Olá, este é um teste do Papéli com Postfix!"
🌸 🌷 💐 🌷 🌸

Configuração TOML Hierárquica

O Papéli resolve variáveis em 3 camadas de precedência descendente. Se o remetente for de um domínio não gerenciado (ex: @gmail.com), o daemon ignora a mensagem sem alterá-la.

[default]
Organização Geral, logo padrão, template padrão
↓
[domains."acme.com"]
Sobrescreve empresa, logo, disclaimer
↓
[senders."carlos@acme.com"]
Sobrescreve nome, cargo, telefone

Arquivo papeli.toml completo

[server]
# Socket de escuta: UNIX socket ou TCP inet
listen = "unix:/run/papeli/papeli.sock"
# listen = "inet:127.0.0.1:8899"
templates_dir = "/etc/papeli/templates"

[default]
company_name  = "Organização Geral"
logo_url      = "https://empresa.com/assets/logo-padrao.png"
template_html = "default.mjml"
template_txt  = "default.txt.j2"
disclaimer    = "Esta mensagem contém informações confidenciais."

[domains."acme.com"]
company_name = "ACME Corporation"
logo_url     = "https://acme.com/logo.png"
disclaimer   = "Mensagem protegida pela política de confidencialidade da ACME."

[senders."diretoria@acme.com"]
sender_name  = "Carlos Silva"
sender_role  = "Diretor Executivo"
sender_phone = "+55 11 99999-9999"

[senders."suporte@acme.com"]
sender_name  = "Suporte ao Cliente"
sender_role  = "Central de Atendimento 24/7"
sender_phone = "0800 123 4567"

Referência das variáveis de template

VariávelFonteDescrição
company_nameconfigNome da empresa/organização
logo_urlconfigURL absoluta do logotipo
template_htmlconfigArquivo MJML do papel de carta
template_txtconfigTemplate texto puro Jinja2
disclaimerconfigAviso legal de rodapé
sender_nameconfig / headerNome completo do remetente
sender_roleconfigCargo/função
sender_phoneconfigTelefone de contato
body_htmlmensagemCorpo HTML sanitizado
body_txtmensagemCorpo texto puro
fromheaderEndereço de email do remetente
dateheaderData/hora da mensagem
🌸
Hot-Reload: Alterações no papeli.toml entram em vigor instantaneamente para o próximo email processado. A estrutura interna é protegida por Arc<RwLock<Config>>. Sem necessidade de reiniciar o serviço systemd ou o Postfix.
💌 ✉️ 📮 ✉️ 💌

Templates MJML

O Papéli compila templates MJML para HTML responsivo usando a biblioteca mrml (Rust puro). As variáveis são interpoladas com MiniJinja antes da compilação MJML.

ℹ️
Os templates ficam em /etc/papeli/templates/. O nome do arquivo é configurado por domínio ou remetente no papeli.toml.

Template MJML mínimo (default.mjml)

<mjml>
  <mj-head>
    <mj-attributes>
      <mj-all font-family="Arial, sans-serif" font-size="14px" />
    </mj-attributes>
  </mj-head>

  <mj-body background-color="#f4f6f8">

    <!-- Cabeçalho com logotipo -->
    <mj-section background-color="#ffffff" padding="20px 30px">
      <mj-column>
        <mj-image src="{{ logo_url }}" width="180px" alt="{{ company_name }}" />
      </mj-column>
    </mj-section>

    <!-- Corpo da mensagem -->
    <mj-section background-color="#ffffff" padding="10px 30px 20px">
      <mj-column>
        <mj-text>
          {{ body_html }}
        </mj-text>
      </mj-column>
    </mj-section>

    <!-- Assinatura -->
    <mj-section background-color="#fafafa" border-top="1px solid #e0e0e0"
                padding="15px 30px">
      <mj-column>
        <mj-text font-size="12px" color="#666666">
          <div class="papeli-sig-long" data-papeli="signature"
               data-sender="{{ from }}">
            <strong>{{ sender_name }}</strong><br>
            {{ sender_role }} | {{ company_name }}<br>
            Tel: {{ sender_phone }}<br>
            <small>{{ disclaimer }}</small>
          </div>
        </mj-text>
      </mj-column>
    </mj-section>

  </mj-body>
</mjml>
⚠️
Atenção com strings literais Rust no template: Evite usar r#"..."# em strings que contenham # (como cores CSS #ffffff ou background-color="#f4f6f8"). Use r##"..."## nesses casos.

Sistema de Assinatura Dupla

O módulo src/signatures.rs implementa o mecanismo de dupla assinatura que resolve o problema clássico de inchaço em threads longas.

Marcadores HTML

Assinatura Longa (mensagem ativa recente):

<div class="papeli-sig-long"
     data-papeli="signature"
     data-sender="carlos@acme.com">
  <strong>Carlos Silva</strong><br>
  Diretor de Tecnologia | ACME Corp<br>
  Tel: +55 11 99999-9999 | www.acme.com<br>
  <small>Aviso de confidencialidade: ...</small>
</div>

Assinatura Curta (substituída no histórico citado):

<div class="papeli-sig-short" data-papeli="signature">
  <p style="color: #666; font-size: 11px;">— Carlos Silva (ACME Corp)</p>
</div>

Delimitadores em Texto Puro

Assinatura Longa:

--
<!--papeli:sig:start:long-->
Carlos Silva
Diretor de Tecnologia | ACME Corp
Tel: +55 11 99999-9999
[Aviso legal...]
<!--papeli:sig:end:long-->

Assinatura Curta:

<!--papeli:sig:start:short-->
— Carlos Silva (ACME Corp)
<!--papeli:sig:end:short-->

Algoritmo de Compactação

  1. O filtro detecta a presença de citações (<blockquote>, .gmail_quote, etc.).
  2. Varre o DOM localizando instâncias antigas de .papeli-sig-long.
  3. Substitui cada bloco longo pela versão compacta equivalente (.papeli-sig-short).
  4. Aplica a nova assinatura longa apenas no final da mensagem atual do remetente.
💚
Resultado: Conversas limpas, economia de mais de 70% dos bytes em threads longas e leitura despoluída.

Processamento MIME

O módulo src/mime.rs é o coração do Papéli: analisa toda a árvore MIME da mensagem e a reconstrói de forma cirúrgica, preservando 100% dos anexos.

Casos suportados

Tipo de Email Estrutura MIME Comportamento
Texto puro text/plain Promovido para multipart/alternative com HTML gerado
HTML simples text/html Sanitizado e envelopado no template MJML
Alternativo multipart/alternative Ambas as partes processadas independentemente
Com anexos multipart/mixed Corpo processado; anexos copiados bit-a-bit (SHA-256 verificado)
Imagens inline multipart/related + CID CIDs preservados; imagens permanecem como partes da mensagem
🌟
Anexos são copiados bit-a-bit com verificação SHA-256. Nunca há decodificação e re-codificação desnecessária — preservando integridade mesmo para arquivos binários complexos.

Sanitização HTML

O módulo src/sanitizer.rs usa Ammonia e Scraper para limpar semanticamente o HTML sem destruir o conteúdo significativo.

O que é removido

🗑️
Lixo do Outlook/Word
class="WordSection1"
class="MsoNormal"
Condicionais VML (<!--[if mso]-->)
Estilos mso-* inline
📱
Rodapés de celular
"Enviado do meu iPhone"
"Sent from my Android"
"Get Outlook for iOS"
"Enviado via Gmail"

O que é preservado

  • Formatação semântica: negrito, itálico, listas, tabelas, links
  • Imagens inline (CID e URLs absolutas)
  • Estrutura de cabeçalhos (h1–h6)
  • Blocos de código e citações (blockquote)
  • Todo o conteúdo textual do email

Protocolo Milter

O src/milter.rs implementa um servidor Milter em Rust puro, compatível com o protocolo Sendmail Milter v6.

Modos de socket

# papeli.toml
[server]
listen = "unix:/run/papeli/papeli.sock"

# master.cf do Postfix
-o smtpd_milters=unix:/run/papeli/papeli.sock,inet:localhost:8891

Socket UNIX é recomendado: comunicação local mais rápida, sem overhead de rede, sem exposição de porta TCP.

# papeli.toml
[server]
listen = "inet:127.0.0.1:8899"

# master.cf do Postfix
-o smtpd_milters=inet:127.0.0.1:8899,inet:localhost:8891

Modo TCP útil quando o Postfix está em container separado ou quando há necessidade de depuração remota.

Bypass automático

Se o domínio do remetente não estiver configurado no papeli.toml (ex: @gmail.com, @hotmail.com), o daemon retorna SMFIS_ACCEPT imediatamente, passando a mensagem inalterada para o próximo milter.

Suite de Testes

O Papéli possui mais de 30 testes automatizados cobrindo todos os cenários de processamento MIME, sanitização e assinatura.

Executar os testes

# Rodar todos os testes
cargo test

# Com output detalhado
cargo test -- --nocapture

# Filtrar por módulo
cargo test mime
cargo test sanitizer
cargo test signatures

Cobertura de testes por módulo

MóduloCenários testados
mime.rs text/plain, text/html, multipart/alternative, multipart/mixed, multipart/related, CID inline, anexos binários (SHA-256), promoção de texto puro
sanitizer.rs remoção de classes MSO/Word, condicionais VML, rodapés de iPhone/Android/Outlook, preservação de links e formatação semântica
signatures.rs injeção de assinatura longa, colapso de assinaturas históricas, delimitadores ASCII em texto puro, múltiplas threads
templates.rs compilação MJML, interpolação de variáveis MiniJinja, templates com todas as variáveis de config
config.rs resolução hierárquica (sender → domain → default), hot-reload, domínios não gerenciados (bypass)

Fixtures de teste

Emails reais de diferentes clientes (Outlook 365, Apple Mail, Thunderbird, Gmail Web, Android Gmail, iPhone Mail) estão disponíveis como fixtures em tests/fixtures/ para testes de regressão end-to-end.

💚
Veja o arquivo docs/FIXTURES.md para a lista completa de fixtures disponíveis e como adicionar novos cenários de teste.