Mailbox fixes (#8406)

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>
This commit is contained in:
Chunosov 2025-04-01 11:41:24 +07:00 committed by GitHub
parent b104585c92
commit a226f9aa0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 243 additions and 63 deletions

View File

@ -12,8 +12,8 @@
"webpack": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development webpack --stats-error-details --progress -w",
"devp": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=production CLIENT_TYPE=dev webpack --progress -w",
"dev": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development webpack --progress -w",
"start": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=production electron .",
"start-dev": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development electron .",
"start": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=production electron --no-sandbox .",
"start-dev": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development electron --no-sandbox .",
"format": "format",
"bump": "bump-package-version"
},

View File

@ -89,6 +89,7 @@ import '@hcengineering/lead-assets'
import '@hcengineering/login-assets'
import '@hcengineering/love-assets'
import '@hcengineering/notification-assets'
import '@hcengineering/my-space-assets'
import '@hcengineering/preference-assets'
import '@hcengineering/print-assets'
import '@hcengineering/process-assets'
@ -361,6 +362,7 @@ export async function configurePlatform (): Promise<void> {
addLocation(mySpaceId, () => import(/* webpackChunkName: "card" */ '@hcengineering/my-space-resources'))
addLocation(chatId, () => import(/* webpackChunkName: "chat" */ '@hcengineering/chat-resources'))
addLocation(inboxId, () => import(/* webpackChunkName: "inbox" */ '@hcengineering/inbox-resources'))
addLocation(mailId, () => import(/* webpackChunkName: "card" */ '@hcengineering/mail-resources'))
addLocation(processId, () => import(/* webpackChunkName: "process" */ '@hcengineering/process-resources'))
setMetadata(client.metadata.FilterModel, 'ui')

View File

@ -124,6 +124,7 @@ import '@hcengineering/view-assets'
import '@hcengineering/workbench-assets'
import '@hcengineering/chat-assets'
import '@hcengineering/inbox-assets'
import '@hcengineering/mail-assets'
import '@hcengineering/github-assets'
import { coreId } from '@hcengineering/core'

View File

@ -93,7 +93,7 @@ export interface AccountClient {
findPersonBySocialId: (socialId: PersonId, requireAccount?: boolean) => Promise<PersonUuid | undefined>
findSocialIdBySocialKey: (socialKey: string) => Promise<PersonId | undefined>
getMailboxOptions: () => Promise<MailboxOptions>
createMailbox: (name: string, domain: string) => Promise<void>
createMailbox: (name: string, domain: string) => Promise<{ mailbox: string, socialId: PersonId }>
getMailboxes: () => Promise<MailboxInfo[]>
deleteMailbox: (mailbox: string) => Promise<void>
@ -653,13 +653,13 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async createMailbox (name: string, domain: string): Promise<void> {
async createMailbox (name: string, domain: string): Promise<{ mailbox: string, socialId: PersonId }> {
const request = {
method: 'createMailbox' as const,
params: { name, domain }
}
await this.rpc(request)
return await this.rpc(request)
}
async getMailboxes (): Promise<MailboxInfo[]> {

View File

@ -14,12 +14,14 @@
-->
<script lang="ts">
import { MailboxOptions } from '@hcengineering/account-client'
import presentation from '@hcengineering/presentation'
import presentation, { getClient } from '@hcengineering/presentation'
import { Dropdown, ListItem, Modal, ModernEditbox, Spinner, themeStore } from '@hcengineering/ui'
import setting from '@hcengineering/setting'
import { createEventDispatcher } from 'svelte'
import { getAccountClient } from '../utils'
import { IntlString, translateCB } from '@hcengineering/platform'
import contact, { getCurrentEmployee, SocialIdentity } from '@hcengineering/contact'
import { buildSocialIdString, Ref, SocialIdType } from '@hcengineering/core'
export let mailboxOptions: MailboxOptions
@ -39,10 +41,42 @@
return n.length >= mailboxOptions.minNameLength && n.length <= mailboxOptions.maxNameLength
}
async function createMailbox (): Promise<void> {
const { mailbox, socialId } = await getAccountClient().createMailbox(name, (domain ?? domains[0])._id)
console.log('Mailbox created', mailbox, socialId)
const currentUser = getCurrentEmployee()
const client = getClient()
await client.addCollection(
contact.class.SocialIdentity,
contact.space.Contacts,
currentUser,
contact.class.Person,
'socialIds',
{
key: buildSocialIdString({ type: SocialIdType.EMAIL, value: mailbox }),
type: SocialIdType.EMAIL,
value: mailbox,
verifiedOn: Date.now()
},
socialId as any as Ref<SocialIdentity>
)
await client.addCollection(
contact.class.Channel,
contact.space.Contacts,
currentUser,
contact.class.Person,
'channels',
{
provider: contact.channelProvider.Email,
value: mailbox
}
)
}
async function save (): Promise<void> {
loading = true
try {
await getAccountClient().createMailbox(name, (domain ?? domains[0])._id)
await createMailbox()
loading = false
dispatch('close', true)
} catch (err: any) {

View File

@ -25,29 +25,19 @@
} from '@hcengineering/ui'
import setting from '@hcengineering/setting'
import { MailboxInfo } from '@hcengineering/account-client'
import { MessageBox } from '@hcengineering/presentation'
import { getClient, MessageBox } from '@hcengineering/presentation'
import { getAccountClient } from '../utils'
import contact, { getCurrentEmployee } from '@hcengineering/contact'
import { buildSocialIdString, SocialIdType } from '@hcengineering/core'
export let mailbox: MailboxInfo
export let mailboxIdx: number
export let loadingRequested: () => void
export let reloadRequested: () => void
let opened = false
function getMenuItems (mailbox: MailboxInfo): (DropdownIntlItem & { action: () => void })[] {
return [
{
id: 'delete',
icon: IconDelete,
label: setting.string.DeleteMailbox,
action: () => {
deleteMailbox(mailbox)
}
}
]
}
function deleteMailbox (mailbox: MailboxInfo): void {
function deleteMailboxAction (): void {
showPopup(
MessageBox,
{
@ -56,24 +46,71 @@
dangerous: true,
okLabel: setting.string.Delete,
action: async () => {
getAccountClient()
.deleteMailbox(mailbox.mailbox)
.then(() => {
reloadRequested()
})
.catch((err: any) => {
console.error('Failed to delete mailbox', err)
})
loadingRequested()
try {
await deleteMailbox()
} catch (err) {
console.error('Failed to delete mailbox', err)
}
reloadRequested()
}
},
undefined
)
}
const openMailboxMenu = (mailbox: MailboxInfo, ev: MouseEvent): void => {
async function deleteMailbox (): Promise<void> {
await getAccountClient().deleteMailbox(mailbox.mailbox)
const client = getClient()
const currentUser = getCurrentEmployee()
const socialIds = await client.findAll(contact.class.SocialIdentity, {
attachedTo: currentUser,
type: SocialIdType.EMAIL,
value: mailbox.mailbox
})
for (const socialId of socialIds) {
const value = `${socialId.value}#${socialId._id}`
await client.updateCollection(
socialId._class,
socialId.space,
socialId._id,
socialId.attachedTo,
socialId.attachedToClass,
socialId.collection,
{
value,
key: buildSocialIdString({ type: SocialIdType.EMAIL, value })
}
)
}
const channels = await client.findAll(contact.class.Channel, {
attachedTo: currentUser,
provider: contact.channelProvider.Email,
value: mailbox.mailbox
})
for (const channel of channels) {
await client.removeCollection(
channel._class,
channel.space,
channel._id,
channel.attachedTo,
channel.attachedToClass,
channel.collection
)
}
}
const openMailboxMenu = (ev: MouseEvent): void => {
if (!opened) {
opened = true
const items = getMenuItems(mailbox)
const items: (DropdownIntlItem & { action: () => void })[] = [
{
id: 'delete',
icon: IconDelete,
label: setting.string.DeleteMailbox,
action: deleteMailboxAction
}
]
showPopup(ModernPopup, { items }, eventToHTMLElement(ev), (result) => {
items.find((it) => it.id === result)?.action()
opened = false
@ -94,9 +131,7 @@
pressed={opened}
inheritColor
hasMenu
on:click={(ev) => {
openMailboxMenu(mailbox, ev)
}}
on:click={openMailboxMenu}
/>
</div>
</div>

View File

@ -119,7 +119,14 @@
{:else}
<Scroller>
{#each mailboxes as mailbox, i}
<MailboxItem {mailbox} mailboxIdx={i} reloadRequested={loadMailboxes} />
<MailboxItem
{mailbox}
mailboxIdx={i}
reloadRequested={loadMailboxes}
loadingRequested={() => {
boxesLoading = true
}}
/>
{/each}
</Scroller>
{/if}

View File

@ -2032,7 +2032,7 @@ async function createMailbox (
name: string
domain: string
}
): Promise<void> {
): Promise<{ mailbox: string, socialId: PersonId }> {
const { account } = decodeTokenVerbose(ctx, token)
const { name, domain } = params
const normalizedName = cleanEmail(name)
@ -2060,7 +2060,14 @@ async function createMailbox (
await db.mailbox.insertOne({ accountUuid: account, mailbox })
await db.mailboxSecret.insertOne({ mailbox, secret: generatePassword() })
await db.socialId.insertOne({ personUuid: account, type: SocialIdType.EMAIL, value: mailbox })
const socialId: PersonId = await db.socialId.insertOne({
personUuid: account,
type: SocialIdType.EMAIL,
value: mailbox,
verifiedOn: Date.now()
})
ctx.info('Mailbox created', { mailbox, account, socialId })
return { mailbox, socialId }
}
async function getMailboxes (
@ -2081,15 +2088,33 @@ async function deleteMailbox (
params: { mailbox: string }
): Promise<void> {
const { account } = decodeTokenVerbose(ctx, token)
const { mailbox } = params
const mailbox = cleanEmail(params.mailbox)
if (!isEmail(mailbox)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'invalid-name' }))
}
const mb = await db.mailbox.findOne({ mailbox, accountUuid: account })
if (mb == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'mailbox-not-found' }))
}
await db.mailbox.deleteMany({ mailbox })
await db.mailboxSecret.deleteMany({ mailbox })
await db.mailbox.deleteMany({ mailbox })
await releaseSocialId(db, account, SocialIdType.EMAIL, mailbox)
ctx.info('Mailbox deleted', { mailbox, account })
}
async function releaseSocialId (
db: AccountDB,
personUuid: PersonUuid,
type: SocialIdType,
value: string
): Promise<void> {
const socialIds = await db.socialId.find({ personUuid, type, value })
for (const socialId of socialIds) {
await db.socialId.updateOne({ _id: socialId._id }, { value: `${socialId.value}#${socialId._id}` })
}
}
export type AccountMethods =

View File

@ -22,6 +22,7 @@ interface Config {
accountsUrl: string
workspaceUrl: string
ignoredAddresses: string[]
hookToken?: string
}
const config: Config = {
@ -34,7 +35,8 @@ const config: Config = {
}
throw Error('WORKSPACE_URL env var is not set')
})(),
ignoredAddresses: process.env.IGNORED_ADDRESSES?.split(',') ?? []
ignoredAddresses: process.env.IGNORED_ADDRESSES?.split(',') ?? [],
hookToken: process.env.HOOK_TOKEN
}
export default config

View File

@ -36,6 +36,13 @@ interface MtaMessage {
export async function handleMtaHook (req: Request, res: Response): Promise<void> {
try {
if (config.hookToken !== undefined) {
const token = req.headers['x-hook-token']
if (token !== config.hookToken) {
throw new Error('Invalid hook token')
}
}
const mta: MtaMessage = req.body
const from = { address: mta.envelope.from.address, name: '' }
@ -119,5 +126,31 @@ async function getContent (mta: MtaMessage): Promise<string> {
function extractContactName (fromHeader: string): string {
// Match name part that appears before an email in angle brackets
const nameMatch = fromHeader.match(/^\s*"?([^"<]+?)"?\s*<.+?>/)
return nameMatch?.[1].trim() ?? ''
const name = nameMatch?.[1].trim() ?? ''
if (name.length > 0) {
return decodeMimeWord(name)
}
return ''
}
function decodeMimeWord (text: string): string {
return text.replace(/=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi, (match, charset, encoding, content) => {
try {
if (encoding.toUpperCase() === 'B') {
// Base64 encoding
const buffer = Buffer.from(content, 'base64')
return buffer.toString(charset as BufferEncoding)
} else if (encoding.toUpperCase() === 'Q') {
// Quoted-printable encoding
const decoded = content
.replace(/_/g, ' ')
.replace(/=([0-9A-F]{2})/gi, (_: any, hex: string) => String.fromCharCode(parseInt(hex, 16)))
return Buffer.from(decoded).toString(charset as BufferEncoding)
}
return match
} catch (error) {
console.warn('Failed to decode encoded word:', match, error)
return match
}
})
}

View File

@ -49,7 +49,7 @@ async function main (): Promise<void> {
const server = app.listen(config.port, () => {
console.log(`server started on port ${config.port}`)
console.log({ ...config, secret: '(stripped)' })
console.log({ ...config, secret: '(stripped)', hookToken: '(stripped)' })
})
const shutdown = (): void => {

View File

@ -29,7 +29,7 @@ import chunter from '@hcengineering/chunter'
import contact, { PersonSpace } from '@hcengineering/contact'
import mail from '@hcengineering/mail'
import config from './config'
import { ensureGlobalPerson } from './person'
import { ensureGlobalPerson, ensureLocalPerson } from './person'
function generateToken (): string {
return encode(
@ -61,6 +61,21 @@ export async function createMessages (
console.error(`[${mailId}] Unable to create message without a proper FROM`)
return
}
try {
await ensureLocalPerson(
client,
mailId,
fromPerson.uuid,
fromPerson.socialId,
from.address,
fromPerson.firstName,
fromPerson.lastName
)
} catch (err) {
console.error(`[${mailId}] Failed to ensure local FROM person`, err)
console.error(`[${mailId}] Unable to create message without a proper FROM`)
return
}
const toPersons: { address: string, uuid: PersonUuid, socialId: PersonId }[] = []
for (const to of tos) {
@ -68,6 +83,20 @@ export async function createMessages (
if (toPerson === undefined) {
continue
}
try {
await ensureLocalPerson(
client,
mailId,
toPerson.uuid,
toPerson.socialId,
to.address,
toPerson.firstName,
toPerson.lastName
)
} catch (err) {
console.error(`[${mailId}] Failed to ensure local TO person, skip`, err)
continue
}
toPersons.push({ address: to.address, ...toPerson })
}
if (toPersons.length === 0) {

View File

@ -12,26 +12,34 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { buildSocialIdString, generateId, PersonId, PersonUuid, SocialIdType, TxOperations } from '@hcengineering/core'
import contact, { AvatarType, combineName } from '@hcengineering/contact'
import {
buildSocialIdString,
generateId,
PersonId,
PersonUuid,
Ref,
SocialIdType,
TxOperations
} from '@hcengineering/core'
import contact, { AvatarType, combineName, SocialIdentity } from '@hcengineering/contact'
import { AccountClient } from '@hcengineering/account-client'
export async function ensureGlobalPerson (
client: AccountClient,
mailId: string,
contact: { address: string, name: string }
): Promise<{ socialId: PersonId, uuid: PersonUuid } | undefined> {
): Promise<{ socialId: PersonId, uuid: PersonUuid, firstName: string, lastName: string } | undefined> {
const [firstName, lastName] = contact.name.split(' ')
const socialKey = buildSocialIdString({ type: SocialIdType.EMAIL, value: contact.address })
const socialId = await client.findSocialIdBySocialKey(socialKey)
const uuid = await client.findPersonBySocialKey(socialKey)
if (socialId !== undefined && uuid !== undefined) {
console.log(`[${mailId}] Found global person for ${contact.address}: ${uuid}`)
return { socialId, uuid }
return { socialId, uuid, firstName, lastName }
}
const [firstName, lastName] = contact.name.split(' ')
try {
const globalPerson = await client.ensurePerson(SocialIdType.EMAIL, contact.address, firstName, lastName)
return globalPerson
console.log(`[${mailId}] Created global person for ${contact.address}: ${globalPerson.uuid}`)
return { ...globalPerson, firstName, lastName }
} catch (err) {
console.error(`[${mailId}] Failed to create global person for ${contact.address}`, err)
}
@ -40,21 +48,14 @@ export async function ensureGlobalPerson (
export async function ensureLocalPerson (
client: TxOperations,
mailId: string,
personUuid: PersonUuid,
socialId: PersonId,
personId: PersonId,
email: string,
firstName: string,
lastName: string
): Promise<void> {
let person = await client.findOne(
contact.class.Person,
{
personUuid
},
{
projection: { name: 1 }
}
)
let person = await client.findOne(contact.class.Person, { personUuid })
if (person === undefined) {
const newPersonId = await client.createDoc(
contact.class.Person,
@ -69,7 +70,16 @@ export async function ensureLocalPerson (
person = await client.findOne(contact.class.Person, { _id: newPersonId })
if (person === undefined) {
throw new Error(`Failed to create local person for ${personUuid}`)
} else {
console.log(`[${mailId}] Created local person for ${personUuid}: ${person._id}`)
}
}
const socialId = await client.findOne(contact.class.SocialIdentity, {
attachedTo: person._id,
type: SocialIdType.EMAIL,
value: email
})
if (socialId === undefined) {
await client.addCollection(
contact.class.SocialIdentity,
contact.space.Contacts,
@ -77,12 +87,13 @@ export async function ensureLocalPerson (
contact.class.Person,
'socialIds',
{
key: socialId,
key: buildSocialIdString({ type: SocialIdType.EMAIL, value: email }),
type: SocialIdType.EMAIL,
value: email
},
generateId()
personId as any as Ref<SocialIdentity>
)
console.log(`[${mailId}] Created local socialId for ${personUuid}: ${email}`)
}
const channel = await client.findOne(contact.class.Channel, {
attachedTo: person._id,
@ -103,5 +114,6 @@ export async function ensureLocalPerson (
},
generateId()
)
console.log(`[${mailId}] Created channel for ${personUuid}: ${email}`)
}
}