mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-12 00:05:09 +00:00
feat: fix implicit-TLS SMTP on 465 and IMAP STARTTLS on 143 behind a stored per-mailbox security mode that accepts any port, stop worker ID churn orphaning mailbox assignments via flock-claimed persistent worker ids, give the unibox a standard mail-folder sidebar (inbox/sent/drafts/archive/spam/trash) backed by a provider-derived folder column, and expose the AI tool registry over REST for non-MCP function-calling agents (#283)
This commit is contained in:
+6
-2
@@ -441,9 +441,13 @@ BILLING_PROVIDER=none
|
||||
# ENCRYPTED_KEYS_BACKEND_URL=http://backend:8080
|
||||
# ENCRYPTED_KEYS_WORKER_TOKEN= # same value as INTERNAL_API_TOKEN
|
||||
#
|
||||
# Stable identity. Set it only when you run one worker per host; scaled replicas
|
||||
# share an environment and would collide.
|
||||
# Stable identity. By default each replica flock-claims a persistent id file
|
||||
# under WORKER_STATE_DIR (compose mounts the worker_state volume there), so ids
|
||||
# survive container recreates and --scale works. Set WORKER_ID only when you
|
||||
# run one worker per host; scaled replicas share an environment and would
|
||||
# collide.
|
||||
# WORKER_ID=<uuid>
|
||||
# WORKER_STATE_DIR=/data/state
|
||||
# WORKER_BIND_IP=
|
||||
# WORKER_PUBLIC_IP=
|
||||
# WORKER_TIER=free # free | premium | dedicated
|
||||
|
||||
@@ -31,6 +31,17 @@ type apiSpec struct {
|
||||
child string // flag name filling {child}, e.g. "step"
|
||||
query []string // query parameters exposed as flags
|
||||
sends bool // true when the command can put real mail on the wire
|
||||
idLabel string // what {id} is called in help, when it is not a uuid
|
||||
}
|
||||
|
||||
// idPlaceholder is what the generated help shows for --id. Most resources are
|
||||
// uuid-keyed; the ones that are not say so, since a user who copies the
|
||||
// example verbatim would otherwise send a malformed identifier.
|
||||
func (s apiSpec) idPlaceholder() string {
|
||||
if s.idLabel != "" {
|
||||
return "<" + s.idLabel + ">"
|
||||
}
|
||||
return "<uuid>"
|
||||
}
|
||||
|
||||
var apiSpecs = []apiSpec{
|
||||
@@ -91,7 +102,7 @@ var apiSpecs = []apiSpec{
|
||||
{name: "mailbox release", summary: "Put a held mailbox back into campaign sending", method: "POST", path: "/emails/{id}/release"},
|
||||
|
||||
// Unified inbox.
|
||||
{name: "inbox list", summary: "List inbox messages", method: "GET", path: "/unibox", query: []string{"limit", "cursor", "address", "direction", "from", "subject", "unseen", "awaiting_reply", "since", "until"}},
|
||||
{name: "inbox list", summary: "List inbox messages", method: "GET", path: "/unibox", query: []string{"limit", "cursor", "address", "direction", "folder", "from", "subject", "unseen", "awaiting_reply", "since", "until"}},
|
||||
{name: "inbox count", summary: "The unseen message count", method: "GET", path: "/unibox/count"},
|
||||
{name: "inbox overview", summary: "Per-mailbox and per-tag inbox rollup", method: "GET", path: "/unibox/overview"},
|
||||
{name: "inbox thread", summary: "One conversation thread", method: "GET", path: "/unibox/thread", query: []string{"thread_id", "email_id", "limit", "cursor"}},
|
||||
@@ -149,12 +160,17 @@ var apiSpecs = []apiSpec{
|
||||
{name: "crm pipelines", summary: "List pipelines with their stages", method: "GET", path: "/crm/pipelines"},
|
||||
{name: "crm deals", summary: "Search deals; --data carries the filter body", method: "POST", path: "/crm/deals/search", body: bodyOptional},
|
||||
{name: "crm tasks", summary: "Search CRM tasks; --data carries the filter body", method: "POST", path: "/crm/tasks/search", body: bodyOptional},
|
||||
|
||||
// Agent tools: the shared AI tool registry over REST, for function-calling
|
||||
// agents that do not speak MCP. --id is the tool name, not a UUID.
|
||||
{name: "tool list", summary: "List the tools this key may call; --format openai emits function-calling manifests", method: "GET", path: "/ai/tools", query: []string{"format"}},
|
||||
{name: "tool call", summary: "Execute one registry tool; --data carries its JSON argument object", method: "POST", path: "/ai/tools/{id}/call", body: bodyOptional, idLabel: "tool-name"},
|
||||
}
|
||||
|
||||
// apiFamilyOrder keeps the top-level help stable; maps iterate randomly.
|
||||
var apiFamilyOrder = []string{
|
||||
"me", "campaign", "contact", "mailbox", "inbox", "analytics",
|
||||
"settings", "webhook", "apikey", "template", "crm",
|
||||
"settings", "webhook", "apikey", "template", "crm", "tool",
|
||||
}
|
||||
|
||||
// apiFamilies drives top-level dispatch and help for the typed commands.
|
||||
@@ -170,6 +186,7 @@ var apiFamilies = map[string]string{
|
||||
"apikey": "API keys",
|
||||
"template": "Reply templates",
|
||||
"crm": "Pipelines, deals, and CRM tasks",
|
||||
"tool": "AI agent tools (list and call the registry)",
|
||||
}
|
||||
|
||||
// runAPIResource runs one typed command: `warmblyctl campaign start --id ...`.
|
||||
@@ -200,7 +217,11 @@ func runAPIResource(ctx context.Context, family string, args []string) error {
|
||||
|
||||
var id, child, data, idem *string
|
||||
if strings.Contains(spec.path, "{id}") {
|
||||
id = fs.String("id", "", "the resource id (required)")
|
||||
label := "resource id"
|
||||
if spec.idLabel != "" {
|
||||
label = spec.idLabel
|
||||
}
|
||||
id = fs.String("id", "", "the "+label+" (required)")
|
||||
}
|
||||
if spec.child != "" {
|
||||
child = fs.String(spec.child, "", "the "+spec.child+" id (required)")
|
||||
@@ -297,7 +318,7 @@ func lookupAPISpec(name string) (apiSpec, bool) {
|
||||
func specExample(s apiSpec) string {
|
||||
parts := []string{"warmblyctl", s.name}
|
||||
if strings.Contains(s.path, "{id}") {
|
||||
parts = append(parts, "--id <uuid>")
|
||||
parts = append(parts, "--id "+s.idPlaceholder())
|
||||
}
|
||||
if s.child != "" {
|
||||
parts = append(parts, "--"+s.child+" <uuid>")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// claimedIDFile keeps the flock-ed id file open for the life of the process.
|
||||
// os.File carries a finalizer that would close the fd (and drop the lock) if
|
||||
// the file were garbage-collected.
|
||||
var claimedIDFile *os.File
|
||||
|
||||
// claimStateID claims a stable worker identity from the pool of "*.id" files
|
||||
// under dir, each holding one UUID. A file is claimed by taking a non-blocking
|
||||
// exclusive flock held until the process exits, so:
|
||||
//
|
||||
// - a recreated container reclaims the UUID its predecessor released, which
|
||||
// keeps the Kafka topic subscription and mailbox assignments intact
|
||||
// - same-host replicas sharing the volume (compose --scale) each claim a
|
||||
// distinct file; shared workers are interchangeable, so replicas swapping
|
||||
// files between boots is harmless
|
||||
//
|
||||
// When every existing file is locked by a sibling, a new UUID is minted and
|
||||
// persisted, growing the pool to the replica count.
|
||||
func claimStateID(dir string) (uuid.UUID, bool) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
log.Printf("WORKER_STATE_DIR %q is not usable (%v), falling back", dir, err)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Printf("WORKER_STATE_DIR %q is not readable (%v), falling back", dir, err)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".id") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
if id, ok := tryClaimIDFile(filepath.Join(dir, name)); ok {
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
path := filepath.Join(dir, id.String()+".id")
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
log.Printf("cannot persist worker id to %q (%v), falling back", path, err)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if _, err := fmt.Fprintln(f, id.String()); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
log.Printf("cannot write worker id to %q (%v), falling back", path, err)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
claimedIDFile = f
|
||||
return id, true
|
||||
}
|
||||
|
||||
// tryClaimIDFile locks and parses one id file. A held lock means a sibling
|
||||
// replica owns it; a corrupt file is skipped rather than deleted, since the
|
||||
// bytes may still matter to whoever wrote them.
|
||||
func tryClaimIDFile(path string) (uuid.UUID, bool) {
|
||||
f, err := os.OpenFile(path, os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return uuid.Nil, false
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return uuid.Nil, false
|
||||
}
|
||||
id, err := uuid.Parse(strings.TrimSpace(string(raw)))
|
||||
if err != nil {
|
||||
log.Printf("ignoring state file %q: not a UUID", path)
|
||||
f.Close()
|
||||
return uuid.Nil, false
|
||||
}
|
||||
claimedIDFile = f
|
||||
return id, true
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// releaseClaim drops the process-lifetime lock between tests so each test
|
||||
// starts from an unclaimed pool.
|
||||
func releaseClaim() {
|
||||
if claimedIDFile != nil {
|
||||
claimedIDFile.Close()
|
||||
claimedIDFile = nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimStateID_MintsAndPersists(t *testing.T) {
|
||||
t.Cleanup(releaseClaim)
|
||||
dir := t.TempDir()
|
||||
|
||||
first, ok := claimStateID(dir)
|
||||
if !ok {
|
||||
t.Fatal("expected a claim from an empty state dir")
|
||||
}
|
||||
releaseClaim()
|
||||
|
||||
second, ok := claimStateID(dir)
|
||||
if !ok {
|
||||
t.Fatal("expected to reclaim the persisted id")
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("id changed across boots: %s != %s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimStateID_LockedFileFallsToNext(t *testing.T) {
|
||||
t.Cleanup(releaseClaim)
|
||||
dir := t.TempDir()
|
||||
|
||||
first, ok := claimStateID(dir)
|
||||
if !ok {
|
||||
t.Fatal("expected first claim to succeed")
|
||||
}
|
||||
// The first claim's lock is still held, simulating a sibling replica: a
|
||||
// second claim must mint a distinct id instead of reusing the locked one.
|
||||
held := claimedIDFile
|
||||
claimedIDFile = nil
|
||||
defer held.Close()
|
||||
|
||||
second, ok := claimStateID(dir)
|
||||
if !ok {
|
||||
t.Fatal("expected second claim to mint a new id")
|
||||
}
|
||||
if first == second {
|
||||
t.Fatalf("second replica claimed the locked id %s", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimStateID_SkipsCorruptFile(t *testing.T) {
|
||||
t.Cleanup(releaseClaim)
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "aaa.id"), []byte("not-a-uuid\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, ok := claimStateID(dir)
|
||||
if !ok {
|
||||
t.Fatal("expected a claim despite the corrupt file")
|
||||
}
|
||||
if id == uuid.Nil {
|
||||
t.Fatal("claimed uuid.Nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorkerID_FromStateDir(t *testing.T) {
|
||||
t.Cleanup(releaseClaim)
|
||||
// t.Setenv (not Unsetenv) so the prior values are restored afterwards;
|
||||
// resolveWorkerID treats empty exactly like unset.
|
||||
t.Setenv("WORKER_ID", "")
|
||||
t.Setenv("WORKER_BIND_IP", "")
|
||||
dir := t.TempDir()
|
||||
t.Setenv("WORKER_STATE_DIR", dir)
|
||||
|
||||
want := uuid.MustParse("99999999-8888-7777-6666-555555555555")
|
||||
if err := os.WriteFile(filepath.Join(dir, want.String()+".id"), []byte(want.String()+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, bind := resolveWorkerID()
|
||||
if got != want {
|
||||
t.Fatalf("got id %s, want %s", got, want)
|
||||
}
|
||||
if bind != "default route" {
|
||||
t.Fatalf("got bind %q, want %q", bind, "default route")
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,16 @@ func resolveWorkerID() (uuid.UUID, string) {
|
||||
if id, err := uuid.Parse(hostname); err == nil {
|
||||
return id, "default route"
|
||||
}
|
||||
|
||||
// A persisted id survives container recreates, which a random one does
|
||||
// not: every fresh UUID orphans the mailboxes assigned to the old one.
|
||||
if dir := os.Getenv("WORKER_STATE_DIR"); dir != "" {
|
||||
if id, ok := claimStateID(dir); ok {
|
||||
log.Printf("Using persisted worker ID %s from %s", id, dir)
|
||||
return id, "default route"
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
log.Printf("Hostname %q is not a UUID, using generated ID: %s", hostname, id)
|
||||
return id, "default route"
|
||||
|
||||
@@ -33,7 +33,7 @@ RUN apk add --no-cache ca-certificates tzdata && \
|
||||
# Docker seeds a fresh named volume from the image, so the directory has to
|
||||
# exist here with the right owner; otherwise Docker creates the mount point
|
||||
# root-owned and the worker cannot read the bodies it is asked to send.
|
||||
RUN mkdir -p /data/blobs && chown -R warmbly:warmbly /data
|
||||
RUN mkdir -p /data/blobs /data/state && chown -R warmbly:warmbly /data
|
||||
|
||||
COPY --from=builder /out/worker /app/worker
|
||||
|
||||
|
||||
+6
-2
@@ -377,9 +377,11 @@ services:
|
||||
ENCRYPTED_KEYS_PROVIDER: http
|
||||
ENCRYPTED_KEYS_BACKEND_URL: ${ENCRYPTED_KEYS_BACKEND_URL:-http://backend:8080}
|
||||
ENCRYPTED_KEYS_WORKER_TOKEN: ${INTERNAL_API_TOKEN:-local-dev-internal-token}
|
||||
# Unset by default so each replica generates its own id and --scale works.
|
||||
# Pin it only when running exactly one worker on this host.
|
||||
# Explicit pin for a single worker; usually unnecessary. Without it each
|
||||
# replica flock-claims a persistent id file in the worker_state volume,
|
||||
# so ids survive container recreates and --scale still works.
|
||||
WORKER_ID: ${WORKER_ID:-}
|
||||
WORKER_STATE_DIR: ${WORKER_STATE_DIR:-/data/state}
|
||||
WORKER_TIER: ${WORKER_TIER:-}
|
||||
WORKER_EGRESS_KIND: ${WORKER_EGRESS_KIND:-}
|
||||
BOX_GOOGLE_CLIENT_ID: ${BOX_GOOGLE_CLIENT_ID:-}
|
||||
@@ -388,6 +390,7 @@ services:
|
||||
BOX_OUTLOOK_CLIENT_SECRET: ${BOX_OUTLOOK_CLIENT_SECRET:-}
|
||||
volumes:
|
||||
- blobs:/data/blobs
|
||||
- worker_state:/data/state
|
||||
depends_on:
|
||||
backend: { condition: service_healthy }
|
||||
nats: { condition: service_healthy }
|
||||
@@ -511,3 +514,4 @@ volumes:
|
||||
redis_data:
|
||||
nats_data:
|
||||
blobs:
|
||||
worker_state:
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: Agent tools (REST)
|
||||
description: Use Warmbly's AI tool registry from any function-calling agent, no MCP client required. List the tools your credentials allow, export OpenAI and Hermes format manifests, and execute tools over plain HTTP.
|
||||
---
|
||||
|
||||
Not every agent speaks MCP. Hermes-style models, OpenAI-compatible frameworks, LangChain agents, and plain scripts all do function calling over ordinary HTTP, so Warmbly exposes the same tool registry as the [MCP server](/api/mcp/) through two REST endpoints: one that lists the tools your credentials allow (in the manifest format your agent framework expects), and one that executes a tool.
|
||||
|
||||
The rules are identical to MCP: every tool is gated by its own permission bits, the list only ever shows what the caller may use, and send-class tools (anything that puts real mail on the wire) are never exposed. An agent wired through this surface can read, search, label, and draft, but a human always presses send.
|
||||
|
||||
- **List**: `GET https://api.warmbly.com/v1/ai/tools`
|
||||
- **Execute**: `POST https://api.warmbly.com/v1/ai/tools/{name}/call`
|
||||
- **Auth**: an API key or OAuth access token as a bearer header, or a dashboard JWT. For keys, each tool checks its own scope from the [permissions](/api/permissions/) table; for JWT members, the matching organization permission.
|
||||
|
||||
## List the tools
|
||||
|
||||
`GET /ai/tools` returns the catalog filtered to what the caller's credentials allow. Three formats:
|
||||
|
||||
| `format` | Shape |
|
||||
| --- | --- |
|
||||
| `warmbly` (default) | `{ name, description, input_schema }` per tool |
|
||||
| `openai` | OpenAI function-calling objects: `{ type: "function", function: { name, description, parameters } }` |
|
||||
| `hermes`, `functions` | Aliases of `openai`; the same JSON schemas drop into a Hermes `<tools>` block verbatim |
|
||||
|
||||
```bash
|
||||
curl -s "https://api.warmbly.com/v1/ai/tools?format=openai" \
|
||||
-H "Authorization: Bearer wmbly_..."
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_threads",
|
||||
"description": "List unified-inbox conversation threads...",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subject": { "type": "string" },
|
||||
"folder": { "type": "string" },
|
||||
"unseen_only": { "type": "boolean" },
|
||||
"limit": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Because the list is permission-filtered, you can hand the response straight to your agent: everything in it is callable with the same credential, and nothing in it can send mail.
|
||||
|
||||
## Execute a tool
|
||||
|
||||
`POST /ai/tools/{name}/call` runs one tool. The request body is the tool's JSON argument object, exactly as your model produced it in its tool call; an empty body means no arguments.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://api.warmbly.com/v1/ai/tools/list_threads/call" \
|
||||
-H "Authorization: Bearer wmbly_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"folder": "inbox", "unseen_only": true, "limit": 10}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"name": "list_threads",
|
||||
"result": { "threads": [], "count": 0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`result` is the tool's output, embedded as JSON. Feed it back to the model as the tool-call result and continue the loop.
|
||||
|
||||
Errors use the standard envelope with a stable `code`: an unknown tool is `404 not_found`, a tool your credential lacks the scope for is `403 forbidden`, a malformed argument body is `400 bad_request`, and a tool-level failure (a validation message, a missing record) is `422 unprocessable` with the tool's own message, which is meant for the model to read and react to.
|
||||
|
||||
This is a side-effectful POST, so `Idempotency-Key` is honored like everywhere else in the API; per-key rate limits and usage logging apply.
|
||||
|
||||
## Wiring a Hermes agent
|
||||
|
||||
Hermes-format models (and anything served with an OpenAI-compatible tool-calling API) take the function schemas in the system prompt or the `tools` array, then emit tool calls you execute and answer. The loop against Warmbly:
|
||||
|
||||
1. `GET /ai/tools?format=hermes` once at session start and give the `data` array to the model as its available tools.
|
||||
2. When the model emits a tool call `{ "name": ..., "arguments": ... }`, `POST /ai/tools/{name}/call` with the arguments object as the body.
|
||||
3. Return `data.result` to the model as the tool response, and repeat.
|
||||
|
||||
```python
|
||||
import json, requests
|
||||
|
||||
BASE = "https://api.warmbly.com/v1"
|
||||
HEADERS = {"Authorization": "Bearer wmbly_..."}
|
||||
|
||||
tools = requests.get(f"{BASE}/ai/tools?format=hermes", headers=HEADERS).json()["data"]
|
||||
# system prompt: "You may call these tools:\n<tools>" + json.dumps(tools) + "</tools>"
|
||||
|
||||
def run_tool(call):
|
||||
r = requests.post(
|
||||
f"{BASE}/ai/tools/{call['name']}/call",
|
||||
headers=HEADERS,
|
||||
json=call.get("arguments") or {},
|
||||
)
|
||||
body = r.json()
|
||||
return body["data"]["result"] if r.ok else body # errors are model-readable too
|
||||
```
|
||||
|
||||
The same two calls back any framework's "custom tool" escape hatch: point the executor at `/ai/tools/{name}/call` and the discovery step at `/ai/tools`.
|
||||
|
||||
## Choosing a surface
|
||||
|
||||
| You have | Use |
|
||||
| --- | --- |
|
||||
| Claude Code, Claude Desktop, Cursor, any MCP client | The [MCP server](/api/mcp/) at `/v1/mcp` |
|
||||
| A Hermes / OpenAI-style function-calling agent | This REST surface |
|
||||
| The dashboard | The built-in assistant, which runs the same registry as the signed-in member |
|
||||
| A terminal | `warmblyctl tool list` and `warmblyctl tool call` |
|
||||
|
||||
All four run the identical registry with identical gates, so a tool behaves the same no matter which door it came through.
|
||||
|
||||
## See also
|
||||
|
||||
- [MCP server](/api/mcp/) for the protocol-native version of this surface
|
||||
- [Permissions](/api/permissions/) for the scope each tool checks
|
||||
- [Realtime API](/api/realtime/) to stream events into a long-running agent instead of polling
|
||||
@@ -347,7 +347,7 @@ Credit balance, top-up purchases, and the transaction log are part of `/subscrip
|
||||
|
||||
### AI assistant
|
||||
|
||||
The dashboard AI assistant is JWT only: sessions are private to the member who started them. The message and approval runs stream over Server-Sent Events. Each tool the assistant runs is gated by the member's own organization permission bits, so the assistant can never do more than the member could by hand. See the [AI assistant](/guides/ai-assistant/) guide. (API-key and OAuth callers reach the same tools through the [MCP server](/api/mcp/), gated by the `AI_AGENT` scope.)
|
||||
The dashboard AI assistant is JWT only: sessions are private to the member who started them. The message and approval runs stream over Server-Sent Events. Each tool the assistant runs is gated by the member's own organization permission bits, so the assistant can never do more than the member could by hand. See the [AI assistant](/guides/ai-assistant/) guide. (API-key and OAuth callers reach the same tools through the [MCP server](/api/mcp/) or the [REST agent-tools surface](/api/agent-tools/), each tool gated by its own scope.)
|
||||
|
||||
| Method | Path | JWT permission |
|
||||
|--------|------|----------------|
|
||||
@@ -370,6 +370,15 @@ Org playbooks the AI features follow (see the [AI skills](/guides/ai-skills/) gu
|
||||
| PATCH | `/ai/skills/:id` | `manage_settings` / `AI_AGENT` |
|
||||
| DELETE | `/ai/skills/:id` | `manage_settings` / `AI_AGENT` |
|
||||
|
||||
### Agent tools (REST)
|
||||
|
||||
The AI tool registry over plain HTTP for function-calling agents that do not speak MCP (see [Agent tools](/api/agent-tools/)). Like the advisor apply path and the MCP endpoint, there is no route-level scope on purpose: each tool enforces its own permission, the list reflects only what the caller may use, and send-class tools are never exposed.
|
||||
|
||||
| Method | Path | API Permission |
|
||||
|--------|------|----------------|
|
||||
| GET | `/ai/tools` | permission of each listed tool |
|
||||
| POST | `/ai/tools/:name/call` | permission of the tool being called |
|
||||
|
||||
### Connected MCP servers
|
||||
|
||||
External MCP servers whose tools the assistant can use (see [Connect MCP tools](/guides/connect-mcp-tools/)). JWT-only and `manage_settings`-gated; bearer tokens are sealed with the org key and never returned.
|
||||
|
||||
@@ -158,6 +158,7 @@ The same discovery drives Warmbly's OAuth server for any client. See the [OAuth
|
||||
|
||||
## See also
|
||||
|
||||
- [Agent tools (REST)](/api/agent-tools/) for the same registry over plain HTTP, for Hermes and OpenAI-style function-calling agents that do not speak MCP.
|
||||
- [OAuth 2.1](/api/oauth/) for the authorization-server endpoints, scopes, and dynamic registration.
|
||||
- [Authentication](/api/authentication/) for how API keys and OAuth tokens work.
|
||||
- [Permissions](/api/permissions/) for the full scope list.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"permissions",
|
||||
"endpoints",
|
||||
"mcp",
|
||||
"agent-tools",
|
||||
"openapi",
|
||||
"reference",
|
||||
"realtime",
|
||||
|
||||
@@ -691,8 +691,10 @@ Connects an SMTP/IMAP mailbox in a single call.
|
||||
|-------|------|----------|-------------|
|
||||
| `email` | string | yes | The mailbox address. |
|
||||
| `name` | string | no | Display name. |
|
||||
| `smtp` | object | yes | SMTP credentials: `username`, `password`, `host`, `port`. |
|
||||
| `imap` | object | yes | IMAP credentials: `username`, `password`, `host`, `port`. |
|
||||
| `smtp` | object | yes | SMTP credentials: `username`, `password`, `host`, `port`, `security`. |
|
||||
| `imap` | object | yes | IMAP credentials: `username`, `password`, `host`, `port`, `security`. |
|
||||
|
||||
`security` is `tls` (implicit TLS, encrypted from the first byte) or `starttls` (plaintext greeting upgraded in-band). It is optional: omit it and the port decides, which is `tls` for SMTP 465 and IMAP 993, and `starttls` for SMTP 587 and IMAP 143. Set it explicitly for anything non-standard, such as a submission relay on 2525. Any port from 1 to 65535 is accepted; TLS itself is not optional.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -702,13 +704,15 @@ Connects an SMTP/IMAP mailbox in a single call.
|
||||
"username": "sales@acme.com",
|
||||
"password": "app-specific-password",
|
||||
"host": "smtp.acme.com",
|
||||
"port": 587
|
||||
"port": 587,
|
||||
"security": "starttls"
|
||||
},
|
||||
"imap": {
|
||||
"username": "sales@acme.com",
|
||||
"password": "app-specific-password",
|
||||
"host": "imap.acme.com",
|
||||
"port": 993
|
||||
"port": 993,
|
||||
"security": "tls"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -18,6 +18,7 @@ Returns the inbox list, collapsed to one row per thread (the newest message), wi
|
||||
| `cursor` | query | string | Opaque pagination cursor from a previous response. |
|
||||
| `limit` | query | integer | Page size. Clamped to the server's min/max. |
|
||||
| `from` | query | string | Filter by sender address (substring). |
|
||||
| `folder` | query | string | One of `inbox`, `sent`, `drafts`, `archive`, `spam`, `trash`. Omit for every folder except `spam` and `trash` (junk never bleeds into the combined view). An unknown value returns `400`. |
|
||||
| `subject` | query | string | Filter by subject (substring). |
|
||||
| `unseen` | query | boolean | `true` returns only threads with unread messages. |
|
||||
| `awaiting_reply` | query | boolean | `true` returns only threads where the latest message was sent by you (recipient has not replied). |
|
||||
@@ -82,7 +83,7 @@ Returns the org-wide unread message count, optionally scoped to one mailbox. Bac
|
||||
|
||||
`GET /unibox/overview`
|
||||
|
||||
Rolls up the scope rail and top metric strip in one call: unread, today, week, snoozed, awaiting-reply, and pending-scheduled counts, plus per-mailbox, per-tag, and per-conversation-label breakdowns. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
|
||||
Rolls up the scope rail and top metric strip in one call: unread, today, week, snoozed, awaiting-reply, and pending-scheduled counts, plus per-folder, per-mailbox, per-tag, and per-conversation-label breakdowns. The `folders` array always lists all six canonical folders, zero-filled, in sidebar order; the headline counts exclude `spam` and `trash`. All counts are threads, not messages. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
|
||||
|
||||
### Response
|
||||
|
||||
@@ -96,6 +97,14 @@ Rolls up the scope rail and top metric strip in one call: unread, today, week, s
|
||||
"awaiting_reply": 9,
|
||||
"scheduled_pending": 2,
|
||||
"scheduled_pending_max": 50,
|
||||
"folders": [
|
||||
{ "folder": "inbox", "unread": 31, "total": 812 },
|
||||
{ "folder": "sent", "unread": 0, "total": 402 },
|
||||
{ "folder": "drafts", "unread": 0, "total": 4 },
|
||||
{ "folder": "archive", "unread": 6, "total": 66 },
|
||||
{ "folder": "spam", "unread": 2, "total": 9 },
|
||||
{ "folder": "trash", "unread": 0, "total": 3 }
|
||||
],
|
||||
"mailboxes": [
|
||||
{
|
||||
"id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
@@ -247,13 +256,14 @@ Returns the resulting label set in a `data` array.
|
||||
|
||||
`PATCH /unibox/seen`
|
||||
|
||||
Marks a batch of messages as read or unread, org-wide. Up to 500 ids per call. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
|
||||
Marks messages as read or unread, org-wide: either an explicit batch of up to 500 ids, or a whole canonical folder at once. Send one of `email_ids` or `folder`; sending both returns `400`. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
|
||||
|
||||
### Request body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `email_ids` | string[] | Yes | Message UUIDs to update (max 500). |
|
||||
| `email_ids` | string[] | No | Message UUIDs to update (max 500). |
|
||||
| `folder` | string | No | Sweep every message in this folder instead of an id list: one of `inbox`, `sent`, `drafts`, `archive`, `spam`, `trash`. |
|
||||
| `seen` | boolean | No | `true` marks as read, `false` marks as unread. |
|
||||
|
||||
```json
|
||||
|
||||
@@ -667,19 +667,20 @@ Each worker needs a stable UUID. It is resolved at boot in this order:
|
||||
1. `WORKER_ID` if set, used as-is
|
||||
2. a UUID derived from `WORKER_BIND_IP`, so each IP on a multi-IP box is its own worker
|
||||
3. the hostname, if the hostname is itself a UUID
|
||||
4. otherwise a **freshly generated UUID**
|
||||
4. a persisted id claimed from `WORKER_STATE_DIR`, when that variable is set
|
||||
5. otherwise a **freshly generated UUID**
|
||||
|
||||
The compose worker hits case 4, so every recreate registers as a new worker and leaves the previous row behind, still holding the mailboxes that were assigned to it.
|
||||
The compose worker hits case 4: the shipped compose file mounts a `worker_state` volume at `/data/state` and sets `WORKER_STATE_DIR` to it. On boot each replica claims an id file from that volume under an exclusive lock and holds it for the life of the process, so a recreated container gets its predecessor's UUID back, and `--scale worker=3` still works because each replica locks a different file (the pool grows to the replica count). The id survives anything short of deleting the volume.
|
||||
|
||||
Sending recovers on its own. Placement skips a worker once its heartbeat goes stale, and the reconciler releases any mailbox still pointing at one and places it on a live worker within its interval. Until that happens, sends from those mailboxes fail with `email account not found in worker`.
|
||||
If the state volume is removed (or `WORKER_STATE_DIR` unset), every recreate registers as a new worker and leaves the previous row behind, still holding the mailboxes that were assigned to it. Sending recovers on its own: placement skips a worker once its heartbeat goes stale, and the reconciler releases any mailbox still pointing at one and places it on a live worker within its interval. Until that happens, sends from those mailboxes fail with `email account not found in worker`.
|
||||
|
||||
Pinning the identity avoids the gap, and the churn of mailboxes moving between workers:
|
||||
Pinning the identity explicitly also works:
|
||||
|
||||
```bash
|
||||
WORKER_ID=<a uuid you generate once> # uuidgen
|
||||
```
|
||||
|
||||
Set it only when you run a single worker per host. Scaled replicas share one environment, so a pinned `WORKER_ID` would make them collide; leave it unset when using `--scale`.
|
||||
Set it only when you run a single worker per host. Scaled replicas share one environment, so a pinned `WORKER_ID` would make them collide; leave it unset when using `--scale` and let the state volume handle it.
|
||||
|
||||
The remote installer handles this for you: it derives the UUID from the machine's public IPv4, so a reinstall on the same IP keeps the same identity and reputation.
|
||||
|
||||
|
||||
@@ -75,11 +75,11 @@ Newer builds return the invite-only refusal with its own machine code, `registra
|
||||
| Nothing updates live and presence is empty | `AUTH_SECRET` must equal realtime's `JWT_SECRET`, and `PUBSUB_ENABLED` must agree across backend, consumer and realtime. Compare the fingerprints under **Instance > Configuration** |
|
||||
| Workers or tracking get 401s | `INTERNAL_API_TOKEN` must match on the backend, the workers (as `ENCRYPTED_KEYS_WORKER_TOKEN`) and tracking. Unset fails closed |
|
||||
| "No mailbox workers are available" when connecting a mailbox | No worker has a heartbeat inside the last 10 minutes. Check `make status` shows `worker` running and `make logs worker` is clean. An empty `ENCRYPTED_KEYS_BACKEND_URL` or worker token lets a worker start and never register, silently |
|
||||
| Connecting a mailbox is rejected on the port | SMTP must be `587` or `465`. The Mailpit sink used by `make sandbox` listens on `1025` with no STARTTLS, so it cannot be used as a test mailbox |
|
||||
| Connecting a mailbox fails with `SERVER_UNREACHABLE` on a reachable host | The security setting does not match the server. A server expecting STARTTLS looks unreachable to a client attempting implicit TLS, and vice versa. Any port from 1 to 65535 is accepted, so the port alone no longer decides: set **Security** to SSL / TLS for a server that is encrypted from the first byte (usually SMTP `465`, IMAP `993`) and STARTTLS for one that upgrades in place (usually SMTP `587` or `2525`, IMAP `143`) |
|
||||
| A mailbox stalls after about an hour | The worker is missing `BOX_GOOGLE_*` or `BOX_OUTLOOK_*`. The backend starts the OAuth flow but each worker refreshes the token. Set them and restart the worker |
|
||||
| Scheduled sends never fire | Delayed sends run through the in-process Postgres task poller (`TASKS_PROVIDER=local`), so the backend must be running |
|
||||
| Every send dead-letters with `permission denied` on `/data/blobs` | The `blobs` volume was created before the images owned that path, so it is still `root:root` while the services run as uid 1000. Fix it once with `docker compose -p warmbly exec -u root backend chown -R warmbly:warmbly /data/blobs`. The `blob_fs_root` health check reports it, and volumes created from current images are already correct |
|
||||
| `email account <id> not found in worker` | The mailbox is assigned to a worker that no longer exists, usually because the worker was recreated without a pinned `WORKER_ID` and came back with a fresh UUID. The reconciler releases and re-places it on a live worker within its interval; pin `WORKER_ID` to stop it recurring. See [worker identity](/development/deployment-guide/#worker-identity) |
|
||||
| `email account <id> not found in worker` | The mailbox is assigned to a worker that no longer exists, usually because the worker was recreated and came back with a fresh UUID. The reconciler releases and re-places it on a live worker within its interval. Compose workers now keep their id in the `worker_state` volume (`WORKER_STATE_DIR`), so this stops recurring once that volume exists; removing the volume or unsetting `WORKER_STATE_DIR` reintroduces the churn. See [worker identity](/development/deployment-guide/#worker-identity) |
|
||||
| Opens and clicks never record | First check the container is actually up with `docker compose -p warmbly ps -a`: a dead `tracking` breaks nothing else, because sends do not wait on it. Then check `TRACKING_DOMAIN` resolves and the service answers on `/health`. If you overrode `KAFKA_TRACKING_TOPIC`, it has to be overridden for the Rust publisher and the Go subscriber together |
|
||||
| `tracking` exits immediately with `Bind for 0.0.0.0:3000 failed: port is already allocated` | Something else on the host owns port `3000`, a very common default. Set `TRACKING_PORT=3001` in `.env`, re-run `make up`, and point your reverse proxy's tracking host at the new port |
|
||||
| The backend restart-loops with `duplicate migration file` | Two migrations on the branch share a version, which stops golang-migrate before a single one runs. Renumber the one that has not been released yet to the next free version and re-deploy. `make check-migrations` reports it, and CI runs the same check on every pull request. See [adding a migration](/development/local-development/#adding-a-migration) |
|
||||
|
||||
@@ -24,6 +24,17 @@ SMTP host: smtp.yourprovider.com port: 465 username: you@yourdomain.com
|
||||
IMAP host: imap.yourprovider.com port: 993 username: you@yourdomain.com
|
||||
```
|
||||
|
||||
Each side also has a **security** setting, which is what decides how the connection is encrypted:
|
||||
|
||||
| Security | What happens | Usual ports |
|
||||
| --- | --- | --- |
|
||||
| SSL / TLS | Encrypted from the first byte | SMTP `465`, IMAP `993` |
|
||||
| STARTTLS | Connects in the clear, then upgrades in place before anything sensitive is sent | SMTP `587` or `2525`, IMAP `143` |
|
||||
|
||||
The form picks the right one from the port as you type, so standard setups need no thought. Change it yourself when your server is unusual: any port from 1 to 65535 works, so a submission relay on `2525` or IMAP on a custom port is fine as long as the security setting matches what the server actually speaks. Encryption itself is not optional; Warmbly will not send credentials over an unencrypted connection.
|
||||
|
||||
If a mailbox fails to connect with a server-unreachable error and the host and port are definitely right, the security setting is the first thing to check. A server expecting STARTTLS looks unreachable to a client attempting implicit TLS, and vice versa.
|
||||
|
||||
With two-factor authentication on, generate an app password in your provider's security settings and use that.
|
||||
|
||||
<Callout type="warn" title="Authentication runs on connect">
|
||||
|
||||
@@ -5,11 +5,32 @@ description: "A unified inbox across all connected mailboxes, with categories an
|
||||
|
||||
One inbox for every connected mailbox. Read, sort, and reply from a single screen instead of logging into each account. Three columns: a **scope rail** for picking what to look at, a **conversation list**, and a **thread view** where you read and reply.
|
||||
|
||||
## Folders
|
||||
|
||||
The rail opens with the standard mail folders, so direction is visually clear instead of one combined list:
|
||||
|
||||
| Folder | Shows |
|
||||
| --- | --- |
|
||||
| Inbox | Inbound mail, plus anything filed in a custom folder at the provider |
|
||||
| Drafts | Messages sitting in a mailbox's drafts folder |
|
||||
| Sent | Outbound mail, campaign and manual |
|
||||
| Archive | Archived at the provider (Gmail's All Mail, the Archive folder elsewhere) |
|
||||
| Spam | Junked at the provider |
|
||||
| Trash | Deleted at the provider |
|
||||
|
||||
Each message's folder follows the provider: IMAP special-use folder attributes, Gmail labels, and Outlook well-known folders all map to the same six. Moves at the provider (junking a message, clearing it out of spam) follow on the next sync. The active folder is highlighted with a grey row and bold label, unread counts sit on the right, and each folder's three-dot menu offers **Mark all as read**.
|
||||
|
||||
Spam and Trash stay out of every other view: the **All** scope, the metric strip, and the unread badge only count the folders you actually work.
|
||||
|
||||
<Callout type="info" title="Spam and Trash start from when you connect">
|
||||
The initial import that runs when a mailbox is first connected covers Inbox, Sent, Archive, and Drafts. It deliberately skips the existing contents of Spam and Trash, whose history would consume the import budget that belongs to real conversations. Both folders fill normally from the moment the mailbox is connected, so they show what arrives from then on rather than what was already there.
|
||||
</Callout>
|
||||
|
||||
## Scopes
|
||||
|
||||
| View | Shows |
|
||||
| --- | --- |
|
||||
| All | Every conversation across every mailbox |
|
||||
| All | Every conversation across every mailbox (except Spam and Trash) |
|
||||
| Unread | At least one unread message |
|
||||
| Today / This week | Activity today, or in the last 7 days |
|
||||
| Awaiting reply | The other side is waiting on you |
|
||||
|
||||
+288
-5
@@ -72,6 +72,10 @@
|
||||
{
|
||||
"name": "deliverability-ops",
|
||||
"description": "Deliverability event ingest, suppression, and seed placement."
|
||||
},
|
||||
{
|
||||
"name": "ai",
|
||||
"description": "The AI tool registry over REST, for function-calling agents that do not speak MCP."
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
@@ -7505,7 +7509,7 @@
|
||||
"get": {
|
||||
"operationId": "unibox_list",
|
||||
"summary": "List incoming mail",
|
||||
"description": "Org-wide inbox list, collapsed to one row per thread (newest message), with filtering and cursor pagination. Excludes snoozed threads unless `snoozed=true`.",
|
||||
"description": "Org-wide inbox list, collapsed to one row per thread (newest message), with filtering and cursor pagination. Excludes snoozed threads unless `snoozed=true`, and the `spam` and `trash` folders unless `folder` selects one of them.",
|
||||
"tags": [
|
||||
"unibox"
|
||||
],
|
||||
@@ -7553,6 +7557,23 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "folder",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Canonical folder scope. Omit for every folder except `spam` and `trash`.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"inbox",
|
||||
"sent",
|
||||
"drafts",
|
||||
"archive",
|
||||
"spam",
|
||||
"trash"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unseen",
|
||||
"in": "query",
|
||||
@@ -17911,6 +17932,225 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ai/tools": {
|
||||
"get": {
|
||||
"operationId": "ai_tools_list",
|
||||
"summary": "List agent tools",
|
||||
"description": "The AI tool registry filtered to what the caller's credentials allow. `format=openai` (aliases `hermes`, `functions`) returns OpenAI function-calling objects; the default returns `{name, description, input_schema}` per tool. Send-class tools are never listed.",
|
||||
"tags": [
|
||||
"ai"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Manifest format.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"warmbly",
|
||||
"openai",
|
||||
"hermes",
|
||||
"functions"
|
||||
],
|
||||
"default": "warmbly"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The permitted tool catalog.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "One tool, shaped by `format`."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Unknown format, or no organization for this key.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limited.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ai/tools/{name}/call": {
|
||||
"post": {
|
||||
"operationId": "ai_tools_call",
|
||||
"summary": "Execute an agent tool",
|
||||
"description": "Runs one registry tool. The request body is the tool's JSON argument object (empty body = no arguments). Each tool enforces its own permission; send-class tools are never callable here.",
|
||||
"tags": [
|
||||
"ai"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The tool name from the list endpoint.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/IdempotencyKey"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "The tool's argument object, matching its input schema."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The tool's output.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"result"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"result": {
|
||||
"description": "The tool's output, embedded as JSON when the tool returned JSON."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Malformed argument body, or no organization for this key.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The credentials lack the permission for this tool.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Unknown tool.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Tool-level failure; the message is meant for the model to read.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limited.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -22640,6 +22880,13 @@
|
||||
"type": "integer",
|
||||
"description": "Max queued scheduled sends allowed."
|
||||
},
|
||||
"folders": {
|
||||
"type": "array",
|
||||
"description": "Per-folder thread counts, always all six canonical folders in sidebar order, zero-filled.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UniboxOverviewFolder"
|
||||
}
|
||||
},
|
||||
"mailboxes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -22672,6 +22919,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UniboxOverviewFolder": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"folder",
|
||||
"unread",
|
||||
"total"
|
||||
],
|
||||
"properties": {
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"inbox",
|
||||
"sent",
|
||||
"drafts",
|
||||
"archive",
|
||||
"spam",
|
||||
"trash"
|
||||
]
|
||||
},
|
||||
"unread": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UniboxLabelList": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -22708,10 +22982,7 @@
|
||||
},
|
||||
"UniboxMarkSeenRequest": {
|
||||
"type": "object",
|
||||
"description": "Also the echoed response body.",
|
||||
"required": [
|
||||
"email_ids"
|
||||
],
|
||||
"description": "Also the echoed response body. Send one of `email_ids` or `folder`, not both.",
|
||||
"properties": {
|
||||
"email_ids": {
|
||||
"type": "array",
|
||||
@@ -22722,6 +22993,18 @@
|
||||
"maxItems": 500,
|
||||
"description": "Message UUIDs to update (max 500)."
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"inbox",
|
||||
"sent",
|
||||
"drafts",
|
||||
"archive",
|
||||
"spam",
|
||||
"trash"
|
||||
],
|
||||
"description": "Sweep every message in this canonical folder instead of an id list."
|
||||
},
|
||||
"seen": {
|
||||
"type": "boolean",
|
||||
"description": "`true` marks as read, `false` marks as unread."
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// REST tool surface for external AI agents that do not speak MCP (Hermes-style
|
||||
// function calling, OpenAI-compatible frameworks, plain HTTP). Exposes the same
|
||||
// shared tool registry as POST /v1/mcp with the same rules: each tool is gated
|
||||
// by its own permission bits, the list reflects only what the caller may use,
|
||||
// and send-class tools are never exposed.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/api/middleware"
|
||||
"github.com/warmbly/warmbly/internal/app/aitools"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/pkg/generation"
|
||||
)
|
||||
|
||||
// agentToolInvocation builds the registry invocation for either credential
|
||||
// kind: JWT callers run as the member with their org permission bits, API-key
|
||||
// and OAuth callers run under the key's permission mask.
|
||||
func (h *Handler) agentToolInvocation(c *gin.Context) (aitools.Invocation, *errx.Error) {
|
||||
if middleware.GetAuthType(c) == "jwt" {
|
||||
return h.jwtInvocation(c)
|
||||
}
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
return aitools.Invocation{}, errx.New(errx.BadRequest, "no organization for this key")
|
||||
}
|
||||
inv := aitools.Invocation{
|
||||
OrgID: *orgID,
|
||||
IsAPIKey: true,
|
||||
APIPerms: middleware.GetAPIKeyPermissions(c),
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
if uid, err := middleware.GetUserUUID(c); err == nil {
|
||||
inv.UserID = uid
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// ListAgentTools — GET /ai/tools[?format=openai]. The default shape mirrors
|
||||
// the registry; format=openai (alias: hermes, functions) returns OpenAI
|
||||
// function-calling objects usable verbatim in an OpenAI-compatible `tools`
|
||||
// array or inside a Hermes <tools> block.
|
||||
func (h *Handler) ListAgentTools(c *gin.Context) {
|
||||
if h.AITools == nil {
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI tools are not available"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.agentToolInvocation(c)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
|
||||
format := c.DefaultQuery("format", "warmbly")
|
||||
switch format {
|
||||
case "warmbly", "openai", "hermes", "functions":
|
||||
default:
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "format must be warmbly, openai, hermes or functions"))
|
||||
return
|
||||
}
|
||||
|
||||
tools := h.AITools.PermittedTools(inv)
|
||||
out := make([]gin.H, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
if t.Risk == generation.RiskSend {
|
||||
continue
|
||||
}
|
||||
schema := t.InputSchema
|
||||
if schema == nil {
|
||||
schema = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
}
|
||||
if format == "warmbly" {
|
||||
out = append(out, gin.H{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"input_schema": schema,
|
||||
})
|
||||
continue
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"type": "function",
|
||||
"function": gin.H{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": schema,
|
||||
},
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
// CallAgentTool — POST /ai/tools/:name/call. The request body is the tool's
|
||||
// argument object; the result is the tool's output, embedded as JSON when the
|
||||
// tool returned JSON (they all do today) and as a string otherwise.
|
||||
func (h *Handler) CallAgentTool(c *gin.Context) {
|
||||
if h.AITools == nil {
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI tools are not available"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.agentToolInvocation(c)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
|
||||
name := c.Param("name")
|
||||
|
||||
// Send-class tools are never exposed or callable over this surface,
|
||||
// matching the MCP endpoint.
|
||||
if t, ok := h.AITools.Get(name); ok && t.Risk == generation.RiskSend {
|
||||
errx.JSON(c, errx.New(errx.NotFound, "tool not found"))
|
||||
return
|
||||
}
|
||||
|
||||
// Read rather than trusting ContentLength: a chunked request reports -1,
|
||||
// and skipping the body there would silently run the tool with no
|
||||
// arguments instead of the ones the model produced.
|
||||
args := json.RawMessage(`{}`)
|
||||
raw, rerr := io.ReadAll(c.Request.Body)
|
||||
if rerr != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "the request body could not be read"))
|
||||
return
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) > 0 {
|
||||
if !json.Valid(raw) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "the request body must be the tool's JSON argument object"))
|
||||
return
|
||||
}
|
||||
args = json.RawMessage(raw)
|
||||
}
|
||||
|
||||
out, err := h.AITools.Call(c.Request.Context(), inv, name, args)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, aitools.ErrToolNotFound):
|
||||
errx.JSON(c, errx.New(errx.NotFound, "tool not found"))
|
||||
case errors.Is(err, aitools.ErrToolForbidden):
|
||||
errx.JSON(c, errx.New(errx.Forbidden, "your credentials lack the permission for this tool"))
|
||||
default:
|
||||
// A tool-level failure is the agent's to read and react to, not a
|
||||
// transport error: surface the message with a stable 422.
|
||||
errx.JSON(c, errx.New(errx.Unprocessable, err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var result any = out
|
||||
var decoded json.RawMessage
|
||||
if json.Unmarshal([]byte(out), &decoded) == nil {
|
||||
result = decoded
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"name": name, "result": result}})
|
||||
}
|
||||
@@ -87,6 +87,16 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) {
|
||||
params.Direction = &direction
|
||||
}
|
||||
|
||||
// Folder scope. Absent = every folder except spam and trash; an
|
||||
// unknown value is a 400 rather than silently widening the result.
|
||||
if folder := c.Query("folder"); folder != "" {
|
||||
if !models.ValidFolder(folder) {
|
||||
errx.Handle(c, errx.ErrUniboxFolder)
|
||||
return
|
||||
}
|
||||
params.Folder = &folder
|
||||
}
|
||||
|
||||
// Parse subject filter
|
||||
if subject := c.Query("subject"); subject != "" {
|
||||
params.Subject = &subject
|
||||
|
||||
@@ -525,6 +525,18 @@ func Run(
|
||||
skillsGroup.DELETE("/:id", m.RequireAccess(models.PermManageSettings, models.APIPermAIAgent), h.DeleteSkill)
|
||||
}
|
||||
|
||||
// REST tool surface for non-MCP agents (Hermes/OpenAI-style function
|
||||
// calling). No route-level permission gate on purpose, matching the
|
||||
// advisor apply path and the MCP endpoint: the registry enforces each
|
||||
// tool's own permission bits, the list reflects only what the caller
|
||||
// may use, and send-class tools are never exposed.
|
||||
agentTools := protected.Group("/ai/tools")
|
||||
agentTools.Use(m.RequireOrganization())
|
||||
{
|
||||
agentTools.GET("", m.RateLimitMiddleware(models.RateLimitRead), h.ListAgentTools)
|
||||
agentTools.POST("/:name/call", m.RateLimitMiddleware(models.RateLimitWrite), h.CallAgentTool)
|
||||
}
|
||||
|
||||
// Advisor. Reads are an analytics read of the org's sending
|
||||
// posture. Apply/undo carry no gate here on purpose: the fix runs
|
||||
// through the AI tool registry, which enforces whatever permission
|
||||
|
||||
@@ -18,6 +18,7 @@ func (d Deps) registerUniboxTools(r *Registry) {
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"subject": strProp("Optional subject contains filter."),
|
||||
"sender": strProp("Optional sender email filter."),
|
||||
"folder": strProp("Optional folder: inbox, sent, drafts, archive, spam or trash. Omit for every folder except spam and trash."),
|
||||
"unseen_only": boolProp("Only threads with unread messages."),
|
||||
"awaiting_reply": boolProp("Only threads whose LATEST message was sent by one of our mailboxes (we spoke last, still waiting on them)."),
|
||||
"limit": intProp("Max threads (1-50, default 20)."),
|
||||
@@ -79,6 +80,7 @@ func (d Deps) listThreads(ctx context.Context, inv Invocation, args json.RawMess
|
||||
in, err := decodeArgs[struct {
|
||||
Subject string `json:"subject"`
|
||||
Sender string `json:"sender"`
|
||||
Folder string `json:"folder"`
|
||||
UnseenOnly bool `json:"unseen_only"`
|
||||
AwaitingReply bool `json:"awaiting_reply"`
|
||||
Limit int `json:"limit"`
|
||||
@@ -86,6 +88,9 @@ func (d Deps) listThreads(ctx context.Context, inv Invocation, args json.RawMess
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if in.Folder != "" && !models.ValidFolder(in.Folder) {
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
@@ -97,6 +102,9 @@ func (d Deps) listThreads(ctx context.Context, inv Invocation, args json.RawMess
|
||||
if in.Sender != "" {
|
||||
params.Sender = &in.Sender
|
||||
}
|
||||
if in.Folder != "" {
|
||||
params.Folder = &in.Folder
|
||||
}
|
||||
if in.UnseenOnly {
|
||||
t := true
|
||||
params.Unseen = &t
|
||||
|
||||
@@ -391,8 +391,8 @@ func (s *service) Enroll(ctx context.Context, orgID, accountID uuid.UUID) (*mode
|
||||
return nil, xerr
|
||||
}
|
||||
req.SMTPIMAP = &models.SmtpImap{
|
||||
SMTP: &models.Service{Host: creds.SMTPHost, Port: creds.SMTPPort, Username: creds.SMTPUser, Password: creds.SMTPPassword},
|
||||
IMAP: &models.Service{Host: creds.IMAPHost, Port: creds.IMAPPort, Username: creds.IMAPUser, Password: creds.IMAPPassword},
|
||||
SMTP: &models.Service{Host: creds.SMTPHost, Port: creds.SMTPPort, Username: creds.SMTPUser, Password: creds.SMTPPassword, Security: creds.SMTPSecurity},
|
||||
IMAP: &models.Service{Host: creds.IMAPHost, Port: creds.IMAPPort, Username: creds.IMAPUser, Password: creds.IMAPPassword, Security: creds.IMAPSecurity},
|
||||
}
|
||||
default:
|
||||
return nil, ErrOAuthMailbox
|
||||
|
||||
@@ -103,6 +103,11 @@ func (s *JobsService) detectDeadWorkers(ctx context.Context) {
|
||||
}
|
||||
|
||||
if len(accountIDs) == 0 {
|
||||
// Nothing to move: retire the row so it stops being rescanned
|
||||
// every interval. Churned ids without WORKER_ID accumulate here
|
||||
// forever otherwise; a returning worker reactivates itself on its
|
||||
// next heartbeat upsert.
|
||||
s.deactivateIfLongDead(ctx, w)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -124,6 +129,18 @@ func (s *JobsService) detectDeadWorkers(ctx context.Context) {
|
||||
}
|
||||
reassigned++
|
||||
|
||||
// Keep the placement counters honest on both rows; without this
|
||||
// every auto-reassignment permanently skews account_count. The
|
||||
// move itself already succeeded, so a counter failure is logged
|
||||
// rather than retried: capacity self-corrects on the next
|
||||
// placement pass, and unwinding the move would strand the mailbox.
|
||||
if cerr := s.WorkerRepo.DecrementAccountCount(ctx, w.ID); cerr != nil {
|
||||
log.Warn().Err(cerr).Str("worker_id", w.ID.String()).Msg("dead worker reassign: account_count not decremented")
|
||||
}
|
||||
if cerr := s.WorkerRepo.IncrementAccountCount(ctx, replacement.ID); cerr != nil {
|
||||
log.Warn().Err(cerr).Str("worker_id", replacement.ID.String()).Msg("dead worker reassign: account_count not incremented")
|
||||
}
|
||||
|
||||
account, aerr := s.EmailRepository.GetByID(ctx, accountID)
|
||||
if aerr != nil || account == nil {
|
||||
continue
|
||||
@@ -168,9 +185,42 @@ func (s *JobsService) detectDeadWorkers(ctx context.Context) {
|
||||
|
||||
s.notifyWorkerDown(ctx, w.ID, affectedOrgs, true)
|
||||
}
|
||||
|
||||
if reassigned == len(accountIDs) {
|
||||
s.deactivateIfLongDead(ctx, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deactivateIfLongDead retires a heartbeat-expired worker row, but only when
|
||||
// its registry timestamp is stale too. During a Redis outage every heartbeat
|
||||
// key vanishes at once while POSTed beats keep last_seen_at fresh; gating on
|
||||
// both signals keeps that from deactivating the whole live fleet.
|
||||
//
|
||||
// The worker is re-read first because the caller's copy is a snapshot from the
|
||||
// top of the scan, which walks the whole fleet and can take a while: a worker
|
||||
// that booted during the scan must not be deactivated on stale evidence.
|
||||
func (s *JobsService) deactivateIfLongDead(ctx context.Context, w models.Worker) {
|
||||
current, err := s.WorkerRepo.GetByID(ctx, w.ID)
|
||||
if err != nil || current == nil {
|
||||
return
|
||||
}
|
||||
// Never seen at all means the age is unknown, not old. Leave it alone
|
||||
// rather than retiring a row that may be mid-registration.
|
||||
if current.LastSeenAt == nil || time.Since(*current.LastSeenAt) < 10*time.Minute {
|
||||
return
|
||||
}
|
||||
// One last heartbeat check against the freshly-read row.
|
||||
if n, herr := s.Cache.Exists(ctx, "worker:heartbeat:"+w.ID.String()).Result(); herr != nil || n > 0 {
|
||||
return
|
||||
}
|
||||
if err := s.WorkerRepo.DeactivateWorker(ctx, w.ID); err != nil {
|
||||
log.Warn().Err(err).Str("worker_id", w.ID.String()).Msg("failed to deactivate dead worker")
|
||||
return
|
||||
}
|
||||
log.Info().Str("worker_id", w.ID.String()).Msg("dead worker deactivated")
|
||||
}
|
||||
|
||||
// accountOrgs resolves which orgs own the given accounts (org -> count).
|
||||
func (s *JobsService) accountOrgs(ctx context.Context, accountIDs []uuid.UUID) map[uuid.UUID]int {
|
||||
orgs := map[uuid.UUID]int{}
|
||||
|
||||
@@ -73,14 +73,21 @@ func (s *JobsService) HandleFlagsAdd(ctx context.Context, e *models.JobEventFlag
|
||||
return nil
|
||||
}
|
||||
|
||||
update := repository.UpdateUniboxEntry{Flags: email.Flags}
|
||||
// A provider-side junking (Gmail SPAM label, IMAP \Junk) moves the
|
||||
// message into the spam folder; trash placement is stronger and kept.
|
||||
if containsSpamFlag(e.Flags) && email.Folder != models.FolderTrash && email.Folder != models.FolderSpam {
|
||||
folder := models.FolderSpam
|
||||
update.Folder = &folder
|
||||
email.Folder = folder
|
||||
}
|
||||
|
||||
if err := s.UniboxRepository.UpdateEntry(
|
||||
ctx,
|
||||
e.UserID,
|
||||
e.EmailID,
|
||||
e.ID,
|
||||
&repository.UpdateUniboxEntry{
|
||||
Flags: email.Flags,
|
||||
},
|
||||
&update,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,14 +140,21 @@ func (s *JobsService) HandleFlagsRemove(ctx context.Context, e *models.JobEventF
|
||||
return nil
|
||||
}
|
||||
|
||||
update := repository.UpdateUniboxEntry{Flags: newFlags}
|
||||
// Un-junking at the provider (spam label cleared while nothing else
|
||||
// still marks it spam) restores the message to the inbox.
|
||||
if email.Folder == models.FolderSpam && containsSpamFlag(e.Flags) && !containsSpamFlag(newFlags) {
|
||||
folder := models.FolderInbox
|
||||
update.Folder = &folder
|
||||
email.Folder = folder
|
||||
}
|
||||
|
||||
if err := s.UniboxRepository.UpdateEntry(
|
||||
ctx,
|
||||
e.UserID,
|
||||
e.EmailID,
|
||||
e.ID,
|
||||
&repository.UpdateUniboxEntry{
|
||||
Flags: newFlags,
|
||||
},
|
||||
&update,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ func (s *JobsService) emailInboxEvent(ctx context.Context, userID uuid.UUID, mes
|
||||
Subject: message.Subject,
|
||||
From: strings.Join(message.FromAddr, ", "),
|
||||
Preview: message.Snippet,
|
||||
Folder: models.NormalizeFolder(message.Folder, message.Flags),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ func (s *JobsService) HandleUpdateEmail(ctx context.Context, e *models.JobEventE
|
||||
if email.ModSeq != e.ModSeq {
|
||||
updateData.ModSeq = &e.ModSeq
|
||||
}
|
||||
// A folder move follows the provider. Events from workers predating the
|
||||
// field carry "", which keeps the stored value.
|
||||
if models.ValidFolder(e.Folder) && email.Folder != e.Folder {
|
||||
updateData.Folder = &e.Folder
|
||||
}
|
||||
|
||||
if err := s.UniboxRepository.UpdateEntry(ctx, e.UserID, e.EmailID, e.ID, &updateData); err != nil {
|
||||
return err
|
||||
@@ -39,6 +44,9 @@ func (s *JobsService) HandleUpdateEmail(ctx context.Context, e *models.JobEventE
|
||||
email.UID = e.UID
|
||||
email.Mailbox = e.Mailbox
|
||||
email.ModSeq = e.ModSeq
|
||||
if models.ValidFolder(e.Folder) {
|
||||
email.Folder = e.Folder
|
||||
}
|
||||
s.publishEmailUpdated(ctx, e.UserID, email)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -316,8 +316,8 @@ func (s *emailService) buildAddWorkerEmail(ctx context.Context, acc *models.Emai
|
||||
out.ImapSync = true
|
||||
out.SmtpImap = &models.AddWorkerEmailSmtpImapData{
|
||||
Credentials: &models.SmtpImap{
|
||||
SMTP: &models.Service{Host: creds.SMTPHost, Port: creds.SMTPPort, Username: creds.SMTPUser, Password: creds.SMTPPassword},
|
||||
IMAP: &models.Service{Host: creds.IMAPHost, Port: creds.IMAPPort, Username: creds.IMAPUser, Password: creds.IMAPPassword},
|
||||
SMTP: &models.Service{Host: creds.SMTPHost, Port: creds.SMTPPort, Username: creds.SMTPUser, Password: creds.SMTPPassword, Security: creds.SMTPSecurity},
|
||||
IMAP: &models.Service{Host: creds.IMAPHost, Port: creds.IMAPPort, Username: creds.IMAPUser, Password: creds.IMAPPassword, Security: creds.IMAPSecurity},
|
||||
},
|
||||
Mailboxes: s.mailboxesFor(ctx, userID, acc.ID),
|
||||
}
|
||||
|
||||
@@ -309,15 +309,35 @@ func validateSMTPIMAPInput(data *models.NewSMTPIMAPAccount) *errx.Error {
|
||||
if strings.TrimSpace(data.SMTP.Host) == "" {
|
||||
return errx.ErrEmailSMTPHost
|
||||
}
|
||||
if data.SMTP.Port != 465 && data.SMTP.Port != 587 {
|
||||
if !validPort(data.SMTP.Port) {
|
||||
return errx.ErrEmailSMTPPort
|
||||
}
|
||||
if strings.TrimSpace(data.IMAP.Host) == "" {
|
||||
return errx.ErrEmailIMAPHost
|
||||
}
|
||||
if data.IMAP.Port <= 0 {
|
||||
if !validPort(data.IMAP.Port) {
|
||||
return errx.ErrEmailIMAPPort
|
||||
}
|
||||
return validateMailSecurity(data.SMTP, data.IMAP)
|
||||
}
|
||||
|
||||
// validPort accepts any routable TCP port. Mail submission is conventionally
|
||||
// 465/587 and IMAP 993/143, but plenty of providers and self-hosted servers
|
||||
// use 2525, 25, or something else entirely, and the security mode (not the
|
||||
// port) is what decides how we connect.
|
||||
func validPort(port int) bool {
|
||||
return port > 0 && port <= 65535
|
||||
}
|
||||
|
||||
// validateMailSecurity rejects an unknown security mode. Empty is allowed and
|
||||
// means "infer from the port", which is how existing clients behave.
|
||||
func validateMailSecurity(smtp, imap *models.Service) *errx.Error {
|
||||
if smtp.Security != "" && !models.ValidMailSecurity(smtp.Security) {
|
||||
return errx.ErrEmailSMTPSecurity
|
||||
}
|
||||
if imap.Security != "" && !models.ValidMailSecurity(imap.Security) {
|
||||
return errx.ErrEmailIMAPSecurity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -218,14 +218,14 @@ func validateSMTPIMAPCredentials(creds *models.SmtpImap) *errx.Error {
|
||||
if strings.TrimSpace(creds.SMTP.Host) == "" {
|
||||
return errx.ErrEmailSMTPHost
|
||||
}
|
||||
if creds.SMTP.Port != 465 && creds.SMTP.Port != 587 {
|
||||
if !validPort(creds.SMTP.Port) {
|
||||
return errx.ErrEmailSMTPPort
|
||||
}
|
||||
if strings.TrimSpace(creds.IMAP.Host) == "" {
|
||||
return errx.ErrEmailIMAPHost
|
||||
}
|
||||
if creds.IMAP.Port <= 0 {
|
||||
if !validPort(creds.IMAP.Port) {
|
||||
return errx.ErrEmailIMAPPort
|
||||
}
|
||||
return nil
|
||||
return validateMailSecurity(creds.SMTP, creds.IMAP)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,22 @@ func (s *uniboxService) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data
|
||||
return nil, errx.ErrSeenMax
|
||||
}
|
||||
|
||||
// A folder sweep and an id list are different requests; refuse the
|
||||
// ambiguous combination instead of guessing which one was meant.
|
||||
if data.Folder != "" {
|
||||
if len(data.EmailIDs) > 0 {
|
||||
return nil, errx.ErrSeenFolderAndIDs
|
||||
}
|
||||
if !models.ValidFolder(data.Folder) {
|
||||
return nil, errx.ErrUniboxFolder
|
||||
}
|
||||
if err := s.uniboxRepository.MarkSeenByFolder(ctx, orgID, data.Folder, data.Seen); err != nil {
|
||||
sentry.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if err := s.uniboxRepository.MarkSeenBulk(ctx, orgID, data.EmailIDs, data.Seen); err != nil {
|
||||
sentry.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
|
||||
@@ -48,10 +48,10 @@ func (w *WorkerService) HandleEmailValidation(ctx context.Context, data models.E
|
||||
}()
|
||||
}
|
||||
probe(func() bool {
|
||||
return email.VerifyImap(ctx, data.Credentials.IMAP.Host, data.Credentials.IMAP.Port, data.Credentials.IMAP.Username, data.Credentials.IMAP.Password)
|
||||
return email.VerifyImap(ctx, data.Credentials.IMAP.Host, data.Credentials.IMAP.Port, data.Credentials.IMAP.Username, data.Credentials.IMAP.Password, data.Credentials.IMAP.Security)
|
||||
})
|
||||
probe(func() bool {
|
||||
return email.VerifySMTP(ctx, data.Credentials.SMTP.Host, data.Credentials.SMTP.Port, data.Credentials.SMTP.Username, data.Credentials.SMTP.Password)
|
||||
return email.VerifySMTP(ctx, data.Credentials.SMTP.Host, data.Credentials.SMTP.Port, data.Credentials.SMTP.Username, data.Credentials.SMTP.Password, data.Credentials.SMTP.Security)
|
||||
})
|
||||
|
||||
result1 := <-results
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package wmail
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// imapCanonicalFolder decides which sidebar folder a message lands in, so a
|
||||
// wrong answer here silently files mail under the wrong scope.
|
||||
func TestImapCanonicalFolder(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
box models.Mailbox
|
||||
want string
|
||||
}{
|
||||
// Special-use attributes are authoritative.
|
||||
{"sent by attribute", models.Mailbox{Name: "Whatever", Attrs: []string{"\\Sent"}}, models.FolderSent},
|
||||
{"drafts by attribute", models.Mailbox{Name: "Whatever", Attrs: []string{"\\Drafts"}}, models.FolderDrafts},
|
||||
{"junk by attribute", models.Mailbox{Name: "Whatever", Attrs: []string{"\\Junk"}}, models.FolderSpam},
|
||||
{"trash by attribute", models.Mailbox{Name: "Whatever", Attrs: []string{"\\Trash"}}, models.FolderTrash},
|
||||
{"archive by attribute", models.Mailbox{Name: "Whatever", Attrs: []string{"\\Archive"}}, models.FolderArchive},
|
||||
{"all mail is archive", models.Mailbox{Name: "[Gmail]/All Mail", Attrs: []string{"\\All"}}, models.FolderArchive},
|
||||
// Name fallback for servers that advertise no special-use.
|
||||
{"sent by name", models.Mailbox{Name: "Sent Items"}, models.FolderSent},
|
||||
{"sent by nested name", models.Mailbox{Name: "INBOX.Sent"}, models.FolderSent},
|
||||
{"spam by name", models.Mailbox{Name: "Junk E-Mail"}, models.FolderSpam},
|
||||
{"trash by name", models.Mailbox{Name: "Deleted Items"}, models.FolderTrash},
|
||||
{"drafts by name", models.Mailbox{Name: "Drafts"}, models.FolderDrafts},
|
||||
// Anything unrecognised stays visible rather than vanishing into a
|
||||
// scope the user never opens.
|
||||
{"inbox", models.Mailbox{Name: "INBOX"}, models.FolderInbox},
|
||||
{"user folder", models.Mailbox{Name: "INBOX.Clients.Acme"}, models.FolderInbox},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := imapCanonicalFolder(&tc.box); got != tc.want {
|
||||
t.Fatalf("imapCanonicalFolder(%+v) = %q, want %q", tc.box, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Drafts is imported now that the folder sidebar gives it a destination; the
|
||||
// rest of the eligibility matrix lives in TestImapBackfillEligible.
|
||||
func TestImapBackfillEligible_DraftsByName(t *testing.T) {
|
||||
for _, name := range []string{"Drafts", "Draft", "INBOX.Drafts"} {
|
||||
if !imapBackfillEligible(&models.Mailbox{Name: name}) {
|
||||
t.Fatalf("imapBackfillEligible(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,9 @@ func TestImapBackfillEligible(t *testing.T) {
|
||||
{"Trash", []string{"\\Trash"}, false},
|
||||
{"Junk", nil, false},
|
||||
{"INBOX.Spam", nil, false},
|
||||
{"Drafts", []string{"\\Drafts"}, false},
|
||||
// Drafts is imported since the folder sidebar gave it a destination;
|
||||
// trash and spam stay out so their history cannot eat the budget.
|
||||
{"Drafts", []string{"\\Drafts"}, true},
|
||||
{"[Gmail]", []string{"\\Noselect"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -120,6 +120,7 @@ func (w *WMail) googleStore(ctx context.Context, msg *models.EmailMessageData) e
|
||||
ID: msg.ID,
|
||||
EmailID: w.ID,
|
||||
Mailbox: 0,
|
||||
Folder: msg.Folder,
|
||||
ThreadID: msg.ThreadID,
|
||||
MessageID: msg.MessageID,
|
||||
GmailID: msg.GmailID,
|
||||
@@ -150,8 +151,11 @@ func (w *WMail) googleStore(ctx context.Context, msg *models.EmailMessageData) e
|
||||
}
|
||||
|
||||
// googleBackfill imports the mailbox's recent history newest first, one
|
||||
// messages.list page at a time, resuming from the saved page token. The
|
||||
// query excludes what the IMAP path also skips: trash, spam, drafts, chats.
|
||||
// messages.list page at a time, resuming from the saved page token. The query
|
||||
// excludes what the IMAP path also skips: trash and spam (their history would
|
||||
// eat the message budget that belongs to real conversations; live sync still
|
||||
// files new mail into those scopes) plus chats. Drafts are imported, matching
|
||||
// IMAP, so the Drafts scope is not empty of everything written before connect.
|
||||
func (w *WMail) googleBackfill(ctx context.Context, stats *tickStats) *errx.MailError {
|
||||
st := &w.tracker.state
|
||||
if st.BackfillStatus == models.SyncBackfillComplete {
|
||||
@@ -159,7 +163,7 @@ func (w *WMail) googleBackfill(ctx context.Context, stats *tickStats) *errx.Mail
|
||||
}
|
||||
policy := w.gov.Policy()
|
||||
w.tracker.startBackfill(time.Now(), policy.BackfillDays)
|
||||
q := fmt.Sprintf("after:%d -in:trash -in:spam -in:drafts -in:chats", st.BackfillSince.Unix())
|
||||
q := fmt.Sprintf("after:%d -in:trash -in:spam -in:chats", st.BackfillSince.Unix())
|
||||
|
||||
for !stats.aborted && !stats.laneDenied(LaneBackfill) {
|
||||
if st.BackfillSynced >= policy.BackfillMessages {
|
||||
|
||||
@@ -111,6 +111,7 @@ func (w *WMail) graphStore(ctx context.Context, msg *models.EmailMessageData) er
|
||||
ID: msg.ID,
|
||||
EmailID: w.ID,
|
||||
Mailbox: 0,
|
||||
Folder: msg.Folder,
|
||||
ThreadID: msg.ThreadID,
|
||||
MessageID: msg.MessageID,
|
||||
GmailID: msg.GmailID,
|
||||
|
||||
@@ -58,6 +58,7 @@ func (w *WMail) Sync(ctx context.Context) *errx.MailError {
|
||||
fullyProcessed := true
|
||||
if befBox.HighestModSeq != box.HighestModSeq && !stats.aborted {
|
||||
w.SmtpImapData.mailbox = box.UIDValidity
|
||||
w.SmtpImapData.folder = imapCanonicalFolder(box)
|
||||
done, err := w.imapIncremental(ctx, box, befBox.HighestModSeq, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -204,6 +205,7 @@ func (w *WMail) imapApply(ctx context.Context, fetched []*imap.Fetched, backfill
|
||||
UID: f.Email.UID,
|
||||
ModSeq: f.Email.ModSeq,
|
||||
Mailbox: w.SmtpImapData.mailbox,
|
||||
Folder: w.SmtpImapData.folder,
|
||||
Flags: f.Email.Flags,
|
||||
}); err != nil {
|
||||
return false, w.controlPlaneError(err, stats)
|
||||
@@ -285,6 +287,7 @@ func (w *WMail) imapStore(ctx context.Context, msg *models.EmailMessageData) err
|
||||
ID: msg.ID,
|
||||
EmailID: w.ID,
|
||||
Mailbox: w.SmtpImapData.mailbox,
|
||||
Folder: w.SmtpImapData.folder,
|
||||
ThreadID: threadID,
|
||||
MessageID: msg.MessageID,
|
||||
GmailID: msg.GmailID,
|
||||
@@ -344,6 +347,7 @@ func (w *WMail) imapBackfill(ctx context.Context, folders []models.Mailbox, stat
|
||||
return nil
|
||||
}
|
||||
w.SmtpImapData.mailbox = box.UIDValidity
|
||||
w.SmtpImapData.folder = imapCanonicalFolder(box)
|
||||
|
||||
count, err := client.SelectForSync(box.Name)
|
||||
if err != nil {
|
||||
@@ -402,14 +406,19 @@ func (w *WMail) imapBackfill(ctx context.Context, folders []models.Mailbox, stat
|
||||
}
|
||||
|
||||
// imapBackfillEligible excludes folders whose history is not worth importing:
|
||||
// trash, drafts, spam and Gmail's virtual "All Mail" (a duplicate of every
|
||||
// other folder). Live sync still follows them for placement signals; only the
|
||||
// import skips them. Special-use attributes are authoritative, with a name
|
||||
// fallback for servers that do not advertise them.
|
||||
// trash, spam and Gmail's virtual "All Mail" (a duplicate of every other
|
||||
// folder). Live sync still follows them for placement signals and to file new
|
||||
// mail into the Spam and Trash scopes; only the bounded initial import skips
|
||||
// them, because their history would consume the message budget that belongs to
|
||||
// real conversations. Drafts IS imported: it is small and a Drafts scope with
|
||||
// none of the mailbox's existing drafts in it reads as broken.
|
||||
//
|
||||
// Special-use attributes are authoritative, with a name fallback for servers
|
||||
// that do not advertise them.
|
||||
func imapBackfillEligible(box *models.Mailbox) bool {
|
||||
for _, a := range box.Attrs {
|
||||
switch strings.ToLower(a) {
|
||||
case "\\noselect", "\\nonexistent", "\\trash", "\\junk", "\\drafts", "\\all":
|
||||
case "\\noselect", "\\nonexistent", "\\trash", "\\junk", "\\all":
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -418,12 +427,50 @@ func imapBackfillEligible(box *models.Mailbox) bool {
|
||||
name = name[i+1:]
|
||||
}
|
||||
switch name {
|
||||
case "trash", "junk", "spam", "drafts", "draft", "deleted items", "deleted messages", "junk e-mail", "junk email", "bulk mail":
|
||||
case "trash", "junk", "spam", "deleted items", "deleted messages", "junk e-mail", "junk email", "bulk mail":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// imapCanonicalFolder maps an IMAP folder to the canonical unibox folder.
|
||||
// Special-use attributes are authoritative, with a name fallback for servers
|
||||
// that do not advertise them; unrecognized user folders file as inbox so
|
||||
// their mail stays visible.
|
||||
func imapCanonicalFolder(box *models.Mailbox) string {
|
||||
for _, a := range box.Attrs {
|
||||
switch strings.ToLower(a) {
|
||||
case "\\sent":
|
||||
return models.FolderSent
|
||||
case "\\drafts":
|
||||
return models.FolderDrafts
|
||||
case "\\junk":
|
||||
return models.FolderSpam
|
||||
case "\\trash":
|
||||
return models.FolderTrash
|
||||
case "\\archive", "\\all":
|
||||
return models.FolderArchive
|
||||
}
|
||||
}
|
||||
name := strings.ToLower(box.Name)
|
||||
if i := strings.LastIndexAny(name, "/."); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
switch name {
|
||||
case "sent", "sent mail", "sent items", "sent messages":
|
||||
return models.FolderSent
|
||||
case "drafts", "draft":
|
||||
return models.FolderDrafts
|
||||
case "junk", "spam", "junk e-mail", "junk email", "bulk mail":
|
||||
return models.FolderSpam
|
||||
case "trash", "deleted", "deleted items", "deleted messages":
|
||||
return models.FolderTrash
|
||||
case "archive", "archives", "all mail":
|
||||
return models.FolderArchive
|
||||
}
|
||||
return models.FolderInbox
|
||||
}
|
||||
|
||||
// controlPlaneError handles a failed map lookup, body store or event publish
|
||||
// the way the old loop did: log it, hold every cursor by ending the pass, and
|
||||
// retry next tick. It is not a mailbox error, so nothing is relayed to the
|
||||
|
||||
@@ -42,6 +42,9 @@ type SmtpImapData struct {
|
||||
SmtpClient *smtp.Client
|
||||
Mailboxes []*models.Mailbox
|
||||
mailbox uint32
|
||||
// folder is the canonical folder of the mailbox currently being walked,
|
||||
// set alongside mailbox and stamped on every stored/updated message.
|
||||
folder string
|
||||
}
|
||||
|
||||
type WMail struct {
|
||||
|
||||
@@ -109,6 +109,35 @@ func parseGmailDate(dateText string) time.Time {
|
||||
return date
|
||||
}
|
||||
|
||||
// gmailFolder maps Gmail labels to the canonical unibox folder. Precedence
|
||||
// mirrors Gmail's own UI: trash and spam are exclusive, a draft is a draft,
|
||||
// inbox wins over sent for self-addressed mail, and mail carrying none of
|
||||
// these labels is archived.
|
||||
func gmailFolder(labels []string) string {
|
||||
var inbox, sent bool
|
||||
for _, l := range labels {
|
||||
switch l {
|
||||
case "TRASH":
|
||||
return models.FolderTrash
|
||||
case "SPAM":
|
||||
return models.FolderSpam
|
||||
case "DRAFT":
|
||||
return models.FolderDrafts
|
||||
case "INBOX":
|
||||
inbox = true
|
||||
case "SENT":
|
||||
sent = true
|
||||
}
|
||||
}
|
||||
if inbox {
|
||||
return models.FolderInbox
|
||||
}
|
||||
if sent {
|
||||
return models.FolderSent
|
||||
}
|
||||
return models.FolderArchive
|
||||
}
|
||||
|
||||
func GmailMessageToEmailData(msg *gmail.Message) *models.EmailMessageData {
|
||||
var headers []*gmail.MessagePartHeader
|
||||
if msg.Payload != nil {
|
||||
@@ -124,6 +153,7 @@ func GmailMessageToEmailData(msg *gmail.Message) *models.EmailMessageData {
|
||||
GmailID: msg.Id,
|
||||
UID: 0, // Gmail has no IMAP UID
|
||||
ThreadID: msg.ThreadId,
|
||||
Folder: gmailFolder(msg.LabelIds),
|
||||
Flags: func() []string {
|
||||
flags := []string{}
|
||||
// Gmail models read state inversely: the UNREAD label is present on
|
||||
|
||||
@@ -50,12 +50,14 @@ type listPage struct {
|
||||
|
||||
// TrackedFolders are the well-known folders live sync follows: inbox and junk
|
||||
// for placement, sent so a conversation shows both sides (as the Gmail and
|
||||
// IMAP paths already do).
|
||||
var TrackedFolders = []string{FolderInbox, FolderJunk, FolderSent}
|
||||
// IMAP paths already do), and drafts so the Drafts scope is populated on
|
||||
// Outlook the way it is on the other two providers.
|
||||
var TrackedFolders = []string{FolderInbox, FolderJunk, FolderSent, FolderDrafts}
|
||||
|
||||
// BackfillFolders are the folders the initial import walks. Junk is followed
|
||||
// live for placement signals but its history is not worth importing.
|
||||
var BackfillFolders = []string{FolderInbox, FolderSent, FolderArchive}
|
||||
// live for placement signals but its history is not worth importing, and would
|
||||
// consume the message budget that belongs to real conversations.
|
||||
var BackfillFolders = []string{FolderInbox, FolderSent, FolderArchive, FolderDrafts}
|
||||
|
||||
// Sync walks the delta stream for the tracked folders and drives the
|
||||
// OnMessage* callbacks. It is the Graph equivalent of goog.FetchHistory and
|
||||
@@ -217,11 +219,22 @@ func (c *Client) ListMessagesSince(ctx context.Context, folder string, since tim
|
||||
return out, pg.NextLink, nil
|
||||
}
|
||||
|
||||
// ToEmailData maps a hydrated message; folder adds the junk placement flag.
|
||||
// ToEmailData maps a hydrated message; folder sets the canonical placement
|
||||
// and adds the junk flag warmup placement detection reads.
|
||||
func (m *GraphMessage) ToEmailData(folder string) *models.EmailMessageData {
|
||||
data := m.toEmailData()
|
||||
if folder == FolderJunk {
|
||||
switch folder {
|
||||
case FolderJunk:
|
||||
data.Flags = append(data.Flags, "\\Junk")
|
||||
data.Folder = models.FolderSpam
|
||||
case FolderSent:
|
||||
data.Folder = models.FolderSent
|
||||
case FolderArchive:
|
||||
data.Folder = models.FolderArchive
|
||||
case FolderDrafts:
|
||||
data.Folder = models.FolderDrafts
|
||||
case FolderInbox:
|
||||
data.Folder = models.FolderInbox
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
FolderJunk = "junkemail"
|
||||
FolderSent = "sentitems"
|
||||
FolderArchive = "archive"
|
||||
FolderDrafts = "drafts"
|
||||
)
|
||||
|
||||
// Client is a single Microsoft 365 mailbox reached over Graph.
|
||||
|
||||
@@ -65,21 +65,43 @@ type Client struct {
|
||||
}
|
||||
|
||||
func (c *Client) Connect() *errx.MailError {
|
||||
var addr string
|
||||
var addr, host, security string
|
||||
var port int
|
||||
switch c.AuthType {
|
||||
case models.AuthPlain:
|
||||
addr = fmt.Sprintf("%s:%d", c.Credentials.Host, c.Credentials.Port)
|
||||
host, port, security = c.Credentials.Host, c.Credentials.Port, c.Credentials.Security
|
||||
case models.AuthOAuth2:
|
||||
addr = fmt.Sprintf("%s:%d", c.Oauth2.Host, c.Oauth2.Port)
|
||||
host, port = c.Oauth2.Host, c.Oauth2.Port
|
||||
}
|
||||
addr = fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
tlsConf := &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: netbind.InsecureTLS(),
|
||||
}
|
||||
|
||||
dialer := netbind.TLSDialer(c.BindIP, &tls.Config{})
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
var client *imapclient.Client
|
||||
if models.ResolveIMAPSecurity(security, port) == models.MailSecurityStartTLS {
|
||||
// Plaintext greeting, upgraded in-band. Dial through netbind so the
|
||||
// STARTTLS path honours WORKER_BIND_IP like the implicit one.
|
||||
conn, err := netbind.Dialer(c.BindIP).DialContext(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
// NewStartTLS closes conn itself when the upgrade fails.
|
||||
client, err = imapclient.NewStartTLS(conn, &imapclient.Options{TLSConfig: tlsConf})
|
||||
if err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
} else {
|
||||
conn, err := netbind.TLSDialer(c.BindIP, tlsConf).DialContext(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
client = imapclient.New(conn, nil)
|
||||
}
|
||||
|
||||
c.client = imapclient.New(conn, nil)
|
||||
c.client = client
|
||||
c.selected.Store(false)
|
||||
|
||||
var xerr *errx.MailError
|
||||
|
||||
@@ -213,19 +213,35 @@ func writeBase64Wrapped(w io.Writer, data []byte) {
|
||||
func (c *Client) sendRaw(ctx context.Context, from string, to []string, data []byte) *errx.MailError {
|
||||
var host string
|
||||
var port int
|
||||
var security string
|
||||
|
||||
switch c.AuthType {
|
||||
case models.AuthPlain:
|
||||
host = c.Credentials.Host
|
||||
port = c.Credentials.Port
|
||||
security = c.Credentials.Security
|
||||
case models.AuthOAuth2:
|
||||
host = c.Oauth2.Host
|
||||
port = c.Oauth2.Port
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
dialer := netbind.Dialer(c.BindIP)
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
tlsConf := &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: netbind.InsecureTLS(),
|
||||
}
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
// Implicit TLS (SMTPS) means the server speaks TLS from the first byte, so
|
||||
// a plaintext dial + STARTTLS never gets past the greeting. The mode is
|
||||
// the mailbox's stored choice, falling back to the port convention.
|
||||
implicitTLS := models.ResolveSMTPSecurity(security, port) == models.MailSecurityTLS
|
||||
if implicitTLS {
|
||||
conn, err = netbind.TLSDialer(c.BindIP, tlsConf).DialContext(ctx, "tcp", addr)
|
||||
} else {
|
||||
conn, err = netbind.Dialer(c.BindIP).DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
if err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
@@ -238,19 +254,17 @@ func (c *Client) sendRaw(ctx context.Context, from string, to []string, data []b
|
||||
}
|
||||
defer client.Quit()
|
||||
|
||||
tlsConf := &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: netbind.InsecureTLS(),
|
||||
}
|
||||
// TLS is mandatory. The MAIL_TLS_INSECURE dev knob additionally allows a
|
||||
// server with no STARTTLS at all (the local mailpit sink) — never taken in
|
||||
// production, where the env var is unset.
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(tlsConf); err != nil {
|
||||
if !implicitTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(tlsConf); err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
} else if !netbind.InsecureTLS() {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
} else if !netbind.InsecureTLS() {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
+26
-9
@@ -9,9 +9,13 @@ import (
|
||||
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/warmbly/warmbly/internal/client/netbind"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func VerifyImap(ctx context.Context, host string, port int, user, pass string) bool {
|
||||
// VerifyImap probes a mailbox's IMAP credentials the way the sync client
|
||||
// connects: the caller's security mode decides implicit TLS versus STARTTLS.
|
||||
// security may be empty, in which case the port convention decides.
|
||||
func VerifyImap(ctx context.Context, host string, port int, user, pass, security string) bool {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
@@ -24,20 +28,33 @@ func VerifyImap(ctx context.Context, host string, port int, user, pass string) b
|
||||
// Matches the sync client's TLS policy: MAIL_TLS_INSECURE is a dev-only
|
||||
// knob for the local self-signed sandbox, never set in production. Without
|
||||
// it, validation rejects mailboxes the worker would go on to sync fine.
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
tlsConf := &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: netbind.InsecureTLS(),
|
||||
})
|
||||
if err := tlsConn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var c *imapclient.Client
|
||||
|
||||
c = imapclient.New(tlsConn, nil)
|
||||
if models.ResolveIMAPSecurity(security, port) == models.MailSecurityStartTLS {
|
||||
// The greeting arrives in cleartext and the upgrade happens in-band.
|
||||
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return false
|
||||
}
|
||||
c, err = imapclient.NewStartTLS(conn, &imapclient.Options{TLSConfig: tlsConf})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
tlsConn := tls.Client(conn, tlsConf)
|
||||
if err := tlsConn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
return false
|
||||
}
|
||||
c = imapclient.New(tlsConn, nil)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = c.Logout().Wait()
|
||||
_ = c.Close()
|
||||
|
||||
+23
-20
@@ -6,17 +6,18 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"time"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/client/netbind"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func VerifySMTP(ctx context.Context, host string, port int, user, pass string) bool {
|
||||
// VerifySMTP probes a mailbox's SMTP credentials the same way the send path
|
||||
// connects: the caller's security mode decides implicit TLS versus STARTTLS,
|
||||
// and any port is accepted. security may be empty, in which case the port
|
||||
// convention decides.
|
||||
func VerifySMTP(ctx context.Context, host string, port int, user, pass, security string) bool {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
|
||||
// Matches the send client's TLS policy: MAIL_TLS_INSECURE is a dev-only
|
||||
// knob for the local self-signed sandbox, never set in production.
|
||||
tlsConf := &tls.Config{
|
||||
@@ -24,20 +25,16 @@ func VerifySMTP(ctx context.Context, host string, port int, user, pass string) b
|
||||
InsecureSkipVerify: netbind.InsecureTLS(),
|
||||
}
|
||||
|
||||
switch port {
|
||||
case 465:
|
||||
dialer := &tls.Dialer{
|
||||
NetDialer: &net.Dialer{Timeout: 5 * time.Second},
|
||||
Config: tlsConf,
|
||||
}
|
||||
conn, err = dialer.DialContext(ctx, "tcp", addr)
|
||||
var conn net.Conn
|
||||
var err error
|
||||
|
||||
case 587:
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err = dialer.DialContext(ctx, "tcp", addr)
|
||||
|
||||
default:
|
||||
return false
|
||||
// netbind dialers so validation probes leave from WORKER_BIND_IP exactly
|
||||
// like the sends they are vouching for.
|
||||
implicitTLS := models.ResolveSMTPSecurity(security, port) == models.MailSecurityTLS
|
||||
if implicitTLS {
|
||||
conn, err = netbind.TLSDialer(nil, tlsConf).DialContext(ctx, "tcp", addr)
|
||||
} else {
|
||||
conn, err = netbind.Dialer(nil).DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
// A bad host is ordinary user input, not an exceptional case: dial failed
|
||||
// means conn is nil, and closing it would panic this goroutine and take
|
||||
@@ -53,8 +50,14 @@ func VerifySMTP(ctx context.Context, host string, port int, user, pass string) b
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if port == 587 {
|
||||
if err := c.StartTLS(tlsConf); err != nil {
|
||||
if !implicitTLS {
|
||||
// TLS stays mandatory, with the same dev-only escape hatch the send
|
||||
// path uses for the local no-STARTTLS sink.
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err := c.StartTLS(tlsConf); err != nil {
|
||||
return false
|
||||
}
|
||||
} else if !netbind.InsecureTLS() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// TestVerifySMTP_UnreachableHost guards against a nil-conn deref. A wrong SMTP
|
||||
@@ -15,16 +17,20 @@ func TestVerifySMTP_UnreachableHost(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
host string
|
||||
port int
|
||||
name string
|
||||
host string
|
||||
port int
|
||||
security string
|
||||
}{
|
||||
// 465/587 are the only ports onboarding accepts, and each takes a
|
||||
// different dial path (implicit TLS vs STARTTLS).
|
||||
{"closed port, 587", "127.0.0.1", 587},
|
||||
{"closed port, 465", "127.0.0.1", 465},
|
||||
{"unresolvable host", "no-such-mail-host.invalid", 587},
|
||||
{"unresolvable host, implicit tls", "no-such-mail-host.invalid", 465},
|
||||
// Each security mode takes a different dial path (implicit TLS vs
|
||||
// plaintext + STARTTLS), and an empty mode falls back to the port.
|
||||
{"closed port, 587", "127.0.0.1", 587, ""},
|
||||
{"closed port, 465", "127.0.0.1", 465, ""},
|
||||
{"unresolvable host", "no-such-mail-host.invalid", 587, ""},
|
||||
{"unresolvable host, implicit tls", "no-such-mail-host.invalid", 465, ""},
|
||||
// Non-standard ports are accepted now, so the mode carries the choice.
|
||||
{"closed nonstandard port, starttls", "127.0.0.1", 2525, models.MailSecurityStartTLS},
|
||||
{"closed nonstandard port, implicit tls", "127.0.0.1", 8465, models.MailSecurityTLS},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
@@ -32,24 +38,39 @@ func TestVerifySMTP_UnreachableHost(t *testing.T) {
|
||||
t.Fatalf("VerifySMTP panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
if VerifySMTP(ctx, tc.host, tc.port, "user", "pass") {
|
||||
if VerifySMTP(ctx, tc.host, tc.port, "user", "pass", tc.security) {
|
||||
t.Fatal("VerifySMTP returned true for an unreachable server")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyImap_UnreachableHost is the same guard for the IMAP probe.
|
||||
// TestVerifyImap_UnreachableHost is the same guard for the IMAP probe, across
|
||||
// both security modes.
|
||||
func TestVerifyImap_UnreachableHost(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("VerifyImap panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
if VerifyImap(ctx, "no-such-mail-host.invalid", 993, "user", "pass") {
|
||||
t.Fatal("VerifyImap returned true for an unreachable server")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
host string
|
||||
port int
|
||||
security string
|
||||
}{
|
||||
{"unresolvable host, implicit tls", "no-such-mail-host.invalid", 993, ""},
|
||||
{"unresolvable host, starttls", "no-such-mail-host.invalid", 143, ""},
|
||||
{"closed port, starttls", "127.0.0.1", 143, models.MailSecurityStartTLS},
|
||||
{"closed nonstandard port, implicit tls", "127.0.0.1", 9993, models.MailSecurityTLS},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("VerifyImap panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
if VerifyImap(ctx, tc.host, tc.port, "user", "pass", tc.security) {
|
||||
t.Fatal("VerifyImap returned true for an unreachable server")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +127,11 @@ var (
|
||||
ErrEmailReauthCloudManaged = New(Conflict, "Warmbly Cloud holds this mailbox's sign-in. Reconnect it from your cloud workspace instead.")
|
||||
ErrEmailReauthNoRefreshToken = New(BadRequest, "The provider did not return a refresh token and none is stored. Please try re-authorizing again.")
|
||||
ErrEmailSMTPHost = New(BadRequest, "SMTP host is required.")
|
||||
ErrEmailSMTPPort = New(BadRequest, "SMTP port must be 465 or 587.")
|
||||
ErrEmailSMTPPort = New(BadRequest, "SMTP port must be between 1 and 65535.")
|
||||
ErrEmailSMTPSecurity = New(BadRequest, "SMTP security must be tls or starttls.")
|
||||
ErrEmailIMAPSecurity = New(BadRequest, "IMAP security must be tls or starttls.")
|
||||
ErrEmailIMAPHost = New(BadRequest, "IMAP host is required.")
|
||||
ErrEmailIMAPPort = New(BadRequest, "IMAP port must be a positive integer.")
|
||||
ErrEmailIMAPPort = New(BadRequest, "IMAP port must be between 1 and 65535.")
|
||||
ErrEmailCredentialsRequired = New(BadRequest, "SMTP and IMAP credentials are required.")
|
||||
ErrEmailTrackingDomain = New(BadRequest, "Invalid tracking domain.")
|
||||
ErrEmailTrackingDomainLength = New(BadRequest, "Tracking domain is too long (max 253 characters).")
|
||||
@@ -175,6 +177,9 @@ var (
|
||||
// Unibox
|
||||
ErrUniboxLimit = New(BadRequest, fmt.Sprintf("Limit must be between %d and %d.", config.UniboxLimitMin, config.UniboxLimitMax))
|
||||
ErrSeenMax = New(BadRequest, "Cannot update more than 500 messages.")
|
||||
// Folder scoping (unibox sidebar).
|
||||
ErrUniboxFolder = New(BadRequest, "Folder must be one of inbox, sent, drafts, archive, spam, trash.")
|
||||
ErrSeenFolderAndIDs = New(BadRequest, "Provide either email_ids or folder, not both.")
|
||||
|
||||
// Servers
|
||||
ErrIPAddr = New(BadRequest, "Invalid IP Address.")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_unibox_emails_folder;
|
||||
|
||||
ALTER TABLE unibox_emails
|
||||
DROP CONSTRAINT IF EXISTS unibox_emails_folder_check;
|
||||
|
||||
ALTER TABLE unibox_emails
|
||||
DROP COLUMN IF EXISTS folder;
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Canonical mail folder per unibox message, so the inbox can present the
|
||||
-- standard folder sidebar (inbox/sent/drafts/archive/spam/trash) instead of
|
||||
-- one combined list. Written by the sync path from provider placement; the
|
||||
-- backfills below recover what past syncs recorded elsewhere.
|
||||
--
|
||||
-- Cost note: unibox_emails holds every synced message, so on a long-running
|
||||
-- instance the three backfill passes below are the expensive part of this
|
||||
-- migration, and the last one runs a correlated address match per row. The
|
||||
-- backend applies migrations at boot inside one transaction and blocks until
|
||||
-- they finish, so on a large instance deploy this in a window rather than
|
||||
-- alongside traffic. ADD COLUMN with a constant DEFAULT is metadata-only on
|
||||
-- PG 11+ and is not itself a rewrite.
|
||||
ALTER TABLE unibox_emails
|
||||
ADD COLUMN folder text NOT NULL DEFAULT 'inbox';
|
||||
|
||||
ALTER TABLE unibox_emails
|
||||
ADD CONSTRAINT unibox_emails_folder_check
|
||||
CHECK (folder IN ('inbox', 'sent', 'drafts', 'archive', 'spam', 'trash'));
|
||||
|
||||
-- IMAP rows: the folder registry kept each source folder's special-use
|
||||
-- attributes, keyed by (email_id, uid_validity).
|
||||
UPDATE unibox_emails ue
|
||||
SET folder = CASE
|
||||
WHEN um.attributes && ARRAY['\Trash'] THEN 'trash'
|
||||
WHEN um.attributes && ARRAY['\Junk'] THEN 'spam'
|
||||
WHEN um.attributes && ARRAY['\Drafts'] THEN 'drafts'
|
||||
WHEN um.attributes && ARRAY['\Sent'] THEN 'sent'
|
||||
WHEN um.attributes && ARRAY['\Archive','\All'] THEN 'archive'
|
||||
ELSE 'inbox'
|
||||
END
|
||||
FROM unibox_mailboxes um
|
||||
WHERE um.email_id = ue.email_id
|
||||
AND um.uid_validity = ue.mailbox
|
||||
AND ue.mailbox <> 0;
|
||||
|
||||
-- Flag-derived placement wins where the registry had nothing (Gmail/Graph
|
||||
-- rows carry mailbox = 0 but kept spam and draft pseudo-flags).
|
||||
UPDATE unibox_emails
|
||||
SET folder = 'spam'
|
||||
WHERE folder = 'inbox'
|
||||
AND flags && ARRAY['SPAM', '\Junk', '\Spam', 'Junk'];
|
||||
|
||||
UPDATE unibox_emails
|
||||
SET folder = 'drafts'
|
||||
WHERE folder = 'inbox'
|
||||
AND flags && ARRAY['\Draft'];
|
||||
|
||||
-- Sent provenance was lost on Gmail/Graph; recover it the way the direction
|
||||
-- filter always has: our own address in From.
|
||||
UPDATE unibox_emails ue
|
||||
SET folder = 'sent'
|
||||
WHERE ue.folder = 'inbox'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(ue.from_addr) AS f(addr)
|
||||
JOIN email_accounts ea ON ea.id = ue.email_id
|
||||
WHERE f.addr ILIKE '%' || ea.email || '%'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_unibox_emails_folder ON unibox_emails (email_id, folder);
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE email_accounts_smtp_imap
|
||||
DROP CONSTRAINT IF EXISTS email_accounts_smtp_imap_imap_security_check;
|
||||
|
||||
ALTER TABLE email_accounts_smtp_imap
|
||||
DROP CONSTRAINT IF EXISTS email_accounts_smtp_imap_smtp_security_check;
|
||||
|
||||
ALTER TABLE email_accounts_smtp_imap
|
||||
DROP COLUMN IF EXISTS imap_security,
|
||||
DROP COLUMN IF EXISTS smtp_security;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Explicit connection security per mailbox, so a mailbox can live on any port
|
||||
-- instead of only the four the code could infer a mode from. Backfilled from
|
||||
-- the port each row already uses, which preserves current behavior for every
|
||||
-- standard setup and repairs IMAP mailboxes stored on 143 (those could never
|
||||
-- connect: the client always dialed implicit TLS).
|
||||
ALTER TABLE email_accounts_smtp_imap
|
||||
ADD COLUMN smtp_security text NOT NULL DEFAULT 'starttls',
|
||||
ADD COLUMN imap_security text NOT NULL DEFAULT 'tls';
|
||||
|
||||
UPDATE email_accounts_smtp_imap
|
||||
SET smtp_security = CASE WHEN smtp_port = 465 THEN 'tls' ELSE 'starttls' END,
|
||||
imap_security = CASE WHEN imap_port = 143 THEN 'starttls' ELSE 'tls' END;
|
||||
|
||||
ALTER TABLE email_accounts_smtp_imap
|
||||
ADD CONSTRAINT email_accounts_smtp_imap_smtp_security_check
|
||||
CHECK (smtp_security IN ('tls', 'starttls'));
|
||||
|
||||
ALTER TABLE email_accounts_smtp_imap
|
||||
ADD CONSTRAINT email_accounts_smtp_imap_imap_security_check
|
||||
CHECK (imap_security IN ('tls', 'starttls'));
|
||||
@@ -133,6 +133,7 @@ type EmailInboxEvent struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Preview string `json:"preview,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
}
|
||||
|
||||
// ContactEvent for contact changes
|
||||
|
||||
@@ -147,11 +147,59 @@ type EmailAuthTransition struct {
|
||||
OrganizationID *uuid.UUID
|
||||
}
|
||||
|
||||
// Mail connection security modes. TLS is mandatory either way; the difference
|
||||
// is whether it is negotiated before the protocol greeting or upgraded in-band
|
||||
// after it. Storing the mode explicitly is what lets a mailbox live on any
|
||||
// port: inferring it from the port only ever worked for 465/587/993/143.
|
||||
const (
|
||||
// MailSecurityTLS is implicit TLS: the server speaks TLS from the first
|
||||
// byte. SMTP 465 (SMTPS), IMAP 993 (IMAPS).
|
||||
MailSecurityTLS = "tls"
|
||||
// MailSecurityStartTLS is a plaintext greeting upgraded in-band with
|
||||
// STARTTLS. SMTP 587/25/2525, IMAP 143.
|
||||
MailSecurityStartTLS = "starttls"
|
||||
)
|
||||
|
||||
// ValidMailSecurity reports whether s is a known security mode.
|
||||
func ValidMailSecurity(s string) bool {
|
||||
return s == MailSecurityTLS || s == MailSecurityStartTLS
|
||||
}
|
||||
|
||||
// ResolveSMTPSecurity returns the security mode to dial SMTP with: the stored
|
||||
// choice when it is set, otherwise the conventional default for the port. The
|
||||
// fallback keeps mailboxes connected across the rollout, when the stored value
|
||||
// is empty and events from older workers carry no mode at all.
|
||||
func ResolveSMTPSecurity(security string, port int) string {
|
||||
if ValidMailSecurity(security) {
|
||||
return security
|
||||
}
|
||||
if port == 465 {
|
||||
return MailSecurityTLS
|
||||
}
|
||||
return MailSecurityStartTLS
|
||||
}
|
||||
|
||||
// ResolveIMAPSecurity is ResolveSMTPSecurity for IMAP, where implicit TLS is
|
||||
// the norm (993) and 143 is the STARTTLS port.
|
||||
func ResolveIMAPSecurity(security string, port int) string {
|
||||
if ValidMailSecurity(security) {
|
||||
return security
|
||||
}
|
||||
if port == 143 {
|
||||
return MailSecurityStartTLS
|
||||
}
|
||||
return MailSecurityTLS
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
// Security is the connection mode (see MailSecurity*). Empty means "infer
|
||||
// from the port", which is how rows and events written before the field
|
||||
// existed behave.
|
||||
Security string `json:"security,omitempty"`
|
||||
}
|
||||
|
||||
type Oauth2Service struct {
|
||||
|
||||
@@ -27,5 +27,9 @@ type JobEventEmailUpdate struct {
|
||||
UID uint32 `json:"uid"`
|
||||
ModSeq uint64 `json:"mod_seq"`
|
||||
Mailbox uint32 `json:"mailbox"`
|
||||
Flags []string `json:"flags"`
|
||||
// Folder is the canonical folder the message now sits in; empty on events
|
||||
// from workers predating folder tracking (the consumer then keeps the
|
||||
// stored value).
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Flags []string `json:"flags"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// The resolvers are what keep mailboxes connected across the rollout: a stored
|
||||
// mode wins, and an empty one has to reproduce the port-inferred behaviour the
|
||||
// clients had before the column existed.
|
||||
func TestResolveSMTPSecurity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
security string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"stored tls wins over port", MailSecurityTLS, 587, MailSecurityTLS},
|
||||
{"stored starttls wins over port", MailSecurityStartTLS, 465, MailSecurityStartTLS},
|
||||
{"empty infers implicit tls on 465", "", 465, MailSecurityTLS},
|
||||
{"empty infers starttls on 587", "", 587, MailSecurityStartTLS},
|
||||
{"empty infers starttls on 2525", "", 2525, MailSecurityStartTLS},
|
||||
{"garbage falls back to the port", "banana", 465, MailSecurityTLS},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ResolveSMTPSecurity(tc.security, tc.port); got != tc.want {
|
||||
t.Fatalf("ResolveSMTPSecurity(%q, %d) = %q, want %q", tc.security, tc.port, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIMAPSecurity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
security string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"stored starttls wins over port", MailSecurityStartTLS, 993, MailSecurityStartTLS},
|
||||
{"stored tls wins over port", MailSecurityTLS, 143, MailSecurityTLS},
|
||||
// Implicit TLS on anything but 143 reproduces the old always-TLS dial.
|
||||
{"empty infers implicit tls on 993", "", 993, MailSecurityTLS},
|
||||
{"empty infers implicit tls on a custom port", "", 9993, MailSecurityTLS},
|
||||
{"empty infers starttls on 143", "", 143, MailSecurityStartTLS},
|
||||
{"garbage falls back to the port", "banana", 143, MailSecurityStartTLS},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ResolveIMAPSecurity(tc.security, tc.port); got != tc.want {
|
||||
t.Fatalf("ResolveIMAPSecurity(%q, %d) = %q, want %q", tc.security, tc.port, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidMailSecurity(t *testing.T) {
|
||||
for _, s := range []string{MailSecurityTLS, MailSecurityStartTLS} {
|
||||
if !ValidMailSecurity(s) {
|
||||
t.Fatalf("ValidMailSecurity(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
// "none" is deliberately not a mode: TLS is mandatory for mailboxes, and
|
||||
// the cleartext escape hatch is the instance-level MAIL_TLS_INSECURE knob.
|
||||
for _, s := range []string{"", "none", "ssl", "TLS"} {
|
||||
if ValidMailSecurity(s) {
|
||||
t.Fatalf("ValidMailSecurity(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,10 @@ type EmailMessageData struct { // used when for kafka when an email arrives
|
||||
// Flags
|
||||
Flags []string `json:"flags"`
|
||||
|
||||
// Canonical folder the provider reported the message in (Folder* consts);
|
||||
// empty when the source path predates folder tracking.
|
||||
Folder string `json:"folder,omitempty"`
|
||||
|
||||
// Envelope
|
||||
BCC []string `json:"bcc"`
|
||||
CC []string `json:"cc"`
|
||||
@@ -88,9 +92,13 @@ type EmailMessageData struct { // used when for kafka when an email arrives
|
||||
}
|
||||
|
||||
type EmailMessageStoreData struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
EmailID uuid.UUID `json:"email_id"`
|
||||
Mailbox uint32 `json:"mailbox"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
EmailID uuid.UUID `json:"email_id"`
|
||||
Mailbox uint32 `json:"mailbox"`
|
||||
// Folder is the canonical folder (see the Folder* constants) the message
|
||||
// was in at sync time. Empty on events from workers predating the field;
|
||||
// the consumer normalizes before storing.
|
||||
Folder string `json:"folder,omitempty"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
GmailID string `json:"gmail_id"`
|
||||
@@ -173,6 +181,57 @@ type MailThreadResult struct {
|
||||
Pagination CPagination `json:"pagination"`
|
||||
}
|
||||
|
||||
// Canonical mail folders. Every unibox message carries exactly one, derived
|
||||
// from provider placement at sync time (IMAP special-use attributes, Gmail
|
||||
// labels, Graph well-known folders) and enforced by a CHECK on the column.
|
||||
const (
|
||||
FolderInbox = "inbox"
|
||||
FolderSent = "sent"
|
||||
FolderDrafts = "drafts"
|
||||
FolderArchive = "archive"
|
||||
FolderSpam = "spam"
|
||||
FolderTrash = "trash"
|
||||
)
|
||||
|
||||
// MailFolders lists every canonical folder, in sidebar order.
|
||||
var MailFolders = []string{FolderInbox, FolderSent, FolderDrafts, FolderArchive, FolderSpam, FolderTrash}
|
||||
|
||||
// ValidFolder reports whether f is one of the canonical folder values.
|
||||
func ValidFolder(f string) bool {
|
||||
switch f {
|
||||
case FolderInbox, FolderSent, FolderDrafts, FolderArchive, FolderSpam, FolderTrash:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizeFolder resolves the folder to persist for a message: the worker's
|
||||
// value when it sent a valid one, otherwise a flag-derived fallback so events
|
||||
// from workers predating the folder field still file spam and drafts sanely.
|
||||
func NormalizeFolder(folder string, flags []string) string {
|
||||
if ValidFolder(folder) {
|
||||
return folder
|
||||
}
|
||||
// Deletion outranks the rest: a message can carry \Deleted alongside a
|
||||
// spam or draft flag, and the provider-driven paths treat trash as the
|
||||
// stronger placement. Scanning for it first keeps a flag-ordering
|
||||
// accident from filing deleted mail as spam.
|
||||
for _, f := range flags {
|
||||
if f == "\\Deleted" {
|
||||
return FolderTrash
|
||||
}
|
||||
}
|
||||
for _, f := range flags {
|
||||
switch f {
|
||||
case "SPAM", "\\Junk", "\\Spam", "Junk":
|
||||
return FolderSpam
|
||||
case "\\Draft":
|
||||
return FolderDrafts
|
||||
}
|
||||
}
|
||||
return FolderInbox
|
||||
}
|
||||
|
||||
type MailSearchResult struct {
|
||||
Data []EmailMessageStoreDataPreview `json:"data"`
|
||||
Pagination CPagination `json:"pagination"`
|
||||
@@ -215,13 +274,20 @@ type MailSearchParams struct {
|
||||
// Uncategorized, when true, narrows to threads carrying no
|
||||
// conversation labels at all. nil = no filter.
|
||||
Uncategorized *bool
|
||||
PageSize int
|
||||
Cursor string
|
||||
// Folder narrows to one canonical folder (inbox/sent/drafts/archive/
|
||||
// spam/trash). nil = every folder except spam and trash, so junk never
|
||||
// bleeds into the combined view.
|
||||
Folder *string
|
||||
PageSize int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
type MarkSeen struct {
|
||||
EmailIDs []uuid.UUID `json:"email_ids"`
|
||||
Seen bool `json:"seen"`
|
||||
// Folder, when set, marks every unread message in that folder for the
|
||||
// whole workspace instead of the explicit id list.
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Seen bool `json:"seen"`
|
||||
}
|
||||
|
||||
// UniboxSnooze hides a thread from the user's inbox until SnoozedUntil
|
||||
@@ -269,6 +335,15 @@ type UniboxCategoryOverview struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// UniboxFolderOverview gives the rail per-folder thread counts. Always
|
||||
// emitted for all six canonical folders, zero-filled, so the sidebar
|
||||
// renders a stable list.
|
||||
type UniboxFolderOverview struct {
|
||||
Folder string `json:"folder"`
|
||||
Unread int64 `json:"unread"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// UniboxOverview powers the scope rail + top metric strip in one
|
||||
// request. Computed at /unibox/overview.
|
||||
type UniboxOverview struct {
|
||||
@@ -286,6 +361,7 @@ type UniboxOverview struct {
|
||||
// tasks per user. The dashboard shows current/max so the user
|
||||
// sees how close they are to the limit before hitting it.
|
||||
ScheduledPendingMax int64 `json:"scheduled_pending_max"`
|
||||
Folders []UniboxFolderOverview `json:"folders"`
|
||||
Mailboxes []UniboxMailboxOverview `json:"mailboxes"`
|
||||
Tags []UniboxTagOverview `json:"tags"`
|
||||
Categories []UniboxCategoryOverview `json:"categories"`
|
||||
|
||||
@@ -26,10 +26,12 @@ type SMTPCredentials struct {
|
||||
SMTPPort int
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPSecurity string
|
||||
IMAPHost string
|
||||
IMAPPort int
|
||||
IMAPUser string
|
||||
IMAPPassword string
|
||||
IMAPSecurity string
|
||||
}
|
||||
|
||||
// OAuthCredentials holds OAuth token credentials
|
||||
@@ -529,16 +531,18 @@ func (r *emailRepository) NewSMTPIMAPAccount(ctx context.Context, userID string,
|
||||
query = `
|
||||
INSERT INTO email_accounts_smtp_imap (
|
||||
email_account_id,
|
||||
smtp_host, smtp_port, smtp_user, smtp_password,
|
||||
imap_host, imap_port, imap_user, imap_password
|
||||
smtp_host, smtp_port, smtp_user, smtp_password, smtp_security,
|
||||
imap_host, imap_port, imap_user, imap_password, imap_security
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9)
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11)
|
||||
`
|
||||
|
||||
params = []any{
|
||||
id, smtphost, data.SMTP.Port, smtpuser, smtppass,
|
||||
models.ResolveSMTPSecurity(data.SMTP.Security, data.SMTP.Port),
|
||||
imaphost, data.IMAP.Port, imapuser, imappass,
|
||||
models.ResolveIMAPSecurity(data.IMAP.Security, data.IMAP.Port),
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
@@ -1597,8 +1601,8 @@ func (r *emailRepository) GetSMTPCredentials(ctx context.Context, emailAccountID
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
query := `
|
||||
SELECT smtp_host, smtp_port, smtp_user, smtp_password,
|
||||
imap_host, imap_port, imap_user, imap_password
|
||||
SELECT smtp_host, smtp_port, smtp_user, smtp_password, smtp_security,
|
||||
imap_host, imap_port, imap_user, imap_password, imap_security
|
||||
FROM email_accounts_smtp_imap
|
||||
WHERE email_account_id = $1
|
||||
`
|
||||
@@ -1607,8 +1611,8 @@ func (r *emailRepository) GetSMTPCredentials(ctx context.Context, emailAccountID
|
||||
var smtpHost, smtpUser, smtpPassword, imapHost, imapUser, imapPassword string
|
||||
|
||||
err := r.DB.QueryRow(ctx, query, emailAccountID).Scan(
|
||||
&smtpHost, &creds.SMTPPort, &smtpUser, &smtpPassword,
|
||||
&imapHost, &creds.IMAPPort, &imapUser, &imapPassword,
|
||||
&smtpHost, &creds.SMTPPort, &smtpUser, &smtpPassword, &creds.SMTPSecurity,
|
||||
&imapHost, &creds.IMAPPort, &imapUser, &imapPassword, &creds.IMAPSecurity,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
||||
@@ -24,10 +24,12 @@ func (r *emailRepository) GetSMTPIMAP(ctx context.Context, userID, emailAccountI
|
||||
smtp.smtp_port,
|
||||
smtp.smtp_user,
|
||||
smtp.smtp_password,
|
||||
smtp.smtp_security,
|
||||
smtp.imap_host,
|
||||
smtp.imap_port,
|
||||
smtp.imap_user,
|
||||
smtp.imap_password,
|
||||
smtp.imap_security,
|
||||
smtp.updated_at
|
||||
FROM
|
||||
email_accounts ea
|
||||
@@ -48,8 +50,8 @@ func (r *emailRepository) GetSMTPIMAP(ctx context.Context, userID, emailAccountI
|
||||
query,
|
||||
params...,
|
||||
).Scan(
|
||||
&smtp.Host, &smtp.Port, &smtp.Username, &smtp.Password,
|
||||
&imap.Host, &imap.Port, &imap.Username, &imap.Password,
|
||||
&smtp.Host, &smtp.Port, &smtp.Username, &smtp.Password, &smtp.Security,
|
||||
&imap.Host, &imap.Port, &imap.Username, &imap.Password, &imap.Security,
|
||||
&ts,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -204,11 +206,14 @@ func (r *emailRepository) ReplaceSMTPIMAPCredentials(ctx context.Context, id uui
|
||||
}
|
||||
query := `
|
||||
UPDATE email_accounts_smtp_imap
|
||||
SET smtp_host = $1, smtp_port = $2, smtp_user = $3, smtp_password = $4,
|
||||
imap_host = $5, imap_port = $6, imap_user = $7, imap_password = $8
|
||||
WHERE email_account_id = $9
|
||||
SET smtp_host = $1, smtp_port = $2, smtp_user = $3, smtp_password = $4, smtp_security = $5,
|
||||
imap_host = $6, imap_port = $7, imap_user = $8, imap_password = $9, imap_security = $10
|
||||
WHERE email_account_id = $11
|
||||
`
|
||||
if _, err := r.DB.Exec(ctx, query, sealed[0], creds.SMTP.Port, sealed[1], sealed[2], sealed[3], creds.IMAP.Port, sealed[4], sealed[5], id); err != nil {
|
||||
if _, err := r.DB.Exec(ctx, query,
|
||||
sealed[0], creds.SMTP.Port, sealed[1], sealed[2], models.ResolveSMTPSecurity(creds.SMTP.Security, creds.SMTP.Port),
|
||||
sealed[3], creds.IMAP.Port, sealed[4], sealed[5], models.ResolveIMAPSecurity(creds.IMAP.Security, creds.IMAP.Port),
|
||||
id); err != nil {
|
||||
db.CaptureError(err, query, nil, "exec")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type UpdateUniboxEntry struct {
|
||||
Flags []string `json:"flags"`
|
||||
ModSeq *uint64 `json:"mod_seq"`
|
||||
Mailbox *uint32 `json:"mailbox"`
|
||||
Folder *string `json:"folder"`
|
||||
}
|
||||
|
||||
type UniboxRepository interface {
|
||||
@@ -38,6 +39,9 @@ type UniboxRepository interface {
|
||||
GetUnseenCount(ctx context.Context, orgID uuid.UUID, emailAccountID *uuid.UUID) (int64, error)
|
||||
MarkSeen(ctx context.Context, userID, id uuid.UUID, seen bool) error
|
||||
MarkSeenBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, seen bool) error
|
||||
// MarkSeenByFolder flips the read state of every message in one canonical
|
||||
// folder for the whole workspace (the sidebar's "mark all as read").
|
||||
MarkSeenByFolder(ctx context.Context, orgID uuid.UUID, folder string, seen bool) error
|
||||
Delete(ctx context.Context, userID, id uuid.UUID) error
|
||||
|
||||
// Snooze: per (user, thread). UpsertSnooze adopts the new
|
||||
@@ -105,7 +109,7 @@ var mailFieldsFull = []string{
|
||||
"gmail_id", "parent_id", "uid", "mod_seq",
|
||||
"flags", "bcc", "cc", "from_addr", "in_reply_to", "reply_to",
|
||||
"to_addr", "subject", "size", "internal_date", "sent_date",
|
||||
"snippet", "seen", "updated_at", "created_at",
|
||||
"snippet", "seen", "updated_at", "created_at", "folder",
|
||||
}
|
||||
|
||||
var mailFieldsPreview = []string{
|
||||
@@ -120,13 +124,13 @@ func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e
|
||||
gmail_id, parent_id, uid, mod_seq,
|
||||
flags, bcc, cc, from_addr, in_reply_to, reply_to,
|
||||
to_addr, subject, size, internal_date, sent_date,
|
||||
snippet, seen, created_at, updated_at, body_text
|
||||
snippet, seen, created_at, updated_at, body_text, folder
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16,
|
||||
$17, $18, $19, $20, $21,
|
||||
$22, $23, $24, $25, $26
|
||||
$22, $23, $24, $25, $26, $27
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`
|
||||
@@ -140,6 +144,7 @@ func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e
|
||||
textArray(e.InReplyTo), textArray(e.ReplyTo), textArray(e.ToAddr),
|
||||
e.Subject, e.Size, e.InternalDate, e.SentDate,
|
||||
e.Snippet, e.Seen, e.CreatedAt, e.UpdatedAt, e.BodyText,
|
||||
models.NormalizeFolder(e.Folder, e.Flags),
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -178,6 +183,11 @@ func (r *uniboxRepository) UpdateEntry(ctx context.Context, userID, emailID, id
|
||||
args = append(args, *e.UID)
|
||||
argPos++
|
||||
}
|
||||
if e.Folder != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("folder = $%d", argPos))
|
||||
args = append(args, *e.Folder)
|
||||
argPos++
|
||||
}
|
||||
|
||||
if argPos == 3 {
|
||||
return nil // nothing to update
|
||||
@@ -234,7 +244,7 @@ func (r *uniboxRepository) GetByID(ctx context.Context, userID, id uuid.UUID) (*
|
||||
&e.GmailID, &e.ParentID, &e.UID, &e.ModSeq,
|
||||
&e.Flags, &e.BCC, &e.CC, &e.FromAddr, &e.InReplyTo, &e.ReplyTo,
|
||||
&e.ToAddr, &e.Subject, &e.Size, &e.InternalDate, &e.SentDate,
|
||||
&e.Snippet, &e.Seen, &e.UpdatedAt, &e.CreatedAt,
|
||||
&e.Snippet, &e.Seen, &e.UpdatedAt, &e.CreatedAt, &e.Folder,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -273,7 +283,7 @@ func (r *uniboxRepository) GetByIDForOrg(ctx context.Context, orgID, id uuid.UUI
|
||||
&e.GmailID, &e.ParentID, &e.UID, &e.ModSeq,
|
||||
&e.Flags, &e.BCC, &e.CC, &e.FromAddr, &e.InReplyTo, &e.ReplyTo,
|
||||
&e.ToAddr, &e.Subject, &e.Size, &e.InternalDate, &e.SentDate,
|
||||
&e.Snippet, &e.Seen, &e.UpdatedAt, &e.CreatedAt,
|
||||
&e.Snippet, &e.Seen, &e.UpdatedAt, &e.CreatedAt, &e.Folder,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -398,6 +408,16 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID, userID uuid.UUID,
|
||||
FROM unibox_emails ue
|
||||
WHERE ue.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)`, strings.Join(previewCols, ", "))
|
||||
|
||||
// Folder scoping. nil = every folder except spam and trash, so junk
|
||||
// never bleeds into the combined view.
|
||||
if params.Folder != nil {
|
||||
inner += fmt.Sprintf(` AND ue.folder = $%d`, argPos)
|
||||
args = append(args, *params.Folder)
|
||||
argPos++
|
||||
} else {
|
||||
inner += ` AND ue.folder NOT IN ('spam', 'trash')`
|
||||
}
|
||||
|
||||
// Snooze handling. nil = exclude snoozed (the inbox default), so
|
||||
// threads with an active snooze never appear unless asked for.
|
||||
switch {
|
||||
@@ -584,7 +604,8 @@ func (r *uniboxRepository) GetUnseenCount(ctx context.Context, orgID uuid.UUID,
|
||||
`SELECT COUNT(DISTINCT COALESCE(NULLIF(thread_id, ''), id::text))
|
||||
FROM unibox_emails
|
||||
WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
|
||||
AND email_id = $2 AND seen = FALSE`,
|
||||
AND email_id = $2 AND seen = FALSE
|
||||
AND folder NOT IN ('spam', 'trash')`,
|
||||
orgID, *emailAccountID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
@@ -593,7 +614,8 @@ func (r *uniboxRepository) GetUnseenCount(ctx context.Context, orgID uuid.UUID,
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT COUNT(DISTINCT COALESCE(NULLIF(thread_id, ''), id::text))
|
||||
FROM unibox_emails
|
||||
WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) AND seen = FALSE`,
|
||||
WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) AND seen = FALSE
|
||||
AND folder NOT IN ('spam', 'trash')`,
|
||||
orgID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
@@ -623,6 +645,18 @@ func (r *uniboxRepository) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, id
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkSeenByFolder flips the read state of every message in one folder,
|
||||
// org-scoped like MarkSeenBulk (the sidebar's "mark all as read").
|
||||
func (r *uniboxRepository) MarkSeenByFolder(ctx context.Context, orgID uuid.UUID, folder string, seen bool) error {
|
||||
_, err := r.db.Exec(ctx,
|
||||
`UPDATE unibox_emails SET seen = $1, updated_at = NOW()
|
||||
WHERE folder = $3 AND seen <> $1
|
||||
AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $2)`,
|
||||
seen, orgID, folder,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *uniboxRepository) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
||||
_, err := r.db.Exec(ctx,
|
||||
`DELETE FROM unibox_emails WHERE user_id = $1 AND id = $2`,
|
||||
@@ -976,6 +1010,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
) AS is_snoozed
|
||||
FROM unibox_emails e
|
||||
WHERE e.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
|
||||
AND e.folder NOT IN ('spam', 'trash')
|
||||
),
|
||||
threads AS (
|
||||
SELECT
|
||||
@@ -1019,6 +1054,44 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-folder counters, per THREAD like everything else. Zero-filled
|
||||
// over all six canonical folders so the sidebar renders a stable list.
|
||||
folderCounts := map[string]models.UniboxFolderOverview{}
|
||||
// Snoozed threads are excluded, matching Search's default scope: a badge
|
||||
// that counts rows the folder then hides reads as a bug.
|
||||
folderRows, err := r.db.Query(ctx, `
|
||||
SELECT
|
||||
e.folder,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(e.thread_id, ''), e.id::text)) FILTER (WHERE NOT e.seen) AS unread,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(e.thread_id, ''), e.id::text)) AS total
|
||||
FROM unibox_emails e
|
||||
WHERE e.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM unibox_snoozes s
|
||||
WHERE s.user_id = e.user_id
|
||||
AND s.thread_id = e.thread_id
|
||||
AND s.snoozed_until > NOW()
|
||||
)
|
||||
GROUP BY e.folder
|
||||
`, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer folderRows.Close()
|
||||
for folderRows.Next() {
|
||||
var f models.UniboxFolderOverview
|
||||
if err := folderRows.Scan(&f.Folder, &f.Unread, &f.Total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folderCounts[f.Folder] = f
|
||||
}
|
||||
overview.Folders = make([]models.UniboxFolderOverview, 0, len(models.MailFolders))
|
||||
for _, name := range models.MailFolders {
|
||||
f := folderCounts[name]
|
||||
f.Folder = name
|
||||
overview.Folders = append(overview.Folders, f)
|
||||
}
|
||||
|
||||
// Per-mailbox counters. LEFT JOIN against unibox_emails so empty
|
||||
// mailboxes still show up in the rail with a zero count. Counts are
|
||||
// per THREAD (distinct thread key, empty-thread-safe) to match the
|
||||
@@ -1040,6 +1113,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
)) AS total
|
||||
FROM email_accounts ea
|
||||
LEFT JOIN unibox_emails ue ON ue.email_id = ea.id AND ue.user_id = ea.user_id
|
||||
AND ue.folder NOT IN ('spam', 'trash')
|
||||
WHERE ea.organization_id = $1
|
||||
GROUP BY ea.id, ea.email, ea.name
|
||||
ORDER BY ea.email ASC
|
||||
@@ -1080,6 +1154,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
LEFT JOIN email_tags et ON et.tag_id = t.id
|
||||
LEFT JOIN email_accounts ea ON ea.id = et.email_id AND ea.user_id = t.user_id
|
||||
LEFT JOIN unibox_emails ue ON ue.email_id = ea.id AND ue.user_id = ea.user_id
|
||||
AND ue.folder NOT IN ('spam', 'trash')
|
||||
WHERE t.user_id = $1
|
||||
GROUP BY t.id, t.title, t.color, t.position
|
||||
ORDER BY t.position ASC, t.title ASC
|
||||
@@ -1112,6 +1187,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
SELECT e.user_id, e.thread_id, bool_or(NOT e.seen) AS has_unread
|
||||
FROM unibox_emails e
|
||||
WHERE e.user_id = $1
|
||||
AND e.folder NOT IN ('spam', 'trash')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM unibox_snoozes s
|
||||
WHERE s.user_id = e.user_id AND s.thread_id = e.thread_id AND s.snoozed_until > NOW()
|
||||
|
||||
@@ -118,6 +118,14 @@ export default function UniboxPage() {
|
||||
return { kind: "snoozed" };
|
||||
case "scheduled":
|
||||
return { kind: "scheduled" };
|
||||
case "inbox":
|
||||
case "sent":
|
||||
case "drafts":
|
||||
case "archive":
|
||||
case "spam":
|
||||
case "trash":
|
||||
// Folder scopes are direct URL segments: /app/unibox/spam.
|
||||
return { kind: "folder", folder: urlScope };
|
||||
case "mailbox":
|
||||
return urlScopeRef
|
||||
? { kind: "mailbox", mailboxId: urlScopeRef }
|
||||
@@ -138,6 +146,9 @@ export default function UniboxPage() {
|
||||
const setScope = React.useCallback(
|
||||
(s: UniboxScope) => {
|
||||
switch (s.kind) {
|
||||
case "folder":
|
||||
goTo({ scope: s.folder, ref: null });
|
||||
return;
|
||||
case "mailbox":
|
||||
goTo({ scope: "mailbox", ref: s.mailboxId });
|
||||
return;
|
||||
@@ -194,6 +205,9 @@ export default function UniboxPage() {
|
||||
case "snoozed":
|
||||
next.snoozed = true;
|
||||
break;
|
||||
case "folder":
|
||||
next.folder = scope.folder;
|
||||
break;
|
||||
case "mailbox":
|
||||
next.accountIds = [scope.mailboxId];
|
||||
break;
|
||||
@@ -250,6 +264,8 @@ export default function UniboxPage() {
|
||||
return "Snoozed";
|
||||
case "scheduled":
|
||||
return "Scheduled";
|
||||
case "folder":
|
||||
return scope.folder.charAt(0).toUpperCase() + scope.folder.slice(1);
|
||||
case "mailbox": {
|
||||
const m = overviewData?.mailboxes.find((x) => x.id === scope.mailboxId);
|
||||
return m ? m.email : "Mailbox";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Segmented picker for a mailbox leg's connection security. Shared by the
|
||||
// connect modal and the reconnect dialog so both describe the choice the same
|
||||
// way. Theme primitives only: h-7 control, slate border, sky active state.
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MailSecurity } from "@/lib/api/models/app/emails/Service";
|
||||
|
||||
const OPTIONS: { value: MailSecurity; label: string; hint: string }[] = [
|
||||
{ value: "tls", label: "SSL / TLS", hint: "Encrypted from the first byte (SMTP 465, IMAP 993)" },
|
||||
{ value: "starttls", label: "STARTTLS", hint: "Upgrades after connecting (SMTP 587 or 2525, IMAP 143)" },
|
||||
];
|
||||
|
||||
export default function SecuritySelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: MailSecurity;
|
||||
onChange: (v: MailSecurity) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-stretch h-7 rounded-md border border-slate-200 bg-white overflow-hidden">
|
||||
{OPTIONS.map((o, i) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
title={o.hint}
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 px-2 text-[12.5px] transition-colors",
|
||||
i > 0 && "border-l border-slate-200",
|
||||
value === o.value
|
||||
? "bg-sky-50 text-sky-700 font-medium"
|
||||
: "text-slate-600 hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,13 @@ import { TextInput } from "@/components/ui/field";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import updateEmailCredentials from "@/lib/api/client/app/emails/updateEmailCredentials";
|
||||
import {
|
||||
defaultImapSecurity,
|
||||
defaultSmtpSecurity,
|
||||
validPort,
|
||||
type MailSecurity,
|
||||
} from "@/lib/api/models/app/emails/Service";
|
||||
import SecuritySelect from "@/components/app/emails/SecuritySelect";
|
||||
|
||||
export default function UpdateCredentialsDialog({
|
||||
mailboxId,
|
||||
@@ -38,9 +45,22 @@ export default function UpdateCredentialsDialog({
|
||||
const [smtpUser, setSmtpUser] = React.useState(mailboxEmail);
|
||||
const [smtpPass, setSmtpPass] = React.useState("");
|
||||
|
||||
const [imapSecurity, setImapSecurity] = React.useState<MailSecurity>("tls");
|
||||
const [smtpSecurity, setSmtpSecurity] = React.useState<MailSecurity>("starttls");
|
||||
const [sameCreds, setSameCreds] = React.useState(true);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
// The port implies the mode until the user picks one by hand, matching the
|
||||
// connect form.
|
||||
const imapSecurityTouched = React.useRef(false);
|
||||
const smtpSecurityTouched = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!imapSecurityTouched.current) setImapSecurity(defaultImapSecurity(Number(imapPort)));
|
||||
}, [imapPort]);
|
||||
React.useEffect(() => {
|
||||
if (!smtpSecurityTouched.current) setSmtpSecurity(defaultSmtpSecurity(Number(smtpPort)));
|
||||
}, [smtpPort]);
|
||||
|
||||
// Reset when reopened so a cancelled attempt never leaks a typed password.
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
@@ -52,6 +72,10 @@ export default function UpdateCredentialsDialog({
|
||||
setSmtpPort("587");
|
||||
setSmtpUser(mailboxEmail);
|
||||
setSmtpPass("");
|
||||
imapSecurityTouched.current = false;
|
||||
smtpSecurityTouched.current = false;
|
||||
setImapSecurity("tls");
|
||||
setSmtpSecurity("starttls");
|
||||
setSameCreds(true);
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -70,8 +94,8 @@ export default function UpdateCredentialsDialog({
|
||||
if (!imapHost.trim() || !imapPort.trim() || !imapUser.trim() || !imapPass) return false;
|
||||
if (!smtpHost.trim() || !smtpPort.trim()) return false;
|
||||
if (!sameCreds && (!smtpUser.trim() || !smtpPass)) return false;
|
||||
const p = Number(smtpPort);
|
||||
if (p !== 465 && p !== 587) return false;
|
||||
// Any routable port; the security mode carries how to connect.
|
||||
if (!validPort(Number(smtpPort)) || !validPort(Number(imapPort))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -79,8 +103,8 @@ export default function UpdateCredentialsDialog({
|
||||
if (submitting || !valid()) return;
|
||||
setSubmitting(true);
|
||||
const smtp = sameCreds
|
||||
? { username: imapUser.trim(), password: imapPass, host: smtpHost.trim(), port: Number(smtpPort) }
|
||||
: { username: smtpUser.trim(), password: smtpPass, host: smtpHost.trim(), port: Number(smtpPort) };
|
||||
? { username: imapUser.trim(), password: imapPass, host: smtpHost.trim(), port: Number(smtpPort), security: smtpSecurity }
|
||||
: { username: smtpUser.trim(), password: smtpPass, host: smtpHost.trim(), port: Number(smtpPort), security: smtpSecurity };
|
||||
try {
|
||||
await toast.promise(
|
||||
updateEmailCredentials(mailboxId, smtp, {
|
||||
@@ -88,6 +112,7 @@ export default function UpdateCredentialsDialog({
|
||||
password: imapPass,
|
||||
host: imapHost.trim(),
|
||||
port: Number(imapPort),
|
||||
security: imapSecurity,
|
||||
}),
|
||||
{
|
||||
loading: "Verifying credentials…",
|
||||
@@ -145,6 +170,15 @@ export default function UpdateCredentialsDialog({
|
||||
<Field label="Server">
|
||||
<HostPortInput host={imapHost} onHost={setImapHost} hostPlaceholder="imap.example.com" port={imapPort} onPort={setImapPort} portPlaceholder="993" />
|
||||
</Field>
|
||||
<Field label="Security">
|
||||
<SecuritySelect
|
||||
value={imapSecurity}
|
||||
onChange={(v) => {
|
||||
imapSecurityTouched.current = true;
|
||||
setImapSecurity(v);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Username">
|
||||
<TextInput value={imapUser} onChange={setImapUser} placeholder={mailboxEmail} />
|
||||
</Field>
|
||||
@@ -157,6 +191,15 @@ export default function UpdateCredentialsDialog({
|
||||
<Field label="Server">
|
||||
<HostPortInput host={smtpHost} onHost={setSmtpHost} hostPlaceholder="smtp.example.com" port={smtpPort} onPort={setSmtpPort} portPlaceholder="587" />
|
||||
</Field>
|
||||
<Field label="Security">
|
||||
<SecuritySelect
|
||||
value={smtpSecurity}
|
||||
onChange={(v) => {
|
||||
smtpSecurityTouched.current = true;
|
||||
setSmtpSecurity(v);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 pl-[76px] pt-0.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -43,6 +43,13 @@ import { API_URL, APP_URL } from "@/lib/information";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import addEmail from "@/lib/api/client/app/emails/addEmail";
|
||||
import {
|
||||
defaultImapSecurity,
|
||||
defaultSmtpSecurity,
|
||||
validPort,
|
||||
type MailSecurity,
|
||||
} from "@/lib/api/models/app/emails/Service";
|
||||
import SecuritySelect from "@/components/app/emails/SecuritySelect";
|
||||
import onboardOAuthStart from "@/lib/api/client/app/emails/onboardOAuthStart";
|
||||
import onboardOAuthFinish from "@/lib/api/client/app/emails/onboardOAuthFinish";
|
||||
import { finishCloudOAuth, startCloudOAuth } from "@/lib/api/client/app/cloudlink/cloudLink";
|
||||
@@ -653,11 +660,30 @@ function SmtpImapPanel({ onDone }: { onDone: () => void }) {
|
||||
const [imapPort, setImapPort] = React.useState("993");
|
||||
const [imapUser, setImapUser] = React.useState("");
|
||||
const [imapPass, setImapPass] = React.useState("");
|
||||
const [imapSecurity, setImapSecurity] = React.useState<MailSecurity>("tls");
|
||||
|
||||
const [smtpHost, setSmtpHost] = React.useState("");
|
||||
const [smtpPort, setSmtpPort] = React.useState("587");
|
||||
const [smtpUser, setSmtpUser] = React.useState("");
|
||||
const [smtpPass, setSmtpPass] = React.useState("");
|
||||
const [smtpSecurity, setSmtpSecurity] = React.useState<MailSecurity>("starttls");
|
||||
|
||||
// The port implies the security mode for every conventional setup, so
|
||||
// typing a port moves the selector with it. Once the user picks a mode by
|
||||
// hand we stop guessing: that is exactly the non-standard case they came
|
||||
// here for (a submission relay on 2525, IMAP on a custom port).
|
||||
const imapSecurityTouched = React.useRef(false);
|
||||
const smtpSecurityTouched = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!imapSecurityTouched.current) {
|
||||
setImapSecurity(defaultImapSecurity(Number(imapPort)));
|
||||
}
|
||||
}, [imapPort]);
|
||||
React.useEffect(() => {
|
||||
if (!smtpSecurityTouched.current) {
|
||||
setSmtpSecurity(defaultSmtpSecurity(Number(smtpPort)));
|
||||
}
|
||||
}, [smtpPort]);
|
||||
|
||||
// Single-credentials toggle — covers the 90% case where IMAP and SMTP
|
||||
// share the same login. The user can flip it off and supply distinct
|
||||
@@ -685,8 +711,9 @@ function SmtpImapPanel({ onDone }: { onDone: () => void }) {
|
||||
if (!imapHost.trim() || !imapPort.trim() || !imapUser.trim() || !imapPass) return false;
|
||||
if (!smtpHost.trim() || !smtpPort.trim()) return false;
|
||||
if (!sameCreds && (!smtpUser.trim() || !smtpPass)) return false;
|
||||
const p = Number(smtpPort);
|
||||
if (p !== 465 && p !== 587) return false;
|
||||
// Any routable port is allowed; the security mode carries how to
|
||||
// connect, so 2525 and other non-standard ports work.
|
||||
if (!validPort(Number(smtpPort)) || !validPort(Number(imapPort))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -704,12 +731,14 @@ function SmtpImapPanel({ onDone }: { onDone: () => void }) {
|
||||
password: imapPass,
|
||||
host: imapHost.trim(),
|
||||
port: Number(imapPort),
|
||||
security: imapSecurity,
|
||||
},
|
||||
smtp: {
|
||||
username: eff.user.trim(),
|
||||
password: eff.pass,
|
||||
host: eff.host.trim(),
|
||||
port: eff.port,
|
||||
security: smtpSecurity,
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -748,6 +777,15 @@ function SmtpImapPanel({ onDone }: { onDone: () => void }) {
|
||||
portPlaceholder="993"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Security">
|
||||
<SecuritySelect
|
||||
value={imapSecurity}
|
||||
onChange={(v) => {
|
||||
imapSecurityTouched.current = true;
|
||||
setImapSecurity(v);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Username">
|
||||
<TextInput
|
||||
value={imapUser}
|
||||
@@ -763,7 +801,7 @@ function SmtpImapPanel({ onDone }: { onDone: () => void }) {
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="SMTP" sub="Outgoing, 465 or 587" icon={<SendIcon className="w-3.5 h-3.5" />}>
|
||||
<Section title="SMTP" sub="Outgoing, usually 587 or 465" icon={<SendIcon className="w-3.5 h-3.5" />}>
|
||||
<Field label="Server">
|
||||
<HostPortInput
|
||||
host={smtpHost}
|
||||
@@ -774,6 +812,15 @@ function SmtpImapPanel({ onDone }: { onDone: () => void }) {
|
||||
portPlaceholder="587"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Security">
|
||||
<SecuritySelect
|
||||
value={smtpSecurity}
|
||||
onChange={(v) => {
|
||||
smtpSecurityTouched.current = true;
|
||||
setSmtpSecurity(v);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 pl-[76px] pt-0.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -12,26 +12,39 @@
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CalendarRangeIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
FileTextIcon,
|
||||
InboxIcon,
|
||||
MailboxIcon,
|
||||
MoonIcon,
|
||||
MoreHorizontalIcon,
|
||||
OctagonAlertIcon,
|
||||
PenLineIcon,
|
||||
ReplyIcon,
|
||||
SearchIcon,
|
||||
SendIcon,
|
||||
SparkleIcon,
|
||||
SparklesIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import useUniboxOverview from "@/lib/api/hooks/app/unibox/useUniboxOverview";
|
||||
import useMarkSeen from "@/lib/api/hooks/app/unibox/useMarkSeen";
|
||||
import ShortcutTooltip from "@/components/ui/shortcut-tooltip";
|
||||
import ComposeDraftsItem from "@/components/app/unibox/compose/ComposeDraftsItem";
|
||||
import { useComposeStore } from "@/hooks/useComposeStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DitherMeter } from "@/components/ui/dither";
|
||||
import {
|
||||
PopoverMenu,
|
||||
PopoverMenuContent,
|
||||
PopoverMenuItem,
|
||||
PopoverMenuTrigger,
|
||||
} from "@/components/ui/popover-menu";
|
||||
import type { UniboxFolder } from "@/lib/api/models/app/unibox/UniboxSearch";
|
||||
|
||||
export type UniboxScope =
|
||||
| { kind: "all" }
|
||||
@@ -42,12 +55,15 @@ export type UniboxScope =
|
||||
| { kind: "agent_drafts" }
|
||||
| { kind: "snoozed" }
|
||||
| { kind: "scheduled" }
|
||||
| { kind: "folder"; folder: UniboxFolder }
|
||||
| { kind: "mailbox"; mailboxId: string }
|
||||
| { kind: "tag"; tagId: string }
|
||||
| { kind: "category"; categoryId: string };
|
||||
|
||||
export function scopeKey(s: UniboxScope): string {
|
||||
switch (s.kind) {
|
||||
case "folder":
|
||||
return `folder:${s.folder}`;
|
||||
case "mailbox":
|
||||
return `mailbox:${s.mailboxId}`;
|
||||
case "tag":
|
||||
@@ -59,6 +75,39 @@ export function scopeKey(s: UniboxScope): string {
|
||||
}
|
||||
}
|
||||
|
||||
const FOLDER_META: {
|
||||
folder: UniboxFolder;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
folder: "inbox",
|
||||
label: "Inbox",
|
||||
icon: <InboxIcon className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
folder: "drafts",
|
||||
label: "Drafts",
|
||||
icon: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{ folder: "sent", label: "Sent", icon: <SendIcon className="w-3.5 h-3.5" /> },
|
||||
{
|
||||
folder: "archive",
|
||||
label: "Archive",
|
||||
icon: <ArchiveIcon className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
folder: "spam",
|
||||
label: "Spam",
|
||||
icon: <OctagonAlertIcon className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
folder: "trash",
|
||||
label: "Trash",
|
||||
icon: <Trash2Icon className="w-3.5 h-3.5" />,
|
||||
},
|
||||
];
|
||||
|
||||
const COLLAPSE_THRESHOLD = 8;
|
||||
const COLLAPSED_VISIBLE = 6;
|
||||
|
||||
@@ -70,8 +119,16 @@ interface ScopeRailProps {
|
||||
export function ScopeRail({ scope, onChange }: ScopeRailProps) {
|
||||
const overview = useUniboxOverview();
|
||||
const data = overview.data;
|
||||
const markSeen = useMarkSeen();
|
||||
|
||||
const active = scopeKey(scope);
|
||||
const folderCounts = React.useMemo(() => {
|
||||
const m = new Map<string, { unread: number; total: number }>();
|
||||
for (const f of data?.folders ?? []) {
|
||||
m.set(f.folder, { unread: f.unread, total: f.total });
|
||||
}
|
||||
return m;
|
||||
}, [data?.folders]);
|
||||
|
||||
return (
|
||||
<nav className="h-full bg-slate-50/60 border-r border-slate-200 overflow-y-auto py-2">
|
||||
@@ -88,7 +145,30 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
|
||||
</ShortcutTooltip>
|
||||
<ComposeDraftsItem />
|
||||
</div>
|
||||
<Section label="Inbox">
|
||||
<Section label="Folders">
|
||||
{FOLDER_META.map((f) => {
|
||||
const counts = folderCounts.get(f.folder);
|
||||
// Drafts reads better as a total; everywhere else the badge is
|
||||
// the classic unread number.
|
||||
const count =
|
||||
f.folder === "drafts" ? counts?.total : counts?.unread;
|
||||
return (
|
||||
<FolderItem
|
||||
key={f.folder}
|
||||
icon={f.icon}
|
||||
label={f.label}
|
||||
count={count || undefined}
|
||||
countTone={count ? "accent" : "muted"}
|
||||
active={active === `folder:${f.folder}`}
|
||||
onOpen={() => onChange({ kind: "folder", folder: f.folder })}
|
||||
onMarkAllRead={() =>
|
||||
markSeen.mutate({ folder: f.folder, seen: true })
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
<Section label="Views">
|
||||
<Item
|
||||
icon={<InboxIcon className="w-3.5 h-3.5" />}
|
||||
label="All"
|
||||
@@ -408,6 +488,108 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
// FolderItem — one standard mail-folder row (issue #283): grey row + bold
|
||||
// label when active, unread badge, and a three-dot menu on the right. The
|
||||
// row is a div, not a button, because the menu trigger nests inside it and
|
||||
// nested buttons are invalid HTML; the trigger stops propagation so opening
|
||||
// the menu never also switches folders.
|
||||
function FolderItem({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
countTone = "muted",
|
||||
active,
|
||||
onOpen,
|
||||
onMarkAllRead,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
count?: number;
|
||||
countTone?: "muted" | "accent";
|
||||
active?: boolean;
|
||||
onOpen: () => void;
|
||||
onMarkAllRead: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
// Only the row itself activates: keydown from the nested menu trigger
|
||||
// bubbles here, and without this guard Enter on the three-dot button
|
||||
// would navigate the folder instead of opening its menu.
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group/folder w-full h-7 pl-2 pr-1 rounded-md flex items-center gap-2 transition-colors text-left cursor-pointer",
|
||||
active
|
||||
? "bg-slate-200/80 text-slate-900"
|
||||
: "text-slate-600 hover:bg-slate-200/70 hover:text-slate-900",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
<span
|
||||
className={cn("shrink-0", active ? "text-slate-700" : "text-slate-500")}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate min-w-0 flex-1 text-[12px]",
|
||||
active && "font-semibold",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{count !== undefined && count !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 font-mono tabular-nums text-[10.5px] px-1.5 h-4 rounded inline-flex items-center",
|
||||
active
|
||||
? "bg-white/80 text-slate-700"
|
||||
: countTone === "accent"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
<PopoverMenu align="end">
|
||||
{/* asChild: the trigger's own onClick already stops propagation, so
|
||||
opening the menu never also fires the row's onOpen. */}
|
||||
<PopoverMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label} folder actions`}
|
||||
className={cn(
|
||||
"shrink-0 size-5 rounded inline-flex items-center justify-center text-slate-500 hover:text-slate-900 hover:bg-white/80 transition-colors",
|
||||
active
|
||||
? "opacity-100"
|
||||
: "opacity-100 md:opacity-0 md:group-hover/folder:opacity-100",
|
||||
)}
|
||||
>
|
||||
<MoreHorizontalIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent>
|
||||
<PopoverMenuItem
|
||||
icon={<SparkleIcon className="w-3 h-3" />}
|
||||
onSelect={onMarkAllRead}
|
||||
>
|
||||
Mark all as read
|
||||
</PopoverMenuItem>
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
icon,
|
||||
label,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import Request from "../../Request";
|
||||
|
||||
// PATCH /unibox/seen marks the given unibox emails seen/unseen. The backend body
|
||||
// is { email_ids, seen } (models.MarkSeen); callers pass { ids } and seen
|
||||
// defaults to true (mark as read). Sending the wrong field names makes the
|
||||
// server bind an empty list and silently no-op, which is why the unread bar
|
||||
// never cleared before.
|
||||
export default async function markSeen(data: { ids: string[]; seen?: boolean }): Promise<void> {
|
||||
// PATCH /unibox/seen marks unibox emails seen/unseen. The backend body is
|
||||
// { email_ids, folder, seen } (models.MarkSeen); callers pass { ids } for an
|
||||
// explicit list or { folder } to sweep a whole folder, and seen defaults to
|
||||
// true (mark as read). Sending the wrong field names makes the server bind an
|
||||
// empty list and silently no-op, which is why the unread bar never cleared
|
||||
// before.
|
||||
export default async function markSeen(data: { ids?: string[]; folder?: string; seen?: boolean }): Promise<void> {
|
||||
return await Request<void>({
|
||||
method: "PATCH",
|
||||
url: `/unibox/seen`,
|
||||
data: { email_ids: data.ids, seen: data.seen ?? true },
|
||||
data: { email_ids: data.ids ?? [], folder: data.folder, seen: data.seen ?? true },
|
||||
authorization: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export default async function searchIncoming(
|
||||
// single email_id for legacy callers; we always use the multi form.
|
||||
usp.set("email_ids", p.accountIds.join(","));
|
||||
}
|
||||
if (p.folder) usp.set("folder", p.folder);
|
||||
if (p.unseen) usp.set("unseen", "true");
|
||||
if (p.snoozed === true) usp.set("snoozed", "true");
|
||||
else if (p.snoozed === "any") usp.set("snoozed", "any");
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function useMarkSeen() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { ids: string[]; seen?: boolean }) => markSeen(data),
|
||||
mutationFn: (data: { ids?: string[]; folder?: string; seen?: boolean }) => markSeen(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["unibox"],
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
// Connection security for a mailbox leg. TLS is mandatory either way; the
|
||||
// mode says whether it is negotiated before the greeting (implicit) or
|
||||
// upgraded in-band after it (STARTTLS). Mirrors models.MailSecurity* in Go.
|
||||
export type MailSecurity = "tls" | "starttls";
|
||||
|
||||
export default interface Service {
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
/** Omitted means "infer from the port", matching the backend. */
|
||||
security?: MailSecurity;
|
||||
}
|
||||
|
||||
/** Conventional SMTP default: 465 is implicit TLS, everything else STARTTLS. */
|
||||
export function defaultSmtpSecurity(port: number): MailSecurity {
|
||||
return port === 465 ? "tls" : "starttls";
|
||||
}
|
||||
|
||||
/** Conventional IMAP default: 143 is STARTTLS, everything else implicit TLS. */
|
||||
export function defaultImapSecurity(port: number): MailSecurity {
|
||||
return port === 143 ? "starttls" : "tls";
|
||||
}
|
||||
|
||||
/** Any routable TCP port; the security mode carries how to connect. */
|
||||
export function validPort(port: number): boolean {
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@ export interface UniboxCategoryOverview {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Per-folder thread counts, always emitted for all six canonical
|
||||
// folders in sidebar order.
|
||||
export interface UniboxFolderOverview {
|
||||
folder: "inbox" | "sent" | "drafts" | "archive" | "spam" | "trash";
|
||||
unread: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default interface UniboxOverview {
|
||||
total: number;
|
||||
unread: number;
|
||||
@@ -45,6 +53,7 @@ export default interface UniboxOverview {
|
||||
* the user sees their position before hitting the wall.
|
||||
*/
|
||||
scheduled_pending_max: number;
|
||||
folders: UniboxFolderOverview[];
|
||||
mailboxes: UniboxMailboxOverview[];
|
||||
tags: UniboxTagOverview[];
|
||||
categories: UniboxCategoryOverview[];
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
// Mirrors backend models.MailSearchParams. Empty / undefined fields are
|
||||
// stripped before serialization so the URL stays clean.
|
||||
|
||||
/** Canonical mail folders, mirroring the backend Folder* constants. */
|
||||
export type UniboxFolder =
|
||||
| "inbox"
|
||||
| "sent"
|
||||
| "drafts"
|
||||
| "archive"
|
||||
| "spam"
|
||||
| "trash";
|
||||
|
||||
export const UNIBOX_FOLDERS: UniboxFolder[] = [
|
||||
"inbox",
|
||||
"sent",
|
||||
"drafts",
|
||||
"archive",
|
||||
"spam",
|
||||
"trash",
|
||||
];
|
||||
|
||||
export interface UniboxSearchParams {
|
||||
query?: string; // Free text — currently matched as subject ILIKE
|
||||
from?: string; // Sender substring
|
||||
@@ -29,6 +47,11 @@ export interface UniboxSearchParams {
|
||||
* (debug only).
|
||||
*/
|
||||
snoozed?: true | "any";
|
||||
/**
|
||||
* Folder scope. Undefined = every folder except spam and trash (the
|
||||
* combined view never shows junk).
|
||||
*/
|
||||
folder?: UniboxFolder;
|
||||
/** Awaiting reply: threads where the last message was from us. */
|
||||
awaitingReply?: boolean;
|
||||
/** Agent drafts: threads with a pending inbox-agent reply draft. */
|
||||
|
||||
Reference in New Issue
Block a user