@ossy/platform 3.20.2 → 3.22.1
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/package.json +5 -3
- package/src/email-preview.js +34 -0
- package/src/runtime.js +2 -0
- package/src/server.js +17 -0
- package/src/ses/inbound-aws-credentials.js +34 -0
- package/src/ses/inbound-aws-credentials.spec.js +36 -0
- package/src/ses/poll-inbound-mail.js +90 -0
- package/src/ses/poll-inbound-mail.spec.js +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.22.1",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,11 +43,13 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@aws-sdk/client-s3": "^3.1057.0",
|
|
45
45
|
"@aws-sdk/client-ses": "^3.0.0",
|
|
46
|
+
"@aws-sdk/client-sqs": "^3.1103.0",
|
|
46
47
|
"@aws-sdk/s3-request-presigner": "^3.1057.0",
|
|
47
48
|
"@aws-sdk/util-create-request": "^3.972.26",
|
|
48
49
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
49
50
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
50
51
|
"@ossy/config": "^3.0.9",
|
|
52
|
+
"@ossy/email": "^3.22.1",
|
|
51
53
|
"@ossy/event-store": "^3.20.1",
|
|
52
54
|
"@ossy/locale": "^3.4.0",
|
|
53
55
|
"@ossy/manifest": "^3.20.1",
|
|
@@ -58,7 +60,7 @@
|
|
|
58
60
|
"@ossy/sdk": "^3.20.1",
|
|
59
61
|
"@ossy/tokens": "^3.11.1",
|
|
60
62
|
"@ossy/users": "^3.20.1",
|
|
61
|
-
"@ossy/workspaces": "^3.
|
|
63
|
+
"@ossy/workspaces": "^3.22.0",
|
|
62
64
|
"cookie-parser": "^1.4.7",
|
|
63
65
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
64
66
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -79,5 +81,5 @@
|
|
|
79
81
|
"Dockerfile",
|
|
80
82
|
"docker-healthcheck.js"
|
|
81
83
|
],
|
|
82
|
-
"gitHead": "
|
|
84
|
+
"gitHead": "b90c38bb41c552f9050067c6518e8a27cabf9827"
|
|
83
85
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createTranslatorForBuild } from './locale.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Give API handlers a way to load a discovered `*.email.jsx` and translate it.
|
|
5
|
+
* Email bundles are server entries, so the browser cannot render them itself.
|
|
6
|
+
*
|
|
7
|
+
* @param {import('express').Request} req
|
|
8
|
+
* @param {{
|
|
9
|
+
* manifest: { emails?: Array<{ id?: string, entry?: string }>, config?: { defaultLanguage?: string, supportedLanguages?: string[] } }
|
|
10
|
+
* loadEntry: (entryUrl: string) => Promise<any>
|
|
11
|
+
* buildDir: string
|
|
12
|
+
* }} context
|
|
13
|
+
*/
|
|
14
|
+
export function attachEmailPreview (req, { manifest, loadEntry, buildDir }) {
|
|
15
|
+
const config = manifest?.config || {}
|
|
16
|
+
const supported = new Set(
|
|
17
|
+
Array.isArray(config.supportedLanguages) && config.supportedLanguages.length
|
|
18
|
+
? config.supportedLanguages
|
|
19
|
+
: ['en', 'sv'],
|
|
20
|
+
)
|
|
21
|
+
const defaultLanguage = config.defaultLanguage || 'en'
|
|
22
|
+
|
|
23
|
+
req.loadEmailTemplate = async (id) => {
|
|
24
|
+
const email = (manifest?.emails || []).find((entry) => entry?.id === id)
|
|
25
|
+
if (!email?.entry) return null
|
|
26
|
+
return loadEntry(email.entry)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
req.emailTranslator = (language) => {
|
|
30
|
+
const requested = typeof language === 'string' ? language : ''
|
|
31
|
+
const active = supported.has(requested) ? requested : defaultLanguage
|
|
32
|
+
return createTranslatorForBuild(buildDir, active, { ...config, warnOnMissingKey: false })
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -6,6 +6,7 @@ import morgan from 'morgan'
|
|
|
6
6
|
import { Router as OssyRouter } from '@ossy/router'
|
|
7
7
|
import { loadManifest, resolveEntryUrl, loadLayoutsById, resolvePageLayoutRender } from './server.js'
|
|
8
8
|
import { resolveRequestLocale } from './locale.js'
|
|
9
|
+
import { attachEmailPreview } from './email-preview.js'
|
|
9
10
|
import { buildManifestSummary } from '@ossy/manifest/build-manifest-summary'
|
|
10
11
|
import { ProxyInternal } from './proxy-internal.js'
|
|
11
12
|
import { loadSite, invalidateSite } from './site-loader.js'
|
|
@@ -158,6 +159,7 @@ export async function startRuntime ({ port } = {}) {
|
|
|
158
159
|
if (apiEntry) {
|
|
159
160
|
const mod = await loadEntry(apiEntry.entry)
|
|
160
161
|
if (typeof mod.handle === 'function') {
|
|
162
|
+
attachEmailPreview(req, { manifest, loadEntry, buildDir })
|
|
161
163
|
await mod.handle(req, res)
|
|
162
164
|
return
|
|
163
165
|
}
|
package/src/server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { ConfigService } from './config.service.js'
|
|
|
21
21
|
import { UsersMiddleware } from './users.middleware.js'
|
|
22
22
|
import { WorkspacesMiddleware } from './workspaces.middleware.js'
|
|
23
23
|
import { resolveRequestLocale } from './locale.js'
|
|
24
|
+
import { attachEmailPreview } from './email-preview.js'
|
|
24
25
|
import { mountPlatformMcp } from './mcp/mount-platform-mcp.js'
|
|
25
26
|
import {
|
|
26
27
|
assertActionEntitled,
|
|
@@ -507,6 +508,7 @@ export async function startServer (options = {}) {
|
|
|
507
508
|
if (apiEntry) {
|
|
508
509
|
const mod = await loadEntry(apiEntry.entry)
|
|
509
510
|
if (typeof mod.handle === 'function') {
|
|
511
|
+
attachEmailPreview(req, { manifest, loadEntry, buildDir })
|
|
510
512
|
await mod.handle(req, res)
|
|
511
513
|
return
|
|
512
514
|
}
|
|
@@ -667,6 +669,20 @@ export async function startServer (options = {}) {
|
|
|
667
669
|
log.info(`Running on http://localhost:${port}`)
|
|
668
670
|
log.info('Press Ctrl+C to stop.')
|
|
669
671
|
|
|
672
|
+
let stopInboundMail = async () => {}
|
|
673
|
+
if (process.env.INBOUND_EMAIL_QUEUE_URL && process.env.INBOUND_EMAIL_BUCKET) {
|
|
674
|
+
try {
|
|
675
|
+
const { startInboundMailPoller } = await import('./ses/poll-inbound-mail.js')
|
|
676
|
+
stopInboundMail = startInboundMailPoller({
|
|
677
|
+
queueUrl: process.env.INBOUND_EMAIL_QUEUE_URL,
|
|
678
|
+
bucket: process.env.INBOUND_EMAIL_BUCKET,
|
|
679
|
+
log,
|
|
680
|
+
})
|
|
681
|
+
} catch (err) {
|
|
682
|
+
log.error('Inbound mail poller not started', undefined, err)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
670
686
|
runStartupRebuild?.()
|
|
671
687
|
|
|
672
688
|
const closeServer = () => new Promise((resolve) => {
|
|
@@ -682,6 +698,7 @@ export async function startServer (options = {}) {
|
|
|
682
698
|
try {
|
|
683
699
|
TaskService.stop()
|
|
684
700
|
closePushSseConnections()
|
|
701
|
+
await stopInboundMail()
|
|
685
702
|
await ChangeStream.stop()
|
|
686
703
|
await Mongo.closeConnection()
|
|
687
704
|
await closeServer()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The website task also has static AWS keys in its environment, and the SDK
|
|
3
|
+
* uses those before the task role. Inbound mail is granted to the task role,
|
|
4
|
+
* so the poller has to ask ECS for that role when it is running in a task.
|
|
5
|
+
*
|
|
6
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
7
|
+
* @param {typeof fetch} [fetchImpl]
|
|
8
|
+
*/
|
|
9
|
+
export function inboundAwsClientConfig (env = process.env, fetchImpl = globalThis.fetch) {
|
|
10
|
+
const relative = env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
|
|
11
|
+
const full = env.AWS_CONTAINER_CREDENTIALS_FULL_URI
|
|
12
|
+
if (!relative && !full) return {}
|
|
13
|
+
|
|
14
|
+
const url = full || `http://169.254.170.2${relative}`
|
|
15
|
+
return {
|
|
16
|
+
credentials: async () => {
|
|
17
|
+
const headers = {}
|
|
18
|
+
if (env.AWS_CONTAINER_AUTHORIZATION_TOKEN) {
|
|
19
|
+
headers.Authorization = env.AWS_CONTAINER_AUTHORIZATION_TOKEN
|
|
20
|
+
}
|
|
21
|
+
const response = await fetchImpl(url, { headers })
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error('Task role credentials could not be read')
|
|
24
|
+
}
|
|
25
|
+
const body = await response.json()
|
|
26
|
+
return {
|
|
27
|
+
accessKeyId: body.AccessKeyId,
|
|
28
|
+
secretAccessKey: body.SecretAccessKey,
|
|
29
|
+
sessionToken: body.Token,
|
|
30
|
+
expiration: body.Expiration ? new Date(body.Expiration) : undefined,
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { inboundAwsClientConfig } from './inbound-aws-credentials.js'
|
|
3
|
+
|
|
4
|
+
describe('inboundAwsClientConfig', () => {
|
|
5
|
+
it('uses the default chain when the process is not in a task', () => {
|
|
6
|
+
expect(inboundAwsClientConfig({ AWS_ACCESS_KEY_ID: 'static' })).toEqual({})
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('reads the task role and ignores static keys', async () => {
|
|
10
|
+
const fetchImpl = async (url, { headers }) => {
|
|
11
|
+
expect(url).toBe('http://169.254.170.2/v2/credentials/task')
|
|
12
|
+
expect(headers.Authorization).toBe('token')
|
|
13
|
+
return {
|
|
14
|
+
ok: true,
|
|
15
|
+
json: async () => ({
|
|
16
|
+
AccessKeyId: 'task-key',
|
|
17
|
+
SecretAccessKey: 'task-secret',
|
|
18
|
+
Token: 'task-token',
|
|
19
|
+
Expiration: '2026-10-17T20:00:00.000Z',
|
|
20
|
+
}),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const config = inboundAwsClientConfig({
|
|
24
|
+
AWS_ACCESS_KEY_ID: 'cms-prod',
|
|
25
|
+
AWS_SECRET_ACCESS_KEY: 'static',
|
|
26
|
+
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: '/v2/credentials/task',
|
|
27
|
+
AWS_CONTAINER_AUTHORIZATION_TOKEN: 'token',
|
|
28
|
+
}, fetchImpl)
|
|
29
|
+
await expect(config.credentials()).resolves.toEqual({
|
|
30
|
+
accessKeyId: 'task-key',
|
|
31
|
+
secretAccessKey: 'task-secret',
|
|
32
|
+
sessionToken: 'task-token',
|
|
33
|
+
expiration: new Date('2026-10-17T20:00:00.000Z'),
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { DeleteMessageCommand, ReceiveMessageCommand, SQSClient } from '@aws-sdk/client-sqs'
|
|
2
|
+
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
|
3
|
+
import { fileSesReceipt, sesReceiptFromBody } from '@ossy/email/ses-inbound'
|
|
4
|
+
import { createInProcessSdk } from '../tasks/in-process-sdk.js'
|
|
5
|
+
import { inboundAwsClientConfig } from './inbound-aws-credentials.js'
|
|
6
|
+
|
|
7
|
+
const ReceiveInbound = { id: '@ossy/email/actions/receive-inbound' }
|
|
8
|
+
|
|
9
|
+
async function readObject (client, { bucket, key }) {
|
|
10
|
+
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
|
|
11
|
+
return Buffer.from(await response.Body.transformToByteArray())
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* File each receipt and delete it. A failure leaves the message for SQS to
|
|
16
|
+
* retry, then the dead-letter queue. A skipped receipt is deleted.
|
|
17
|
+
*
|
|
18
|
+
* @param {Array<{ Body?: string, ReceiptHandle?: string }>} messages
|
|
19
|
+
*/
|
|
20
|
+
export async function ackInboundMessages (messages, { file, deleteMessage, log } = {}) {
|
|
21
|
+
for (const message of messages ?? []) {
|
|
22
|
+
try {
|
|
23
|
+
const result = await file(message.Body)
|
|
24
|
+
if (result?.skipped) log?.info('[ses/inbound] skipped receipt', result)
|
|
25
|
+
await deleteMessage(message)
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error?.name === 'NoSuchKey') {
|
|
28
|
+
log?.info('[ses/inbound] raw message already gone', { key: error.Key })
|
|
29
|
+
await deleteMessage(message)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
log?.error('[ses/inbound] receipt failed', undefined, error)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Long-poll the inbound queue. Several tasks may poll; SQS delivers each
|
|
39
|
+
* message to one of them. Returns a stop function for shutdown.
|
|
40
|
+
*/
|
|
41
|
+
export function startInboundMailPoller ({ queueUrl, bucket, log } = {}) {
|
|
42
|
+
const clientConfig = inboundAwsClientConfig()
|
|
43
|
+
const sqs = new SQSClient(clientConfig)
|
|
44
|
+
const objects = new S3Client(clientConfig)
|
|
45
|
+
const abort = new AbortController()
|
|
46
|
+
|
|
47
|
+
const file = (body) => fileSesReceipt(sesReceiptFromBody(body), {
|
|
48
|
+
bucket,
|
|
49
|
+
getObject: (location) => readObject(objects, location),
|
|
50
|
+
invoke: ({ mime, workspaceId }) => createInProcessSdk()
|
|
51
|
+
.forWorkspace(workspaceId)
|
|
52
|
+
.invoke(ReceiveInbound, { mime, workspaceId }),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const run = (async () => {
|
|
56
|
+
while (!abort.signal.aborted) {
|
|
57
|
+
try {
|
|
58
|
+
const response = await sqs.send(new ReceiveMessageCommand({
|
|
59
|
+
QueueUrl: queueUrl,
|
|
60
|
+
MaxNumberOfMessages: 5,
|
|
61
|
+
WaitTimeSeconds: 20,
|
|
62
|
+
VisibilityTimeout: 120,
|
|
63
|
+
}), { abortSignal: abort.signal })
|
|
64
|
+
await ackInboundMessages(response.Messages, {
|
|
65
|
+
file,
|
|
66
|
+
log,
|
|
67
|
+
deleteMessage: (message) => sqs.send(new DeleteMessageCommand({
|
|
68
|
+
QueueUrl: queueUrl,
|
|
69
|
+
ReceiptHandle: message.ReceiptHandle,
|
|
70
|
+
})),
|
|
71
|
+
})
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (abort.signal.aborted) return
|
|
74
|
+
log?.error('[ses/inbound] poll failed', undefined, error)
|
|
75
|
+
await new Promise((resolve) => {
|
|
76
|
+
const timer = setTimeout(resolve, 5000)
|
|
77
|
+
abort.signal.addEventListener('abort', () => {
|
|
78
|
+
clearTimeout(timer)
|
|
79
|
+
resolve()
|
|
80
|
+
}, { once: true })
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
})()
|
|
85
|
+
|
|
86
|
+
return async function stopInboundMailPoller () {
|
|
87
|
+
abort.abort()
|
|
88
|
+
await run.catch(() => {})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
import { ackInboundMessages } from './poll-inbound-mail.js'
|
|
3
|
+
|
|
4
|
+
describe('ackInboundMessages', () => {
|
|
5
|
+
it('deletes a filed receipt and a skipped one', async () => {
|
|
6
|
+
const deleteMessage = jest.fn()
|
|
7
|
+
await ackInboundMessages([
|
|
8
|
+
{ Body: 'filed' },
|
|
9
|
+
{ Body: 'skip' },
|
|
10
|
+
], {
|
|
11
|
+
file: async (body) => (body === 'skip' ? { skipped: true, reason: 'no workspace' } : { id: 'res-1' }),
|
|
12
|
+
deleteMessage,
|
|
13
|
+
})
|
|
14
|
+
expect(deleteMessage).toHaveBeenCalledTimes(2)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('leaves a failed receipt on the queue', async () => {
|
|
18
|
+
const deleteMessage = jest.fn()
|
|
19
|
+
await ackInboundMessages([{ Body: 'bad' }], {
|
|
20
|
+
file: async () => { throw new Error('store failed') },
|
|
21
|
+
deleteMessage,
|
|
22
|
+
log: { error () {} },
|
|
23
|
+
})
|
|
24
|
+
expect(deleteMessage).not.toHaveBeenCalled()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('deletes a receipt whose raw object is already gone', async () => {
|
|
28
|
+
const deleteMessage = jest.fn()
|
|
29
|
+
const error = Object.assign(new Error('missing'), { name: 'NoSuchKey' })
|
|
30
|
+
await ackInboundMessages([{ Body: 'gone' }], {
|
|
31
|
+
file: async () => { throw error },
|
|
32
|
+
deleteMessage,
|
|
33
|
+
log: { info () {} },
|
|
34
|
+
})
|
|
35
|
+
expect(deleteMessage).toHaveBeenCalledTimes(1)
|
|
36
|
+
})
|
|
37
|
+
})
|