- 📬 Um app só para email e agenda — a trilha do dia mora ao lado do leitor, e selecionar uma caixa filtra as duas.
- 🔄 As contas se mantêm sozinhas: IMAP IDLE, Gmail incremental por histórico, e a rede que volta acorda tudo. Ação feita offline chega ao servidor quando a conexão volta — com fila transacional, retry e "tentar de novo" explicado.
- 📖 Leitor de verdade: HTML renderizado (JavaScript morto, imagem remota bloqueada por padrão com memória de confiança por remetente), conversas agrupadas em pilha, convite de agenda vira cartão com "Colocar na agenda" — sem duplicar, por UID.
- ✉️ Enviar envia: Gmail pela API, qualquer IMAP por SMTP próprio (STARTTLS antes da senha), caixa Enviadas, resposta com
In-Reply-To, idempotência por Message-ID — timeout ambíguo nunca duplica email. - 🗂️ As pastas do provedor na barra lateral, expansíveis por conta — e um arquivar que cria a pasta que falta no servidor em vez de parar a fila.
- 🖱️ Ações onde a mão espera: botão direito custom em toda superfície, arraste lateral com Desfazer, atalhos de verdade (
⌘R⇧⌘R⇧⌘F⌘E⌫⇧⌘L⇧⌘U⌘N⌘K). - 🎨 26 temas, hairlines de 1 pixel em telas 1×, semáforos a 22pt verificados por ensaio — o polimento é requisito, não acabamento.
- 🔌 Qualquer provedor: nada no código limita provedor, domínio, número de contas ou de pastas.
- ✅ 1725 testes que provam por mutação: cada teste novo só conta depois de falhar com o defeito reintroduzido.
Tools/rodar.sh # mata a instância antiga, regenera o projeto, compila e abreCliente de email é o app que mais horas passa aberto — e o que menos respeito costuma receber: web view, ações escondidas, agenda em outro app. O OkamiUNI nasce do desenho (design/, a fonte da verdade deste repositório) para o binário nativo, com uma regra que atravessa tudo: controle que existe faz alguma coisa — ou aparece desabilitado explicando por quê. Botão mudo é defeito, não estado.
| Área | O que tem |
|---|---|
| 📥 Caixa de entrada | Fluxo de triagem (Hoje · Depois · Tudo · Arquivado · Lixeira · Enviadas), busca que dobra acento ("Revisao" acha "Revisão"), filtro por conta que alcança lista e agenda, ponto + fundo de não-lida, estrela de sinalizada, data honesta na linha (hoje → hora · "Ontem" · "21 de jul.") |
| Duas ações por lado, persistidas e configuráveis; a linha para aberta, o disparo longo inunda de cor antes de executar, destrutivo tem Desfazer | |
| 🖱️ Botão direito | Menu custom no idioma do design em 10 superfícies — responder, responder a todos, encaminhar, arquivar, apagar, sinalizar, mover, copiar; submenu, atalhos exibidos, navegação por teclado (↑↓⏎→← Esc) |
| ⌨️ Atalhos | Menu Mensagem na barra do sistema; ⌘R responder · ⇧⌘R responder a todos · ⇧⌘F encaminhar · ⌘E arquivar · ⌫ apagar · ⇧⌘L sinalizar · ⇧⌘U lida/não lida · ⌘N nova · ⌘K busca — e campo de texto nunca perde tecla sem modificador |
| ✍️ Composer | NSTextView de verdade: formatação na seleção, tabelas (Enter não quebra), hyperlink, justificado, cor livre, fontes do sistema, assinatura por conta; a faixa de resposta rápida nasce recolhida e o rascunho sobrevive ao recolher |
| 🪟 Janelas | Composer, nova mensagem, mensagem destacada e detalhe de compromisso são cenas reais (⌘W, menu Janela, uma por valor) — todas com o cabeçalho na linha do semáforo, verificado por ensaio |
| 🎨 Shell | 26 temas com tokens de ponta a ponta, hairlines de 1 pixel de dispositivo, duplo clique na barra respeitando a preferência do sistema, painéis redimensionáveis com intenção preservada |
| Área | O que tem |
|---|---|
| 🔐 OAuth do Google | PKCE S256 validado contra o vetor oficial do RFC 7636, redirect derivado do próprio client ID, refresh com corrida única por conta, client_secret de app desktop |
| 📡 IMAP para qualquer provedor | Cliente próprio sobre SwiftNIO com framing de literais, STARTTLS obrigatório antes de qualquer credencial, detecção de servidor por endereço, toda espera com teto e cancelamento |
| 🔑 Segredos | Só no Keychain — nunca em banco, log ou arquivo. Assinatura estável do binário: o Keychain pede a senha uma vez, não a cada build |
| 💾 Local-first | SQLite (GRDB) com FTS5 de acento dobrado no corpo, carga retomável dos últimos 90 dias — o app abre offline. Sem conta conectada, ele continua sendo o shell do Marco 1, com as fixtures |
| Área | O que tem |
|---|---|
| 🔄 Sync contínuo | IMAP IDLE (reengate ≤25min, DONE que sai sempre) com delta de chegadas/bandeiras/expurgos; Gmail incremental por history.list com recarga idempotente quando o marcador expira; NWPathMonitor acorda sync e fila quando a rede volta |
| 📤 Fila espelhada | Toda ação (arquivar, apagar, lida, estrela, mover, enviar) persiste e enfileira na mesma transação; executor por conta com claim atômico, backoff, e idempotência por UUID. Falha permanente para com a causa na tela e "Tentar de novo" — que religa de verdade. Fila parada sobrevive ao reinício e não executa nada fora de ordem |
| 📖 Leitor rico | HTML sanitizado (script/iframe/form fora, cid: embutido, anexo inline do Gmail buscado pela API) numa WebView travada: JS morto, zero requisição remota por padrão, links no navegador. Imagem remota com "Carregar" por mensagem e "Sempre carregar deste remetente" (endereço exato, revogável). Email de largura fixa encolhe para caber; height:100% de marketing não colapsa; espera tem roda + texto plano legível por baixo, e voltar à mensagem é instantâneo (acervo de sessão, nada em disco) |
| 🧵 Conversas | Agrupadas por threadId (Gmail) / corrente de References (IMAP) / assunto normalizado (fallback), uma linha por conversa com contagem, pilha cronológica no leitor (anteriores recolhidas), ações da linha alcançam a conversa inteira — e a resposta enviada carrega In-Reply-To |
| ✉️ Envio | RFC 5322 de verdade (RFC 2047 no assunto, multipart texto+HTML, Message-ID próprio); Gmail pela API, IMAP por SMTP (EHLO→STARTTLS→AUTH, dot-stuffing) + APPEND em Enviadas; pela fila: offline funciona, greylisting re-tenta, endereço recusado explica, timeout ambíguo checa antes de reenviar |
| 🗂️ Pastas do provedor | LIST com special-use (RFC 6154) e labels do Gmail na barra lateral, expansíveis por conta, não-lidas por pasta; destino de move que não existe é criado no servidor e a operação repete |
| 📅 Agenda que lembra | Convite (text/calendar) vira cartão com organizador, participantes, local limpo, link da reunião e "Colocar na agenda" — dedup por UID (50 encaminhamentos = 1 evento), "Convite atualizado" atualiza. Compromisso criado sobrevive ao reinício; "Entrar" abre a reunião; a mensagem de origem se lê dentro do compromisso |
| 👥 Contatos reais | O autocomplete sugere quem troca email com as contas conectadas, por frequência e recência — as fixtures só ficam para quem não conectou nada |
Pré-requisitos: Xcode 26.6+ (Swift 6.3) e XcodeGen (brew install xcodegen).
git clone https://github.com/OkamiOps/okamiuni.git
cd okamiuni
Tools/rodar.shO script encerra a instância anterior, limpa o estado salvo da janela, regenera o .xcodeproj, compila, imprime a data do binário e o commit, e abre o app.
Para a rota Google, copie Config/Google.example.xcconfig para Config/Google.xcconfig (gitignored) e cole o seu client ID de app desktop — o roteiro completo está em docs/oauth-google.md. Qualquer outro provedor entra por IMAP com senha de app (docs/senha-de-app.md).
Quatro pacotes Swift e um princípio: lógica pura fora das views — uma View SwiftUI é @MainActor implícito, e tudo que merece teste nonisolated mora em UNICore.
| Pacote | Papel | Exemplos |
|---|---|---|
Packages/UNICore |
Modelo e lógica pura, sem SwiftUI | MailStore, ConversationStack, ICalendar, PlainTextReflow, ThreadKey, ContextMenus, WeekAgenda/MonthAgenda |
Packages/UNIDesign |
O sistema de temas — 26 temas, tokens de cor, tipografia, fontes embarcadas | Theme, ThemeStore, FontRegistry |
Packages/UNIShell |
As telas e o chrome da janela | InboxScreen, ReaderPane, CalendarScreen, ComposerWindow, WindowChrome, os menus custom |
Packages/UNISync |
Contas e sincronização | AccountDirector, GoogleAuth, ImapSession, SmtpSession, OutboxExecutor, SyncRunner, MimeBody, SyncDatabase |
App/ ──▶ UNIShell ──▶ UNIDesign
└───────▶ UNICore ◀── UNISync ──▶ (Keychain · GRDB · Gmail API · IMAP · SMTP)
O projeto Xcode é gerado por project.yml (XcodeGen) com SWIFT_STRICT_CONCURRENCY: complete. O desenho original — HTML navegável — vive em design/ e é tratado como especificação: quando uma medida está em dúvida, o protótipo é servido e medido, não lido.
Swift Testing (nunca XCTest), 1725 testes em quatro pacotes — e uma regra que virou cultura: teste que passa com o código quebrado é defeito. Todo teste novo nasce provado vermelho com o defeito reintroduzido; mais de 190 mutações registradas mataram, entre outras, um quoted-printable que comia a última letra de cada linha, uma fila que engolia a terceira ação de um ciclo ler→não ler→ler, e um "esvaziar a lixeira" que só funcionava uma vez na vida da conta.
Cinco instrumentos fazem o app testemunhar contra si mesmo, sem tocar no mouse de ninguém:
| Instrumento | Bandeira | O que faz |
|---|---|---|
| Captura | --capturar |
A janela real se fotografa e encerra — pixels do AppKit, não de um harness |
| Ensaio de arraste | --ensaiar-arraste |
Eventos de mouse sintetizados dentro do processo (NSWindow.sendEvent), uma foto por fase do gesto |
| Ensaio de teclado / barra | --ensaiar-teclado · --ensaiar-barra |
Cada atalho e o duplo clique na barra, aferidos no caminho real dos eventos |
| Ensaio de contas | --ensaiar-contas |
O fluxo inteiro de conectar uma conta, contra um servidor IMAP falso em loopback — banco descartável, Keychain intocado |
| Ensaio de semáforos | --ensaiar-semaforos |
Abre as seis janelas, lê a moldura real dos botões do sistema e mede o alinhamento do cabeçalho — 6 janelas, diferença 0.0, verificado |
Nenhum teste toca rede externa: IMAP e SMTP falam com servidores falsos em 127.0.0.1, o Gmail com um transport stub, e até a imagem remota lenta dos testes do leitor sai de um servidor local que conta requisições. Foi assim que se provou que voltar a uma mensagem custa zero downloads.
for p in UNICore UNIDesign UNIShell UNISync; do (cd "Packages/$p" && swift test); doneO registro das decisões — por que o Button do macOS dispara no mouse-up depois de 200pt de arraste, por que NSApp.postEvent mata um processo de teste em silêncio, por que !important de folha perde para !important inline — está em docs/decisoes-de-engenharia.md.
- Marco 1 — Shell: o app inteiro navegável, com as quatro contas vindo de fixtures
- Marco 2 — Contas: OAuth do Google (PKCE), IMAP para qualquer provedor, Keychain, banco SQLite local-first com FTS5, carga de 90 dias retomável
- Marco 3 — Sincronização: sync contínuo (IDLE + histórico), fila de ações espelhada com autocura, leitor HTML seguro, conversas, envio (API + SMTP), Enviadas, pastas do provedor, convites com dedup, contatos reais
- Marco 4 — Agenda real: EventKit/CalDAV; RSVP do convite (iTIP); anexos
- Marco 5 — Inteligência no dispositivo: resumo e detecção de compromisso deixando as fixtures
Dívidas deliberadas, registradas onde doem: anexos visíveis, RSVP (exige METHOD:REPLY por SMTP), recorrência de evento, árvore de pastas indentada (o delimitador ainda não sobe pelo fio), encaminhar convite com o .ics junto.
- O design é a especificação. O HTML em
design/decide medida, cor e comportamento; divergência é bug com número dos dois lados. - Nenhum controle mudo. Faz, ou explica por que não pode — o "Entrar" da reunião, o "Tentar de novo" da fila e o "Sempre carregar" do remetente existem porque botão que finge é defeito.
- Nada limita contas. Provedor, domínio, quantidade de contas e de pastas são ilimitados por construção.
- Fuso horário não atravessa o modelo. Horário é minuto-do-dia, dia é data civil —
Datesó nas bordas. - Prova no app real. Conserto de interação só conta com ensaio antes e depois, no caminho real dos eventos — inclusive a moldura dos botões que o bitmap não vê.
- Privacidade por padrão. Imagem remota é rastreador: bloqueada até você mandar, confiança por endereço exato, cache só em memória, segredos só no Keychain.
- Telas 1× importam. Meia unidade de ponto é zero ou um pixel; hairline é
1/displayScale, borda éstrokeBorder, nunca.strokefino.


