@ossy/platform 3.20.2 → 3.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.20.2",
3
+ "version": "3.22.0",
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.0",
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.20.1",
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": "d0e5d69906917105609af4bb8bbdd2340d9fbaf1"
84
+ "gitHead": "29de300fd5e2c36f9d6d00f09eb9f1508997a904"
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,88 @@
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
+
6
+ const ReceiveInbound = { id: '@ossy/email/actions/receive-inbound' }
7
+
8
+ async function readObject ({ bucket, key }) {
9
+ const client = new S3Client({})
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 sqs = new SQSClient({})
43
+ const abort = new AbortController()
44
+
45
+ const file = (body) => fileSesReceipt(sesReceiptFromBody(body), {
46
+ bucket,
47
+ getObject: readObject,
48
+ invoke: ({ mime, workspaceId }) => createInProcessSdk()
49
+ .forWorkspace(workspaceId)
50
+ .invoke(ReceiveInbound, { mime, workspaceId }),
51
+ })
52
+
53
+ const run = (async () => {
54
+ while (!abort.signal.aborted) {
55
+ try {
56
+ const response = await sqs.send(new ReceiveMessageCommand({
57
+ QueueUrl: queueUrl,
58
+ MaxNumberOfMessages: 5,
59
+ WaitTimeSeconds: 20,
60
+ VisibilityTimeout: 120,
61
+ }), { abortSignal: abort.signal })
62
+ await ackInboundMessages(response.Messages, {
63
+ file,
64
+ log,
65
+ deleteMessage: (message) => sqs.send(new DeleteMessageCommand({
66
+ QueueUrl: queueUrl,
67
+ ReceiptHandle: message.ReceiptHandle,
68
+ })),
69
+ })
70
+ } catch (error) {
71
+ if (abort.signal.aborted) return
72
+ log?.error('[ses/inbound] poll failed', undefined, error)
73
+ await new Promise((resolve) => {
74
+ const timer = setTimeout(resolve, 5000)
75
+ abort.signal.addEventListener('abort', () => {
76
+ clearTimeout(timer)
77
+ resolve()
78
+ }, { once: true })
79
+ })
80
+ }
81
+ }
82
+ })()
83
+
84
+ return async function stopInboundMailPoller () {
85
+ abort.abort()
86
+ await run.catch(() => {})
87
+ }
88
+ }
@@ -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
+ })