pipework 0.254.0 → 0.255.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/infra/alerts/handle.d.ts +10 -0
- package/dist/infra/alerts/handle.d.ts.map +1 -1
- package/dist/infra/alerts/handle.js +9 -2
- package/dist/infra/alerts/handle.js.map +1 -1
- package/dist/infra/email/inbound.d.ts +12 -1
- package/dist/infra/email/inbound.d.ts.map +1 -1
- package/dist/infra/email/inbound.js +22 -1
- package/dist/infra/email/inbound.js.map +1 -1
- package/dist/infra/email/schema.d.ts +5 -0
- package/dist/infra/email/schema.d.ts.map +1 -1
- package/dist/infra/email/schema.js +4 -0
- package/dist/infra/email/schema.js.map +1 -1
- package/dist/infra/email/sns.js +1 -1
- package/dist/infra/email/sns.js.map +1 -1
- package/dist/infra/outbox/core.d.ts +24 -0
- package/dist/infra/outbox/core.d.ts.map +1 -1
- package/dist/infra/outbox/core.js +58 -0
- package/dist/infra/outbox/core.js.map +1 -1
- package/dist/infra/support/handle.d.ts +14 -0
- package/dist/infra/support/handle.d.ts.map +1 -1
- package/dist/infra/support/handle.js +16 -3
- package/dist/infra/support/handle.js.map +1 -1
- package/dist/infra/support/namespace.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/infra/alerts/handle.ts +16 -2
- package/src/infra/email/inbound.ts +34 -2
- package/src/infra/email/schema.ts +4 -0
- package/src/infra/email/sns.ts +1 -1
- package/src/infra/outbox/core.ts +70 -0
- package/src/infra/support/handle.ts +26 -3
|
@@ -9,6 +9,7 @@ import { define } from '../../data/domain/index.js'
|
|
|
9
9
|
import { field } from '../../data/domain/field.js'
|
|
10
10
|
import { schema } from '../../data/schema/namespace.js'
|
|
11
11
|
import { index, unique } from '../../data/db/idx/index.js'
|
|
12
|
+
import { sql } from '../../data/db/sql.js'
|
|
12
13
|
|
|
13
14
|
export const EMAIL_TABLE_NAMES: ReadonlySet<string> = new Set([
|
|
14
15
|
'pipework_email_messages', 'pipework_email_suppressions', 'pipework_email_inbound', 'pipework_email_unsubscribes',
|
|
@@ -71,6 +72,8 @@ export const emailInbound = define('pipework_email_inbound', {
|
|
|
71
72
|
rawLocation: field.jsonb(schema.check.json()),
|
|
72
73
|
rawExpiresAt: field.timestamp(),
|
|
73
74
|
rawDeletedAt: field.timestamp().nullable(),
|
|
75
|
+
// Set when `sweep` nulls the address, subject, recipients and Message-ID after `metadataRetentionDays`.
|
|
76
|
+
metadataScrubbedAt: field.timestamp().nullable(),
|
|
74
77
|
status: field.enum(INBOUND_STATUSES).default('received'),
|
|
75
78
|
error: field.text().nullable(),
|
|
76
79
|
messageIdHeader: field.text().nullable(),
|
|
@@ -92,5 +95,6 @@ export const emailInbound = define('pipework_email_inbound', {
|
|
|
92
95
|
bySource: unique('pipework_email_inbound_source_id_key').on(t['sourceId']),
|
|
93
96
|
byMessageId: index('idx_pipework_email_inbound_message_id').on(t['messageIdHeader']),
|
|
94
97
|
byExpiry: index('idx_pipework_email_inbound_raw_expires').on(t['rawExpiresAt']),
|
|
98
|
+
unscrubbed: index('idx_pipework_email_inbound_unscrubbed').on(t['receivedAt']).where(sql.raw('metadata_scrubbed_at IS NULL')),
|
|
95
99
|
}),
|
|
96
100
|
})
|
package/src/infra/email/sns.ts
CHANGED
|
@@ -94,7 +94,7 @@ const processEvent = __flowStep({ ref: 'pipework/email:notification' }, async (e
|
|
|
94
94
|
} else {
|
|
95
95
|
getBaseLogger().info({ event: 'email bounce not suppressed', address, bounceType: r.bounceType }, 'transient bounce: address kept')
|
|
96
96
|
}
|
|
97
|
-
await auditEmail(r.kind, messageId, {
|
|
97
|
+
await auditEmail(r.kind, messageId, { bounceType: r.bounceType, suppressed: permanent, messageTraceId })
|
|
98
98
|
}
|
|
99
99
|
})
|
|
100
100
|
|
package/src/infra/outbox/core.ts
CHANGED
|
@@ -13,6 +13,9 @@ const LEASE_MS = 30_000
|
|
|
13
13
|
const BATCH = 50
|
|
14
14
|
const MAX_BATCHES = 20
|
|
15
15
|
const MAX_BACKOFF_MS = 3_600_000
|
|
16
|
+
const SWEEP_BATCH = 500
|
|
17
|
+
const DEFAULT_SWEEP_LIMIT = 10_000
|
|
18
|
+
const SWEEP_EVERY_MS = 3_600_000
|
|
16
19
|
|
|
17
20
|
export interface OutboxTotals {
|
|
18
21
|
delivered: number
|
|
@@ -61,6 +64,27 @@ export interface OutboxOptions<D> {
|
|
|
61
64
|
readonly onGivenUp: (row: Record<string, unknown>, attempt: number, error: string) => Promise<void>
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/** Retention for an outbox's rows, in days. Unset: kept. */
|
|
68
|
+
export interface OutboxRetention {
|
|
69
|
+
/** Delivered rows (and their payloads) older than this are deleted. */
|
|
70
|
+
readonly deliveredRetentionDays: number
|
|
71
|
+
/** Given-up rows (`failed_at` set, never delivered) older than this are deleted. Unset: kept. */
|
|
72
|
+
readonly failedRetentionDays?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Refuses a retention that is not whole days of zero or more, naming `who` and the option. */
|
|
76
|
+
export function checkRetention(who: string, config: { readonly deliveredRetentionDays?: number; readonly failedRetentionDays?: number }): OutboxRetention | undefined {
|
|
77
|
+
for (const name of ['deliveredRetentionDays', 'failedRetentionDays'] as const) {
|
|
78
|
+
const v = config[name]
|
|
79
|
+
if (v !== undefined && !(Number.isInteger(v) && v >= 0)) throw new Error(`[pipework] ${who}: ${name} must be an integer of zero or more, got ${String(v)}.`)
|
|
80
|
+
}
|
|
81
|
+
if (config.failedRetentionDays !== undefined && config.deliveredRetentionDays === undefined) {
|
|
82
|
+
throw new Error(`[pipework] ${who}: failedRetentionDays needs deliveredRetentionDays too.`)
|
|
83
|
+
}
|
|
84
|
+
if (config.deliveredRetentionDays === undefined) return undefined
|
|
85
|
+
return { deliveredRetentionDays: config.deliveredRetentionDays, ...(config.failedRetentionDays !== undefined ? { failedRetentionDays: config.failedRetentionDays } : {}) }
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
export interface Outbox {
|
|
65
89
|
/** Delivers what is due now, up to 20 batches; rows that fail wait for the next call. */
|
|
66
90
|
drain(): Promise<OutboxTotals>
|
|
@@ -70,6 +94,26 @@ export interface Outbox {
|
|
|
70
94
|
* `prev_attempts`, the attempts it had when given up.
|
|
71
95
|
*/
|
|
72
96
|
requeue(options: { sink?: string; failedSince?: Date }): Promise<Record<string, unknown>[]>
|
|
97
|
+
/**
|
|
98
|
+
* Deletes delivered rows whose `delivered_at` is older than `deliveredRetentionDays` and, with `failedRetentionDays`,
|
|
99
|
+
* given-up rows whose `failed_at` is older than that. A row still queued or being retried is never deleted.
|
|
100
|
+
* Deletes in batches of 500, at most `limit` rows per call (default 10 000).
|
|
101
|
+
*/
|
|
102
|
+
sweep(options: OutboxRetention & { limit?: number }): Promise<{ deleted: number }>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A function that runs `outbox.sweep` with `retention` at most once an hour per process, and never throws; `onError` gets a failure. No retention: does nothing. */
|
|
106
|
+
export function hourlySweep(outbox: Outbox, retention: OutboxRetention | undefined, onError: (err: unknown) => void): () => Promise<void> {
|
|
107
|
+
let last = 0
|
|
108
|
+
return async () => {
|
|
109
|
+
if (retention === undefined || Date.now() - last < SWEEP_EVERY_MS) return
|
|
110
|
+
last = Date.now()
|
|
111
|
+
try {
|
|
112
|
+
await outbox.sweep(retention)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
onError(err)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
73
117
|
}
|
|
74
118
|
|
|
75
119
|
function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
|
|
@@ -136,7 +180,33 @@ export function createOutbox<D>(options: OutboxOptions<D>): Outbox {
|
|
|
136
180
|
}))
|
|
137
181
|
}
|
|
138
182
|
|
|
183
|
+
async function deleteBatch(predicate: ReturnType<typeof sql>, size: number): Promise<number> {
|
|
184
|
+
const rows = await db().execute(sql`
|
|
185
|
+
DELETE FROM ${table} WHERE id IN (SELECT id FROM ${table} WHERE ${predicate} LIMIT ${size}) RETURNING id`)
|
|
186
|
+
return rows.length
|
|
187
|
+
}
|
|
188
|
+
|
|
139
189
|
return {
|
|
190
|
+
async sweep(opts) {
|
|
191
|
+
const days = [['deliveredRetentionDays', opts.deliveredRetentionDays], ['failedRetentionDays', opts.failedRetentionDays], ['limit', opts.limit]] as const
|
|
192
|
+
for (const [name, v] of days) {
|
|
193
|
+
if (v !== undefined && !(Number.isInteger(v) && v >= (name === 'limit' ? 1 : 0))) {
|
|
194
|
+
throw new ValidationError({ [name]: ['must be a whole number' + (name === 'limit' ? ' of one or more' : ' of zero or more')] }, `Outbox sweep ${name} is invalid.`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const delivered = sql`delivered_at IS NOT NULL AND delivered_at < clock_timestamp() - ${opts.deliveredRetentionDays} * interval '1 day'`
|
|
198
|
+
const failed = opts.failedRetentionDays === undefined ? null
|
|
199
|
+
: sql`delivered_at IS NULL AND failed_at IS NOT NULL AND failed_at < clock_timestamp() - ${opts.failedRetentionDays} * interval '1 day'`
|
|
200
|
+
const predicate = failed === null ? delivered : sql`((${delivered}) OR (${failed}))`
|
|
201
|
+
const limit = opts.limit ?? DEFAULT_SWEEP_LIMIT
|
|
202
|
+
let deleted = 0
|
|
203
|
+
while (deleted < limit) {
|
|
204
|
+
const n = await deleteBatch(predicate, Math.min(SWEEP_BATCH, limit - deleted))
|
|
205
|
+
deleted += n
|
|
206
|
+
if (n === 0) break
|
|
207
|
+
}
|
|
208
|
+
return { deleted }
|
|
209
|
+
},
|
|
140
210
|
async requeue(opts) {
|
|
141
211
|
if (opts.failedSince !== undefined && !(opts.failedSince instanceof Date && !Number.isNaN(opts.failedSince.getTime()))) {
|
|
142
212
|
throw new ValidationError({ failedSince: ['must be a valid Date'] }, 'Outbox failedSince must be a Date.')
|
|
@@ -17,7 +17,7 @@ import { recordBeat, beatsEnabled } from '../alerts/beat.js'
|
|
|
17
17
|
import type { Mail } from '../email/mail.js'
|
|
18
18
|
import { sanitizeHtml, htmlToText } from '../email/sanitize.js'
|
|
19
19
|
import type { ReceivedEmail } from '../email/inbound.js'
|
|
20
|
-
import { createOutbox, type Sink, type SinkDelivery } from '../outbox/core.js'
|
|
20
|
+
import { checkRetention, createOutbox, hourlySweep, type Sink, type SinkDelivery } from '../outbox/core.js'
|
|
21
21
|
import { TICKET_STATES } from './schema.js'
|
|
22
22
|
import { describeError } from '../../core/logging/error-chain.js'
|
|
23
23
|
|
|
@@ -99,6 +99,14 @@ export interface SupportConfig {
|
|
|
99
99
|
readonly maxAttempts?: number
|
|
100
100
|
/** First retry delay, doubling per attempt up to one hour; default 5000. */
|
|
101
101
|
readonly backoffMs?: number
|
|
102
|
+
/**
|
|
103
|
+
* Days a delivered event row, with its payload (the ticket subject), is kept before the sweep deletes it. Unset: kept.
|
|
104
|
+
* `tick` (so `support.job`) runs the sweep at most once an hour per process; `sweep()` runs it on demand.
|
|
105
|
+
* The ticket audit rows carry ids only; they expire with the trace partitions (`auditRetentionDays`).
|
|
106
|
+
*/
|
|
107
|
+
readonly deliveredRetentionDays?: number
|
|
108
|
+
/** Days an event row that was given up on (never delivered) is kept. Needs `deliveredRetentionDays`. Unset: kept. */
|
|
109
|
+
readonly failedRetentionDays?: number
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
export interface Ticket {
|
|
@@ -256,6 +264,8 @@ export interface Support extends WithFor<TicketApi> {
|
|
|
256
264
|
readonly job: Handler<Record<string, unknown>, unknown>
|
|
257
265
|
/** What one run of `job` does; callable from a test. Needs a Flow. */
|
|
258
266
|
tick(): Promise<{ delivered: number; retried: number; failed: number }>
|
|
267
|
+
/** Deletes event rows past `deliveredRetentionDays` / `failedRetentionDays` now, up to `limit` (default 10 000); `deleted: 0` when neither is set. Needs a Flow. */
|
|
268
|
+
sweep(options?: { limit?: number }): Promise<{ deleted: number }>
|
|
259
269
|
/**
|
|
260
270
|
* Puts the event deliveries that were given up (and never delivered) back on the queue, for every tenant, optionally
|
|
261
271
|
* only those of `sink` and/or that failed at or after `failedSince`; the next `tick` sends them. Delivery ids stay the
|
|
@@ -358,6 +368,7 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
358
368
|
if ((joinThread as string) !== 'contact' && (joinThread as string) !== 'conversation') {
|
|
359
369
|
throw new Error(`[pipework] support.create: joinThread must be 'contact' or 'conversation', got ${JSON.stringify(joinThread)}.`)
|
|
360
370
|
}
|
|
371
|
+
const retention = checkRetention('support.create', config)
|
|
361
372
|
const jobType = config.jobType ?? 'pipework.support.events'
|
|
362
373
|
const replyTemplate = config.replyTemplate ?? 'pipework.support.reply'
|
|
363
374
|
const distinctApprover = config.distinctApprover ?? true
|
|
@@ -545,7 +556,7 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
545
556
|
RETURNING *`)
|
|
546
557
|
ticket = toTicket(rows[0]!)
|
|
547
558
|
created = true
|
|
548
|
-
await audit(flow, tx, tenantId, 'create', ticket.id, {
|
|
559
|
+
await audit(flow, tx, tenantId, 'create', ticket.id, { channel: m.channel, userId: ticket.userId })
|
|
549
560
|
} else {
|
|
550
561
|
await discardLive(flow, tx, ticket, 'customer-replied')
|
|
551
562
|
if (ticket.state !== 'open') ticket = await moveTo(flow, tx, ticket, 'open', true)
|
|
@@ -802,7 +813,15 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
802
813
|
await audit(flow, null, payload.tenantId, verb, row['ticket_id'] as string, { sink: row['sink'], kind: row['kind'], ...extra })
|
|
803
814
|
}
|
|
804
815
|
|
|
805
|
-
const
|
|
816
|
+
const sweepIfDue = hourlySweep(outbox, retention, (err) => {
|
|
817
|
+
getBaseLogger().error({ err }, 'support event sweep failed')
|
|
818
|
+
void recordFault('support-event-sweep', faultMessage(err), {})
|
|
819
|
+
})
|
|
820
|
+
const drainStep = __flowStep({ ref: 'pipework/support:tick' }, async () => {
|
|
821
|
+
const totals = await outbox.drain()
|
|
822
|
+
await sweepIfDue()
|
|
823
|
+
return totals
|
|
824
|
+
})
|
|
806
825
|
// A beat for `alerts.source.loopStalled`, written only where the blueprint has `alerts`.
|
|
807
826
|
const beat = async (outcome: { ok: boolean; error?: string }): Promise<void> => {
|
|
808
827
|
const ctx = getContext()
|
|
@@ -855,6 +874,10 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
855
874
|
},
|
|
856
875
|
job: job as unknown as Handler<Record<string, unknown>, unknown>,
|
|
857
876
|
tick: () => tickStep(),
|
|
877
|
+
async sweep(options = {}) {
|
|
878
|
+
requireContext()
|
|
879
|
+
return retention === undefined ? { deleted: 0 } : outbox.sweep({ ...retention, ...(options.limit !== undefined ? { limit: options.limit } : {}) })
|
|
880
|
+
},
|
|
858
881
|
async requeueFailed(options) {
|
|
859
882
|
requireContext()
|
|
860
883
|
nonEmpty('by', options.by)
|