chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+18
View File
@@ -0,0 +1,18 @@
# Email integration
## Description
This integration provides Internet Messaging Access Protocol (IMAP) and Simple Messaging Transport Protocol (SMTP) actions to read and send email messages.
The integration **does not** support HTML content yet.
## Getting started
### Configuration
The configuration contains three required fields. Here is an example of config
```yml
user: yourEmailAccount@gmail.com
password: yourAccountPassword
host: imap.gmail.com #for gmail
```
+8
View File
@@ -0,0 +1,8 @@
<svg width="241" height="241" viewBox="0 0 241 241" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="241" height="241" rx="34" fill="#ff4040ff"/>
<path d="M102 112L130.5 95.5L127.5 86L98 103L102 112Z" fill="white"/>
<path d="M133 157.5L98.5 137.5L101 128.5L136 148.5L133 157.5Z" fill="white"/>
<path d="M172.014 179.812L155.014 189.672C152.532 191.112 149.468 191.112 146.986 189.672L129.986 179.812C127.519 178.381 126 175.744 126 172.892V152.608C126 149.756 127.519 147.119 129.986 145.688L146.986 135.828C149.468 134.388 152.532 134.388 155.014 135.828L172.014 145.688C174.481 147.119 176 149.756 176 152.608V172.892C176 175.744 174.481 178.381 172.014 179.812Z" fill="white"/>
<path d="M103 110.108V130.392C103 133.244 101.481 135.881 99.0137 137.312L98.5698 137.57L83.5 129L92 141.38L82.0137 147.172C79.5316 148.612 76.4684 148.612 73.9863 147.172L56.9863 137.312C54.5188 135.881 53 133.244 53 130.392V110.108C53 107.256 54.5188 104.619 56.9863 103.188L73.9863 93.328C76.4684 91.8883 79.5316 91.8883 82.0137 93.328L99.0137 103.188C101.481 104.619 103 107.256 103 110.108Z" fill="white"/>
<path d="M172.014 95.312L155.014 105.172C152.532 106.612 149.468 106.612 146.986 105.172L137.789 99.8376L147.5 85.5L130.425 95.5666L129.986 95.312C127.519 93.8809 126 91.2442 126 88.3918V68.1082C126 65.2558 127.519 62.6191 129.986 61.188L146.986 51.328C149.468 49.8883 152.532 49.8883 155.014 51.328L172.014 61.188C174.481 62.6191 176 65.2558 176 68.1082V88.3918C176 91.2442 174.481 93.8809 172.014 95.312Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,157 @@
import { z, IntegrationDefinition, messages } from '@botpress/sdk'
const emailSchema = z.object({
id: z.string().describe('The unique identifier of the email'),
subject: z.string().describe('The subject line of the email'),
inReplyTo: z.string().optional().describe('The ID of the email this is replying to'),
date: z.string().optional().describe('ISO datetime'),
sender: z.string().describe('The email address of the sender'),
firstMessageId: z.string().optional().describe('The ID of the first message in the conversation thread'),
})
export default new IntegrationDefinition({
name: 'email',
version: '0.1.4',
title: 'Email',
description: 'Send and receive emails using IMAP and SMTP protocols',
readme: 'hub.md',
icon: 'icon.svg',
configuration: {
schema: z
.object({
user: z
.string()
.title('Email Address')
.describe('The email account you want to use to receive and send messages. Example: example@gmail.com'),
password: z.string().title('Account Password').describe('The password to the email account.'),
imapHost: z
.string()
.title('IMAP Host')
.describe('The imap server you want to connect to. Example: imap.gmail.com'),
smtpHost: z
.string()
.title('SMTP Host')
.describe('The smtp server you want to connect to. Example: smtp.gmail.com'),
})
.required(),
},
states: {
lastSyncTimestamp: {
type: 'integration',
schema: z.object({
lastSyncTimestamp: z
.string()
.datetime()
.title('Last Sync Timestamp')
.describe('The timestamp of the last successful email synchronization'),
}),
},
syncLock: {
type: 'integration',
schema: z.object({
currentlySyncing: z
.boolean()
.default(false)
.title('Currently Syncing')
.describe('Indicates whether an email synchronization is currently in progress'),
}),
},
},
actions: {
listEmails: {
title: 'List emails',
description: 'List all emails in the inbox',
input: {
schema: z.object({
nextToken: z
.string()
.optional()
.title('Next Token')
.describe('The page number in the inbox. Starts at 0')
.optional(),
}),
},
output: {
schema: z.object({
messages: z.array(emailSchema).describe('The list of email messages'),
nextToken: z.string().optional().describe('Token for retrieving the next page of results'),
}),
},
},
getEmail: {
title: 'Get emails',
description: 'Get the email with specified id from the inbox',
input: {
schema: z.object({
id: z.string().title('Email ID').describe('The unique identifier of the email to retrieve'),
}),
},
output: {
schema: emailSchema.extend({
body: z.string().optional().describe('The body content of the email'),
}),
},
},
syncEmails: {
title: 'Sync Emails',
description: 'Sends unseen emails as new messages. Call periodically to allow your bot to receive new emails.',
input: { schema: z.object({}) },
output: {
schema: z.object({}),
},
},
sendEmail: {
title: 'Send Email',
description: 'Send an email using SMTP',
input: {
schema: z.object({
to: z.string().title('To').describe('The email address of the recipient'),
subject: z.string().optional().title('Subject').describe('The subject of the outgoing email'),
text: z.string().optional().title('Text').describe('The text contained in the body of the email'),
inReplyTo: z.string().optional().title('In Reply To').describe('The id of the email you want to reply to'),
replyTo: z
.string()
.optional()
.title('Reply To')
.describe(
'The email address to which replies should be sent. This allows recipients to reply to a different address than the sender'
),
}),
},
output: {
schema: z.object({}),
},
},
},
channels: {
default: {
title: 'Email',
description: 'Email channel for sending and receiving emails',
message: { tags: { id: { title: 'Email id', description: 'The email id ' } } },
messages: { text: messages.defaults.text },
conversation: {
tags: {
firstMessageId: {
title: 'First Message Id',
description: 'The id (from the IMAP server) of the first incoming message of the conversation',
},
subject: { title: 'Thread Subject', description: 'Subject for the conversation' },
to: { title: 'Recipient', description: 'Recipient email address for the conversation' },
latestEmail: {
title: 'Email id',
description: 'The id of the latest email received (to put in the In-Reply-To header)',
},
},
},
},
},
user: {
tags: {
email: { title: 'User email', description: 'Required' },
},
},
attributes: {
category: 'Marketing & Email',
repo: 'botpress',
},
})
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@botpresshub/email",
"scripts": {
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"build": "bp build",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"imap": "^0.8.17",
"nodemailer": "^6.7.2"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*",
"@types/imap": "^0.8.42",
"@types/nodemailer": "^6.4.4"
}
}
+131
View File
@@ -0,0 +1,131 @@
import * as sdk from '@botpress/sdk'
import * as imap from './imap'
import * as locking from './locking'
import * as smtp from './smtp'
import * as bp from '.botpress'
const DEFAULT_START_PAGE = 0
const ELEMENTS_PER_PAGE = 50
export const sendEmail: bp.IntegrationProps['actions']['sendEmail'] = async (props) => {
return await smtp.sendNodemailerMail(props.ctx.configuration, props.input, props.logger)
}
export const listEmails: bp.IntegrationProps['actions']['listEmails'] = async (props) => {
const page = parseInt(props.input.nextToken ?? DEFAULT_START_PAGE.toString())
if (page < 0) {
throw new sdk.RuntimeError('The nextToken value cannot be negative')
}
const perPage = ELEMENTS_PER_PAGE
const messages = await imap.getMessages(
{ page, perPage },
{
ctx: props.ctx,
logger: props.logger,
},
{ bodyNeeded: false }
)
return messages
}
export const getEmail: bp.IntegrationProps['actions']['getEmail'] = async (props) => {
const email = await imap.getMessageById(props.input.id, props)
if (!email) throw new sdk.RuntimeError('Could not find an email with corresponding id.')
props.logger.info(`Retrieved email with id ${props.input.id}`)
return email
}
export const syncEmails: bp.IntegrationProps['actions']['syncEmails'] = async (props) => {
props.logger.forBot().info(`Starting sync in the inbox at [${new Date().toISOString()}]`)
await _syncEmails(props, { enableNewMessageNotification: true })
props.logger.forBot().info(`Finished sync in the inbox at [${new Date().toISOString()}]`)
return {}
}
const _syncEmails = async (
props: { ctx: bp.Context; client: bp.Client; logger: bp.Logger },
options: { enableNewMessageNotification: boolean }
) => {
const lock = new locking.LockHandler({ client: props.client, ctx: props.ctx })
const currentlySyncing = await lock.readLock()
if (currentlySyncing) throw new sdk.RuntimeError('The bot is still syncing the messages. Try again later.')
await lock.setLock(true)
const {
state: { payload: lastSyncTimestamp },
} = await props.client.getState({
name: 'lastSyncTimestamp',
id: props.ctx.integrationId,
type: 'integration',
})
const allMessages = await imap.getMessages(
{ page: DEFAULT_START_PAGE, perPage: ELEMENTS_PER_PAGE },
{
ctx: props.ctx,
logger: props.logger,
},
{ bodyNeeded: options.enableNewMessageNotification }
)
for (const message of allMessages.messages) {
if (message.sender === props.ctx.configuration.user) continue
const messageAlreadySeen =
message.date && lastSyncTimestamp && new Date(message.date) <= new Date(lastSyncTimestamp.lastSyncTimestamp)
if (messageAlreadySeen) continue
if (options.enableNewMessageNotification) {
props.logger.forBot().info(`Detecting a new email from '${message.sender}': ${message.subject}`)
await _notifyNewMessage(props, message)
}
}
await props.client.setState({
name: 'lastSyncTimestamp',
id: props.ctx.integrationId,
type: 'integration',
payload: {
lastSyncTimestamp: new Date().toISOString(),
},
})
await lock.setLock(false)
return {}
}
const _notifyNewMessage = async (props: { client: bp.Client; logger: bp.Logger }, message: imap.Email) => {
const { user } = await props.client.getOrCreateUser({
tags: { email: message.sender },
})
const firstMessageId = message.firstMessageId || message.id
const { conversation } = await props.client.getOrCreateConversation({
channel: 'default',
tags: {
firstMessageId,
subject: message.subject,
to: user.tags.email,
latestEmail: message.id,
},
discriminateByTags: ['firstMessageId'],
})
props.logger
.forBot()
.info(
`Retrieved or created conversation with id '${conversation.tags.firstMessageId}' and subject '${conversation.tags.subject}'.`
)
await props.client.createMessage({
conversationId: conversation.id,
userId: user.id,
payload: { text: message.body ?? '' },
tags: { id: message.id },
type: 'text',
})
}
+23
View File
@@ -0,0 +1,23 @@
import { RuntimeError } from '@botpress/sdk'
import * as smtp from './smtp'
import * as bp from '.botpress'
export const defaultChannel = {
messages: {
text: async (props) => {
if (!props.conversation.tags.to) throw new RuntimeError("Tried sending an email without a 'to' header")
await smtp.sendNodemailerMail(
props.ctx.configuration,
{
to: props.conversation.tags.to,
subject: 'Sent from botpress email integration',
text: props.payload.text,
inReplyTo: props.conversation.tags.latestEmail,
replyTo: props.ctx.configuration.user,
},
props.logger
)
},
},
} satisfies bp.IntegrationProps['channels']['default']
+251
View File
@@ -0,0 +1,251 @@
import * as sdk from '@botpress/sdk'
import Imap from 'imap'
import * as paging from './paging'
import * as bp from '.botpress'
type HeaderData = {
id: string
subject: string
inReplyTo: string | undefined
date: string | undefined
sender: string
firstMessageId: string | undefined
}
const INBOX_ERROR_MESSAGE = 'An error occured while opening the inbox'
export type Email = bp.actions.listEmails.output.Output['messages'][0] & { body?: string }
export type EmailResponse = { messages: Array<Email> } & { nextToken?: string }
const _connectToImap = async (props: {
ctx: bp.Context
logger: bp.Logger
}): Promise<{ imap: Imap; box: Imap.Box }> => {
const imap: Imap = new Imap(_getConfig(props.ctx.configuration))
await new Promise<EmailResponse>((resolve, reject) => {
imap.once('ready', resolve)
imap.once('error', (err: Error) => {
reject(new sdk.RuntimeError('An error occured while connecting to the inbox', err))
})
imap.connect()
})
return {
imap,
box: await new Promise<Imap.Box>((resolve, reject) => {
imap.openBox('INBOX', true, (err, box) => {
if (err) {
reject(new sdk.RuntimeError(INBOX_ERROR_MESSAGE, err))
} else {
resolve(box)
}
})
}),
}
}
export const getMessages = async function (
range: { page: number; perPage: number },
props: { ctx: bp.Context; logger: bp.Logger },
options?: { bodyNeeded: boolean }
): Promise<EmailResponse> {
let messages: EmailResponse
let imap: Imap | undefined = undefined
let box: Imap.Box | undefined = undefined
try {
;({ imap, box } = await _connectToImap(props))
if (!imap || !box) throw new sdk.RuntimeError(INBOX_ERROR_MESSAGE)
const imapBodies = ['HEADER']
if (options?.bodyNeeded || !options) {
imapBodies.push('TEXT')
}
if (box.messages.total === 0) return { messages: [] }
const { firstElementIndex, lastElementIndex } = paging.pageToSpan({
page: range.page,
perPage: range.perPage,
totalElements: box.messages.total,
})
const imapRange = `${firstElementIndex}:${lastElementIndex}`
const f: Imap.ImapFetch = imap.seq.fetch(imapRange, {
bodies: imapBodies,
struct: true,
})
const nextToken = paging.getNextToken({ page: range.page, firstElementIndex })?.toString()
messages = { messages: await _handleFetch(imap, f, imapBodies.length), nextToken }
} catch (thrown: unknown) {
if (imap?.state !== 'disconnected') {
imap?.end()
}
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new sdk.RuntimeError(
'An error occured while opening the inbox or fetching messages. Verify the integration configuration parameters.',
err
)
}
props.logger.forBot().info(`Read ${messages.messages.length} messages from the inbox`)
return messages
}
const _handleFetch = function (imap: Imap, f: Imap.ImapFetch, totalParts: number): Promise<Array<Email>> {
const messages: Array<Email> = []
return new Promise((resolve, reject) => {
f.on('message', (msg: Imap.ImapMessage) => {
let headerData: HeaderData
let body: string
msg.on('body', (stream: NodeJS.ReadableStream, info) => {
let buffer = ''
stream.on('data', function (chunk) {
buffer += chunk.toString('utf8')
})
stream.once('end', function () {
if (info.which === 'HEADER') {
headerData = _parseHeader(buffer)
} else if (info.which === 'TEXT') {
body = buffer
}
})
})
let partsProcessed = 0
msg.on('body', (stream: NodeJS.ReadableStream) => {
stream.once('end', () => {
partsProcessed++
if (partsProcessed === totalParts) {
// All parts for this message have been processed
messages.push({
...headerData,
body,
})
}
})
})
})
f.once('error', (err) => {
reject(new sdk.RuntimeError('An error occured while fetching messages', err))
})
f.once('end', function () {
imap.end()
resolve(messages)
})
})
}
export const getMessageById = async function (
messageId: string,
props: { ctx: bp.Context; logger: bp.Logger },
options?: { bodyNeeded: boolean }
): Promise<Email | undefined> {
let imap: Imap | undefined = undefined
try {
;({ imap } = await _connectToImap(props))
if (!imap) throw new sdk.RuntimeError('')
const imapBodies = ['HEADER']
if (options?.bodyNeeded || !options) {
imapBodies.push('TEXT')
}
// Search for the message by message-id
const searchCriteria = [['HEADER', 'MESSAGE-ID', messageId]]
const uids: number[] = await new Promise((resolve, reject) => {
if (!imap) throw new sdk.RuntimeError(INBOX_ERROR_MESSAGE)
imap.search(searchCriteria, (err, results) => {
if (err) {
reject(new sdk.RuntimeError('An error occured while searching for the message', err))
} else {
resolve(results)
}
})
})
if (!uids || uids.length === 0) {
imap.end()
return undefined
}
const f: Imap.ImapFetch = imap.fetch(uids, {
bodies: imapBodies,
struct: true,
})
const messages = await _handleFetch(imap, f, imapBodies.length)
return messages[0]
} catch (thrown: unknown) {
if (imap?.state !== 'disconnected') {
imap?.end()
}
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new sdk.RuntimeError('An error occured while searching for the message by message-id.', err)
}
}
const _getConfig = function (config: bp.configuration.Configuration) {
return {
user: config.user,
password: config.password,
host: config.imapHost,
port: 993,
tls: true,
tlsOptions: { rejectUnauthorized: false },
}
}
function _getStringBetweenAngles(input: string): string | undefined {
const start = input.indexOf('<')
const end = input.indexOf('>')
if (start !== -1 && end !== -1 && end > start) {
return input.substring(start, end + 1)
}
return undefined
}
const _parseHeader = (buffer: string): HeaderData => {
const headerBuffer = buffer
let subject = ''
let sender = ''
let id = ''
let inReplyTo: string | undefined
let firstMessageId: string | undefined
let date: string | undefined
try {
const parsedHeader = Imap.parseHeader(headerBuffer)
subject = (parsedHeader.subject || ['']).join(' ')
sender = (parsedHeader.from || ['']).join(' ')
if (sender.includes('<') && sender.includes('>')) {
sender = sender.substring(sender.indexOf('<') + 1, sender.lastIndexOf('>'))
}
inReplyTo = parsedHeader['in-reply-to']?.[0]
if (!parsedHeader['message-id']?.[0]) {
throw new sdk.RuntimeError('Email message is missing a message-id (uid)')
}
id = parsedHeader['message-id']?.[0]
if (parsedHeader.date && parsedHeader.date.length > 0) {
date = parsedHeader.date[0]
}
const references = parsedHeader['references']?.[0]
if (references) {
firstMessageId = _getStringBetweenAngles(references)
}
} catch (e) {
console.error('Error parsing header:', e)
}
return { date, firstMessageId, id, inReplyTo, sender, subject }
}
+19
View File
@@ -0,0 +1,19 @@
import * as actions from './actions'
import * as channels from './channels'
import * as setup from './setup'
import * as bp from '.botpress'
export default new bp.Integration({
register: setup.register,
unregister: setup.unregister,
actions: {
listEmails: actions.listEmails,
getEmail: actions.getEmail,
syncEmails: actions.syncEmails,
sendEmail: actions.sendEmail,
},
channels: {
default: channels.defaultChannel,
},
handler: async () => {},
})
+25
View File
@@ -0,0 +1,25 @@
import * as bp from '.botpress'
export class LockHandler {
public constructor(private readonly _props: { client: bp.Client; ctx: bp.Context }) {}
public async setLock(value: boolean): Promise<void> {
await this._props.client.getOrSetState({
name: 'syncLock',
id: this._props.ctx.integrationId,
type: 'integration',
payload: {
currentlySyncing: value,
},
})
}
public async readLock(): Promise<boolean> {
const syncLock = await this._props.client.getState({
name: 'syncLock',
id: this._props.ctx.integrationId,
type: 'integration',
})
return syncLock.state.payload.currentlySyncing ?? false
}
}
+43
View File
@@ -0,0 +1,43 @@
import { expect, test } from 'vitest'
import { pageToSpan, Span, getNextToken, NextTokenProps } from './paging'
import * as sdk from '@botpress/sdk'
test('pageToSpan with with zero messages throws', () => {
expect(() => pageToSpan({ page: 0, perPage: 50, totalElements: 0 })).toThrow(sdk.RuntimeError)
})
test('pageToSpan with single element returns single element', () => {
expect(pageToSpan({ page: 0, perPage: 50, totalElements: 1 })).toEqual({
firstElementIndex: 1,
lastElementIndex: 1,
} satisfies Span)
})
test('pageToSpan with partial page returns all elements', () => {
expect(pageToSpan({ page: 0, perPage: 50, totalElements: 13 })).toEqual({
firstElementIndex: 1,
lastElementIndex: 13,
} satisfies Span)
})
test('pageToSpan with full page returns first page', () => {
expect(pageToSpan({ page: 0, perPage: 50, totalElements: 300 })).toEqual({
firstElementIndex: 251,
lastElementIndex: 300,
} satisfies Span)
})
test('pageToSpan with multiple pages on next page returns next page', () => {
expect(pageToSpan({ page: 1, perPage: 50, totalElements: 300 })).toEqual({
firstElementIndex: 201,
lastElementIndex: 250,
} satisfies Span)
})
test('given a page greater than one, getNextToken returns next token', () => {
expect(getNextToken({ page: 0, firstElementIndex: 10 })).toEqual(1)
})
test('given a page equal to one, getNextToken returns undefined', () => {
expect(getNextToken({ page: 0, firstElementIndex: 1 })).toEqual(undefined)
})
+32
View File
@@ -0,0 +1,32 @@
import * as sdk from '@botpress/sdk'
export type PageToSpanProps = {
page: number
perPage: number
totalElements: number
}
export type NextTokenProps = {
page: number
firstElementIndex: number
}
export type Span = {
firstElementIndex: number
lastElementIndex: number
}
export const pageToSpan = (props: PageToSpanProps): Span => {
if (props.totalElements <= 0) {
throw new sdk.RuntimeError('Could not read the inbox: the number of messages in the inbox is 0')
}
const lastElementIndex = Math.max(1, props.totalElements - props.page * props.perPage)
const firstElementIndex = Math.max(1, lastElementIndex - props.perPage + 1)
return { firstElementIndex, lastElementIndex }
}
export const getNextToken = (props: NextTokenProps): number | undefined => {
if (props.firstElementIndex === 1) return undefined
return props.page + 1
}
+29
View File
@@ -0,0 +1,29 @@
import * as sdk from '@botpress/sdk'
import { getMessages } from './imap'
import * as locking from './locking'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async (props) => {
const lock = new locking.LockHandler({ client: props.client, ctx: props.ctx })
await lock.setLock(false)
await props.client.setState({
name: 'lastSyncTimestamp',
id: props.ctx.integrationId,
type: 'integration',
payload: { lastSyncTimestamp: new Date().toISOString() },
})
try {
await getMessages({ page: 0, perPage: 1 }, props)
} catch (thrown: unknown) {
const err = thrown instanceof Error ? thrown : new Error(`${thrown}`)
throw new sdk.RuntimeError(
`An error occured when registering the integration: ${err.message} Verify your configuration.`
)
}
}
export const unregister: bp.IntegrationProps['unregister'] = async () => {
// nothing to unregister
}
+24
View File
@@ -0,0 +1,24 @@
import nodemailer from 'nodemailer'
import * as bp from '.botpress'
export const sendNodemailerMail = async (
config: bp.Context['configuration'],
props: bp.actions.sendEmail.input.Input,
logger: bp.Logger
) => {
const transporter = nodemailer.createTransport({
host: config.smtpHost,
auth: {
user: config.user,
pass: config.password,
},
})
await transporter.sendMail({
from: config.user,
...props,
references: props.inReplyTo,
})
logger.forBot().info(`Sent email with subject '${props.subject}' via SMTP`)
return {}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config