Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/api/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ interface ThreadViewJson {
bodyText: string | null
bodyHtml: string | null
deliveryStatus: 'pending' | 'sent' | 'failed' | null
/** Open tracking (spec §4g, v1.1): first customer view of this outbound reply; null until then, always null for inbound/notes or with the feature off. */
customerViewedAt: string | null
createdAt: string
}

Expand Down Expand Up @@ -278,6 +280,7 @@ export async function handleReply(
keyring: Keyring
mailDomain: string
supportAddress: string
openTracking?: { publicBaseUrl: string }
},
): Promise<Response> {
if (!isUuid(id)) {
Expand Down Expand Up @@ -346,6 +349,7 @@ export async function handleReply(
sender: deps.sender,
keyring: deps.keyring,
mailDomain: deps.mailDomain,
...(deps.openTracking !== undefined ? { openTracking: deps.openTracking } : {}),
},
)

Expand Down Expand Up @@ -778,6 +782,8 @@ function toThreadViewJson(thread: StoredThread): ThreadViewJson {
bodyText: thread.bodyText,
bodyHtml: thread.bodyHtml,
deliveryStatus: thread.deliveryStatus,
customerViewedAt:
thread.customerViewedAt === null ? null : thread.customerViewedAt.toISOString(),
createdAt: thread.createdAt.toISOString(),
}
}
109 changes: 108 additions & 1 deletion src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ describe('createInboxApi', () => {
db = undefined
})

async function freshApi(overrides: { sender?: EmailSender } = {}): Promise<{
async function freshApi(
overrides: { sender?: EmailSender; openTracking?: { publicBaseUrl: string } } = {},
): Promise<{
db: Db
store: ConversationStore
api: (request: Request) => Promise<Response>
Expand All @@ -177,6 +179,7 @@ describe('createInboxApi', () => {
keyring: KEYRING,
mailDomain: MAIL_DOMAIN,
supportAddress: SUPPORT_ADDRESS,
...(overrides.openTracking !== undefined ? { openTracking: overrides.openTracking } : {}),
})
return { db, store, api, sent }
}
Expand Down Expand Up @@ -1166,6 +1169,110 @@ describe('createInboxApi', () => {
})
})

// --- open tracking (HT-32, spec §4g v1.1) -----------------------------------------

describe('open tracking', () => {
const BASE = 'https://desk.example.test'

/** Reply with html, then pull the pixel token out of the sent mail. */
async function replyAndExtractToken(
api: (request: Request) => Promise<Response>,
sent: OutboundEmail[],
conversationId: string,
): Promise<{ token: string; threadId: string }> {
const res = await api(
replyPost(`/api/v1/conversations/${conversationId}/replies`, {
text: 'On it.',
html: '<html><body><p>On it.</p></body></html>',
}),
)
expect(res.status).toBe(201)
const body = (await res.json()) as { id: string }
const match = /\/api\/v1\/t\/([^"]+)\.gif/.exec(sent[0].html as string)
expect(match).not.toBeNull()
return { token: (match as RegExpExecArray)[1], threadId: body.id }
}

it('full loop: enabled → reply carries the pixel; an UNAUTHENTICATED gif fetch records the first view; the detail surfaces it', async () => {
const { store, api, sent } = await freshApi({ openTracking: { publicBaseUrl: BASE } })
const { conversationId } = await store.createConversation(newConversation())
const { token, threadId } = await replyAndExtractToken(api, sent, conversationId)

// Before any view: null on the wire.
const before = await api(get(`/api/v1/conversations/${conversationId}`))
const beforeBody = (await before.json()) as {
threads: Array<{ id: string; customerViewedAt: string | null }>
}
expect(beforeBody.threads.find((t) => t.id === threadId)?.customerViewedAt).toBeNull()

// The pixel fetch: NO Authorization header — a customer's mail client.
const pixel = await fetchPixel(api, token)
expect(pixel.status).toBe(200)
expect(pixel.headers.get('Content-Type')).toBe('image/gif')
expect(pixel.headers.get('Cache-Control')).toBe('no-store')
expect((await pixel.arrayBuffer()).byteLength).toBeGreaterThan(0)

const after = await api(get(`/api/v1/conversations/${conversationId}`))
const afterBody = (await after.json()) as {
threads: Array<{ id: string; customerViewedAt: string | null }>
}
const viewedAt = afterBody.threads.find((t) => t.id === threadId)?.customerViewedAt
expect(viewedAt).toEqual(expect.any(String))

// Second fetch: same gif, timestamp unchanged (first view wins).
await fetchPixel(api, token)
const again = await api(get(`/api/v1/conversations/${conversationId}`))
const againBody = (await again.json()) as {
threads: Array<{ id: string; customerViewedAt: string | null }>
}
expect(againBody.threads.find((t) => t.id === threadId)?.customerViewedAt).toBe(viewedAt)
})

it('an invalid token gets the IDENTICAL gif response and records nothing', async () => {
const { store, api } = await freshApi({ openTracking: { publicBaseUrl: BASE } })
await store.createConversation(newConversation())

const pixel = await fetchPixel(api, 'v.k1.forged-thread-id.AAAA')
expect(pixel.status).toBe(200)
expect(pixel.headers.get('Content-Type')).toBe('image/gif')
})

it('DISABLED (the default): no pixel in outbound html, and a valid-looking gif fetch records nothing', async () => {
const { store, api, sent } = await freshApi()
const { conversationId } = await store.createConversation(newConversation())

const res = await api(
replyPost(`/api/v1/conversations/${conversationId}/replies`, {
text: 'On it.',
html: '<html><body><p>On it.</p></body></html>',
}),
)
expect(res.status).toBe(201)
const replyBody = (await res.json()) as { id: string }
expect(sent[0].html).toBe('<html><body><p>On it.</p></body></html>')

// Even a genuinely valid token records nothing while the feature is
// off — turning tracking off stops recording, not just injection.
const { mintViewToken } = await import('../mail/open-tracking.js')
const validToken = mintViewToken(replyBody.id, KEYRING)
const pixel = await fetchPixel(api, validToken)
expect(pixel.status).toBe(200)

const detail = await api(get(`/api/v1/conversations/${conversationId}`))
const detailBody = (await detail.json()) as {
threads: Array<{ id: string; customerViewedAt: string | null }>
}
expect(detailBody.threads.find((t) => t.id === replyBody.id)?.customerViewedAt).toBeNull()
})

function fetchPixel(
api: (request: Request) => Promise<Response>,
token: string,
): Promise<Response> {
return api(new Request(`https://x.example.test/api/v1/t/${token}.gif`))
}
})

// --- notes (HT-28, spec §4c v1.1) ------------------------------------------------

describe('notes', () => {
Expand Down
46 changes: 45 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
* 3. **Dispatch** to the matched handler (`src/api/conversations.ts`).
*/

import { TRANSPARENT_GIF, verifyViewToken } from '../mail/open-tracking.js'
import type { Keyring } from '../mail/reply-token.js'
import type { EmailSender } from '../providers/index.js'
import type { ConversationStore } from '../store/conversations.js'
Expand All @@ -45,7 +46,7 @@ import {
} from './conversations.js'
import type { ApiError } from './responses.js'
import { apiError } from './responses.js'
import { matchRoute } from './router.js'
import { matchOpenTrackingPixel, matchRoute } from './router.js'

/**
* Minimum length for the service Bearer token. A short/empty token is a
Expand Down Expand Up @@ -76,6 +77,15 @@ export interface InboxApiDeps {
mailDomain: string
/** The deployment's configured support address — the `from` on every Agent reply (spec §4a). */
supportAddress: string
/**
* Open tracking (spec §4g, v1.1 — HT-32): ABSENT BY DEFAULT — a deliberate
* privacy stance, not an unset knob. When present, outbound replies get a
* signed tracking pixel served from `publicBaseUrl`, and the pixel
* endpoint records first views. When absent — the shipped default —
* nothing is injected and nothing is EVER recorded (a pixel from mail sent
* while the feature was on stops recording the moment it is turned off).
*/
openTracking?: { publicBaseUrl: string }
}

/**
Expand All @@ -96,6 +106,39 @@ export function createInboxApi(deps: InboxApiDeps): (request: Request) => Promis
}

return async (request: Request): Promise<Response> => {
// The open-tracking pixel is the API's ONE unauthenticated surface (spec
// §4g; §3 names it as the deliberate exception) — customer mail clients
// fetch it, so it is matched BEFORE Bearer auth. Everything about it is
// deliberately uniform: `200` + the same 1×1 gif + `no-store`, valid
// token or not, feature on or off — no validity or existence leak, and a
// pixel baked into old mail keeps rendering harmlessly forever. The
// recording side effect happens ONLY when the feature is enabled AND the
// token verifies (first view wins; `recordThreadView` is idempotent and
// silent on every miss). Its own try/catch keeps even a store failure
// answering with the gif — the JSON error envelope below must never
// reach an <img> tag.
const pixel = matchOpenTrackingPixel(request.method, new URL(request.url).pathname)
if (pixel !== null) {
if (deps.openTracking !== undefined) {
try {
const verified = verifyViewToken(pixel.token, deps.keyring)
if (verified !== null) {
await deps.store.recordThreadView(verified.threadId)
}
} catch (err) {
console.error('[inbox-api] open-tracking record failed (gif still served)', err)
}
}
return new Response(new Uint8Array(TRANSPARENT_GIF), {
status: 200,
headers: {
'Content-Type': 'image/gif',
'Cache-Control': 'no-store',
'Content-Length': String(TRANSPARENT_GIF.length),
},
})
}

if (!authenticateRequest(request, deps.apiToken)) {
return apiError(401, 'unauthorized', 'Missing or invalid credentials.')
}
Expand Down Expand Up @@ -170,6 +213,7 @@ export function createInboxApi(deps: InboxApiDeps): (request: Request) => Promis
keyring: deps.keyring,
mailDomain: deps.mailDomain,
supportAddress: deps.supportAddress,
...(deps.openTracking !== undefined ? { openTracking: deps.openTracking } : {}),
})
}
} catch (err) {
Expand Down
16 changes: 16 additions & 0 deletions src/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ export type RouteMatch =
* independently, not as a fallback, and the two routes never contend for
* the same pathname.
*/
/**
* Match the open-tracking pixel path (spec §4g, v1.1 — HT-32):
* `GET /api/v1/t/{token}.gif`. Kept SEPARATE from {@link matchRoute} on
* purpose — the pixel is the API's one UNAUTHENTICATED surface, checked by
* `index.ts` BEFORE Bearer auth, and giving it its own matcher keeps the
* authenticated route table free of any pre-auth special case. GET only;
* any other method on this path simply falls through to the normal
* authenticated pipeline (and 401s like everything else).
*/
export function matchOpenTrackingPixel(method: string, pathname: string): { token: string } | null {
if (method !== 'GET') return null
const match = /^\/api\/v1\/t\/(?<token>[^/]+)\.gif$/.exec(pathname)
const token = match?.groups?.token
return token === undefined ? null : { token }
}

export function matchRoute(method: string, pathname: string): RouteMatch {
for (const route of ROUTES) {
const match = route.pattern.exec(pathname)
Expand Down
30 changes: 30 additions & 0 deletions src/db/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe('migrate', () => {
{ id: 5, name: 'conversation_number' },
{ id: 6, name: 'tags_and_assignee' },
{ id: 7, name: 'note_thread_direction' },
{ id: 8, name: 'customer_viewed_at' },
])
})

Expand All @@ -64,6 +65,7 @@ describe('migrate', () => {
{ id: 5 },
{ id: 6 },
{ id: 7 },
{ id: 8 },
])
})

Expand Down Expand Up @@ -496,4 +498,32 @@ describe('migrate', () => {
),
).rejects.toThrow()
})
it('migration 008 ties customer_viewed_at to direction: outbound may carry one, inbound and note may not', async () => {
const database = await createPgliteDb()
db = database
await migrate(database)

const [conversation] = await database.query<{ id: string }>(
'INSERT INTO conversations (customer_email) VALUES ($1) RETURNING id',
['customer@example.test'],
)

await expect(
database.query(
`INSERT INTO threads (conversation_id, direction, from_address, delivery_status, customer_viewed_at)
VALUES ($1, 'outbound', $2, 'sent', now())`,
[conversation.id, 'support@example.test'],
),
).resolves.toBeDefined()

for (const direction of ['inbound', 'note']) {
await expect(
database.query(
`INSERT INTO threads (conversation_id, direction, from_address, customer_viewed_at)
VALUES ($1, $2, $3, now())`,
[conversation.id, direction, 'customer@example.test'],
),
).rejects.toThrow()
}
})
})
25 changes: 25 additions & 0 deletions src/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,26 @@ ALTER TABLE threads ADD CONSTRAINT threads_delivery_status_by_direction CHECK (
);
`

/**
* Migration 008 — `customer_viewed_at` for open tracking (HT-32;
* specs/api/agent-inbox-v1.md §4g, v1.1).
*
* Nullable, outbound-only (same cross-column CHECK style as migrations
* 002/003, same NULL-semantics care): the first time a customer's mail
* client fetches an outbound reply's tracking pixel — feature enabled, token
* verified — the timestamp is recorded once, idempotently
* (`ConversationStore.recordThreadView`). Inbound threads and notes never
* carry one; the schema forbids it, not just the application. No backfill:
* NULL is the correct value for every existing row (nothing was tracked
* before the feature existed).
*/
const MIGRATION_008_CUSTOMER_VIEWED_AT = `
ALTER TABLE threads ADD COLUMN customer_viewed_at timestamptz;
ALTER TABLE threads ADD CONSTRAINT threads_customer_viewed_at_outbound_only CHECK (
(direction = 'outbound') OR (customer_viewed_at IS NULL)
);
`

/**
* Every migration, in the order they must apply. `id` is the sole ordering
* key (ascending) — array position is not relied upon, so re-sorting this
Expand Down Expand Up @@ -335,6 +355,11 @@ const MIGRATIONS: Migration[] = [
name: 'note_thread_direction',
sql: MIGRATION_007_NOTE_DIRECTION,
},
{
id: 8,
name: 'customer_viewed_at',
sql: MIGRATION_008_CUSTOMER_VIEWED_AT,
},
]

/**
Expand Down
Loading
Loading