feat: abstract nodemailer into email adapter interface
This commit is contained in:
@@ -57,7 +57,7 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise<
|
||||
disableEmail,
|
||||
expiration,
|
||||
req: {
|
||||
payload: { config, emailOptions, sendEmail: email },
|
||||
payload: { config, email },
|
||||
payload,
|
||||
},
|
||||
req,
|
||||
@@ -132,8 +132,8 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise<
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
email({
|
||||
from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`,
|
||||
email.sendEmail({
|
||||
from: `"${email.defaultFromName}" <${email.defaultFromAddress}>`,
|
||||
html,
|
||||
subject,
|
||||
to: data.email,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { URL } from 'url'
|
||||
|
||||
import type { Collection } from '../collections/config/types.js'
|
||||
import type { EmailOptions, SanitizedConfig } from '../config/types.js'
|
||||
import type { SanitizedConfig } from '../config/types.js'
|
||||
import type { EmailAdapter } from '../email/types.js'
|
||||
import type { Payload } from '../index.js'
|
||||
import type { PayloadRequest } from '../types/index.js'
|
||||
import type { User, VerifyConfig } from './types.js'
|
||||
@@ -10,9 +11,8 @@ type Args = {
|
||||
collection: Collection
|
||||
config: SanitizedConfig
|
||||
disableEmail: boolean
|
||||
emailOptions: EmailOptions
|
||||
email: EmailAdapter<any, unknown>
|
||||
req: PayloadRequest
|
||||
sendEmail: Payload['sendEmail']
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
@@ -23,9 +23,8 @@ async function sendVerificationEmail(args: Args): Promise<void> {
|
||||
collection: { config: collectionConfig },
|
||||
config,
|
||||
disableEmail,
|
||||
emailOptions,
|
||||
email,
|
||||
req,
|
||||
sendEmail,
|
||||
token,
|
||||
user,
|
||||
} = args
|
||||
@@ -67,8 +66,8 @@ async function sendVerificationEmail(args: Args): Promise<void> {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
sendEmail({
|
||||
from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`,
|
||||
email.sendEmail({
|
||||
from: `"${email.defaultFromName}" <${email.defaultFromName}>`,
|
||||
html,
|
||||
subject,
|
||||
to: user.email,
|
||||
|
||||
@@ -84,7 +84,7 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
|
||||
fallbackLocale,
|
||||
locale,
|
||||
payload,
|
||||
payload: { config, emailOptions },
|
||||
payload: { config, email },
|
||||
},
|
||||
req,
|
||||
showHiddenFields,
|
||||
@@ -272,9 +272,8 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
|
||||
collection: { config: collectionConfig },
|
||||
config: payload.config,
|
||||
disableEmail: disableVerificationEmail,
|
||||
emailOptions,
|
||||
email: payload.email,
|
||||
req,
|
||||
sendEmail: payload.sendEmail,
|
||||
token: verificationToken,
|
||||
user: result,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { I18nOptions, TFunction } from '@payloadcms/translations'
|
||||
import type { Options as ExpressFileUploadOptions } from 'express-fileupload'
|
||||
import type GraphQL from 'graphql'
|
||||
import type { Transporter } from 'nodemailer'
|
||||
import type SMTPConnection from 'nodemailer/lib/smtp-connection'
|
||||
import type { DestinationStream, LoggerOptions } from 'pino'
|
||||
import type React from 'react'
|
||||
import type { default as sharp } from 'sharp'
|
||||
@@ -18,6 +16,7 @@ import type {
|
||||
SanitizedCollectionConfig,
|
||||
} from '../collections/config/types.js'
|
||||
import type { DatabaseAdapterResult } from '../database/types.js'
|
||||
import type { EmailAdapter } from '../email/types.js'
|
||||
import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js'
|
||||
import type { Payload } from '../index.js'
|
||||
import type { PayloadRequest, Where } from '../types/index.js'
|
||||
@@ -34,12 +33,6 @@ type Prettify<T> = {
|
||||
[K in keyof T]: T[K]
|
||||
} & NonNullable<unknown>
|
||||
|
||||
type Email = {
|
||||
fromAddress: string
|
||||
fromName: string
|
||||
logMockCredentials?: boolean
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
export type Plugin = (config: Config) => Config | Promise<Config>
|
||||
|
||||
@@ -85,36 +78,6 @@ export type GeneratePreviewURL = (
|
||||
options: GeneratePreviewURLOptions,
|
||||
) => Promise<null | string> | null | string
|
||||
|
||||
export type EmailTransport = Email & {
|
||||
transport: Transporter
|
||||
transportOptions?: SMTPConnection.Options
|
||||
}
|
||||
|
||||
export type EmailTransportOptions = Email & {
|
||||
transport?: Transporter
|
||||
transportOptions: SMTPConnection.Options
|
||||
}
|
||||
|
||||
export type EmailOptions = Email | EmailTransport | EmailTransportOptions
|
||||
|
||||
/**
|
||||
* type guard for EmailOptions
|
||||
* @param emailConfig
|
||||
*/
|
||||
export function hasTransport(emailConfig: EmailOptions): emailConfig is EmailTransport {
|
||||
return (emailConfig as EmailTransport).transport !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* type guard for EmailOptions
|
||||
* @param emailConfig
|
||||
*/
|
||||
export function hasTransportOptions(
|
||||
emailConfig: EmailOptions,
|
||||
): emailConfig is EmailTransportOptions {
|
||||
return (emailConfig as EmailTransportOptions).transportOptions !== undefined
|
||||
}
|
||||
|
||||
export type GraphQLInfo = {
|
||||
Mutation: {
|
||||
fields: Record<string, any>
|
||||
@@ -162,13 +125,6 @@ export type InitOptions = {
|
||||
*/
|
||||
disableOnInit?: boolean
|
||||
|
||||
/**
|
||||
* Configuration for Payload's email functionality
|
||||
*
|
||||
* @see https://payloadcms.com/docs/email/overview
|
||||
*/
|
||||
email?: EmailOptions
|
||||
|
||||
/**
|
||||
* A previously instantiated logger instance. Must conform to the PayloadLogger interface which uses Pino
|
||||
* This allows you to bring your own logger instance and let payload use it
|
||||
@@ -571,11 +527,11 @@ export type Config = {
|
||||
/** Default richtext editor to use for richText fields */
|
||||
editor: RichTextAdapter<any, any, any>
|
||||
/**
|
||||
* Email configuration options. This value is overridden by `email` in Payload.init if passed.
|
||||
* Email Adapter
|
||||
*
|
||||
* @see https://payloadcms.com/docs/email/overview
|
||||
*/
|
||||
email?: EmailOptions
|
||||
email?: EmailAdapter<any, unknown>
|
||||
/** Custom REST endpoints */
|
||||
endpoints?: Endpoint[]
|
||||
/**
|
||||
|
||||
129
packages/payload/src/email/adapters/nodemailer/index.ts
Normal file
129
packages/payload/src/email/adapters/nodemailer/index.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/* eslint-disable no-console */
|
||||
import type { SendMailOptions, Transporter } from 'nodemailer'
|
||||
import type SMTPConnection from 'nodemailer/lib/smtp-connection'
|
||||
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
import type { EmailAdapter } from '../../types.js'
|
||||
|
||||
import InvalidConfiguration from '../../../errors/InvalidConfiguration.js'
|
||||
import { emailDefaults } from '../../defaults.js'
|
||||
|
||||
type Email = {
|
||||
defaultFromAddress: string
|
||||
defaultFromName: string
|
||||
logMockCredentials?: boolean
|
||||
}
|
||||
|
||||
type EmailTransportOptions = Email & {
|
||||
transport?: Transporter
|
||||
transportOptions: SMTPConnection.Options
|
||||
}
|
||||
|
||||
export type NodemailerAdapterArgs = Email | EmailTransportOptions
|
||||
export type NodemailerAdapter = EmailAdapter<SendMailOptions, unknown>
|
||||
|
||||
/**
|
||||
* Creates an email adapter using nodemailer
|
||||
*
|
||||
* If no email configuration is provided, an ethereal email test account is returned
|
||||
*/
|
||||
export const createNodemailerAdapter = async (
|
||||
args: NodemailerAdapterArgs,
|
||||
): Promise<NodemailerAdapter> => {
|
||||
const { defaultFromAddress, defaultFromName, transport } = await buildEmail(args)
|
||||
|
||||
const adapter: NodemailerAdapter = {
|
||||
defaultFromAddress,
|
||||
defaultFromName,
|
||||
sendEmail: async (message) => {
|
||||
return await transport.sendMail({
|
||||
from: `${defaultFromName} <${defaultFromAddress}>`,
|
||||
...message,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return adapter
|
||||
}
|
||||
|
||||
async function buildEmail(
|
||||
emailConfig?: NodemailerAdapterArgs,
|
||||
): Promise<{ defaultFromAddress: string; defaultFromName: string; transport: Transporter }> {
|
||||
if (!emailConfig) {
|
||||
return {
|
||||
defaultFromAddress: emailDefaults.defaultFromAddress,
|
||||
defaultFromName: emailDefaults.defaultFromName,
|
||||
transport: await createMockAccount(emailConfig),
|
||||
}
|
||||
}
|
||||
|
||||
ensureConfigHasFrom(emailConfig)
|
||||
|
||||
// Create or extract transport
|
||||
let transport: Transporter
|
||||
if ('transport' in emailConfig && emailConfig.transport) {
|
||||
;({ transport } = emailConfig)
|
||||
} else if ('transportOptions' in emailConfig && emailConfig.transportOptions) {
|
||||
transport = nodemailer.createTransport(emailConfig.transportOptions)
|
||||
} else {
|
||||
transport = await createMockAccount(emailConfig)
|
||||
}
|
||||
|
||||
await verifyTransport(transport)
|
||||
return {
|
||||
defaultFromAddress: emailConfig.defaultFromAddress,
|
||||
defaultFromName: emailConfig.defaultFromName,
|
||||
transport,
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyTransport(transport: Transporter) {
|
||||
try {
|
||||
await transport.verify()
|
||||
} catch (err: unknown) {
|
||||
console.error({ err, msg: 'Error verifying Nodemailer transport.' })
|
||||
}
|
||||
}
|
||||
|
||||
const ensureConfigHasFrom = (emailConfig: NodemailerAdapterArgs) => {
|
||||
if (!emailConfig?.defaultFromName || !emailConfig?.defaultFromAddress) {
|
||||
throw new InvalidConfiguration(
|
||||
'Email fromName and fromAddress must be configured when transport is configured',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use ethereal.email to create a mock email account
|
||||
*/
|
||||
async function createMockAccount(emailConfig: NodemailerAdapterArgs) {
|
||||
try {
|
||||
const etherealAccount = await nodemailer.createTestAccount()
|
||||
|
||||
const smtpOptions = {
|
||||
...emailConfig,
|
||||
auth: {
|
||||
pass: etherealAccount.pass,
|
||||
user: etherealAccount.user,
|
||||
},
|
||||
fromAddress: emailConfig?.defaultFromAddress,
|
||||
fromName: emailConfig?.defaultFromName,
|
||||
host: 'smtp.ethereal.email',
|
||||
port: 587,
|
||||
secure: false,
|
||||
}
|
||||
const transport = nodemailer.createTransport(smtpOptions)
|
||||
const { pass, user, web } = etherealAccount
|
||||
|
||||
if (emailConfig?.logMockCredentials) {
|
||||
console.info('E-mail configured with mock configuration')
|
||||
console.info(`Log into mock email provider at ${web}`)
|
||||
console.info(`Mock email account username: ${user}`)
|
||||
console.info(`Mock email account password: ${pass}`)
|
||||
}
|
||||
return transport
|
||||
} catch (err) {
|
||||
console.error({ err, msg: 'There was a problem setting up the mock email handler' })
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { Transporter } from 'nodemailer'
|
||||
import type { Logger } from 'pino'
|
||||
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
import type { EmailOptions, EmailTransport } from '../config/types.js'
|
||||
import type { BuildEmailResult, MockEmailHandler } from './types.js'
|
||||
|
||||
import { hasTransport, hasTransportOptions } from '../config/types.js'
|
||||
import { InvalidConfiguration } from '../errors/index.js'
|
||||
import mockHandler from './mockHandler.js'
|
||||
|
||||
async function handleTransport(
|
||||
transport: Transporter,
|
||||
email: EmailTransport,
|
||||
logger: Logger,
|
||||
): BuildEmailResult {
|
||||
try {
|
||||
await transport.verify()
|
||||
} catch (err) {
|
||||
logger.error(`There is an error with the email configuration you have provided. ${err}`)
|
||||
}
|
||||
|
||||
return { ...email, transport }
|
||||
}
|
||||
|
||||
const ensureConfigHasFrom = (emailConfig: EmailOptions) => {
|
||||
if (!emailConfig?.fromName || !emailConfig?.fromAddress) {
|
||||
throw new InvalidConfiguration(
|
||||
'Email fromName and fromAddress must be configured when transport is configured',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMockAccount = async (emailConfig: EmailOptions, logger: Logger) => {
|
||||
let mockAccount: MockEmailHandler
|
||||
try {
|
||||
mockAccount = await mockHandler(emailConfig)
|
||||
const {
|
||||
account: { pass, user, web },
|
||||
} = mockAccount
|
||||
if (emailConfig?.logMockCredentials) {
|
||||
logger.info('E-mail configured with mock configuration')
|
||||
logger.info(`Log into mock email provider at ${web}`)
|
||||
logger.info(`Mock email account username: ${user}`)
|
||||
logger.info(`Mock email account password: ${pass}`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, msg: 'There was a problem setting up the mock email handler' })
|
||||
}
|
||||
return mockAccount
|
||||
}
|
||||
|
||||
export default async function buildEmail(
|
||||
emailConfig: EmailOptions | undefined,
|
||||
logger: Logger,
|
||||
): BuildEmailResult {
|
||||
if (!emailConfig) {
|
||||
return handleMockAccount(emailConfig, logger)
|
||||
}
|
||||
|
||||
if (hasTransport(emailConfig) && emailConfig.transport) {
|
||||
ensureConfigHasFrom(emailConfig)
|
||||
const email = { ...emailConfig }
|
||||
const { transport }: { transport: Transporter } = emailConfig
|
||||
return handleTransport(transport, email, logger)
|
||||
}
|
||||
|
||||
if (hasTransportOptions(emailConfig) && emailConfig.transportOptions) {
|
||||
ensureConfigHasFrom(emailConfig)
|
||||
const email = { ...emailConfig } as EmailTransport
|
||||
const transport = nodemailer.createTransport(emailConfig.transportOptions)
|
||||
return handleTransport(transport, email, logger)
|
||||
}
|
||||
|
||||
return handleMockAccount(emailConfig, logger)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { EmailOptions } from '../config/types.js'
|
||||
import type { EmailAdapter } from './types.js'
|
||||
|
||||
export const defaults: EmailOptions = {
|
||||
fromAddress: 'info@payloadcms.com',
|
||||
fromName: 'Payload',
|
||||
export const emailDefaults: Pick<
|
||||
EmailAdapter<any, unknown>,
|
||||
'defaultFromAddress' | 'defaultFromName'
|
||||
> = {
|
||||
defaultFromAddress: 'info@payloadcms.com',
|
||||
defaultFromName: 'Payload',
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
import type { EmailOptions } from '../config/types.js'
|
||||
import type { MockEmailHandler } from './types.js'
|
||||
|
||||
import { defaults as emailDefaults } from './defaults.js'
|
||||
|
||||
const mockEmailHandler = async (emailConfig: EmailOptions): Promise<MockEmailHandler> => {
|
||||
const testAccount = await nodemailer.createTestAccount()
|
||||
|
||||
const smtpOptions = {
|
||||
...emailConfig,
|
||||
auth: {
|
||||
pass: testAccount.pass,
|
||||
user: testAccount.user,
|
||||
},
|
||||
fromAddress: emailConfig?.fromAddress || emailDefaults.fromAddress,
|
||||
fromName: emailConfig?.fromName || emailDefaults.fromName,
|
||||
host: 'smtp.ethereal.email',
|
||||
port: 587,
|
||||
secure: false,
|
||||
}
|
||||
|
||||
return {
|
||||
account: testAccount,
|
||||
transport: nodemailer.createTransport(smtpOptions),
|
||||
}
|
||||
}
|
||||
|
||||
export default mockEmailHandler
|
||||
@@ -1,13 +1,36 @@
|
||||
import type { SendMailOptions } from 'nodemailer'
|
||||
|
||||
export default async function sendEmail(message: SendMailOptions): Promise<unknown> {
|
||||
import type { Payload } from '../types/index.js'
|
||||
|
||||
export async function sendEmail(this: Payload, message: SendMailOptions): Promise<unknown> {
|
||||
let result
|
||||
|
||||
try {
|
||||
const email = await this.email
|
||||
result = await email.transport.sendMail(message)
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Failed to send mail to ${message.to}, subject: ${message.subject}`)
|
||||
result = await this.email.sendEmail(message)
|
||||
} catch (err: unknown) {
|
||||
let stringifiedTo: string | undefined
|
||||
|
||||
if (typeof message.to === 'string') {
|
||||
stringifiedTo = message.to
|
||||
} else if (Array.isArray(message.to)) {
|
||||
stringifiedTo = message.to
|
||||
.map((to) => {
|
||||
if (typeof to === 'string') {
|
||||
return to
|
||||
} else if (to.address) {
|
||||
return to.address
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.join(', ')
|
||||
} else if (message.to.address) {
|
||||
stringifiedTo = message.to.address
|
||||
}
|
||||
|
||||
this.logger.error({
|
||||
err,
|
||||
msg: `Failed to send mail to ${stringifiedTo}, subject: ${message.subject ?? 'No Subject'}`,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import type { TestAccount, Transporter } from 'nodemailer'
|
||||
import type Mail from 'nodemailer/lib/mailer'
|
||||
import type SMTPConnection from 'nodemailer/lib/smtp-connection'
|
||||
import type { SendMailOptions } from 'nodemailer'
|
||||
|
||||
export type Message = {
|
||||
from: string
|
||||
html: string
|
||||
subject: string
|
||||
to: string
|
||||
export type EmailAdapter<
|
||||
TSendEmailOptions extends SendMailOptions,
|
||||
KSendEmailResponse = unknown,
|
||||
> = {
|
||||
defaultFromAddress: string
|
||||
defaultFromName: string
|
||||
sendEmail: (message: TSendEmailOptions) => Promise<KSendEmailResponse>
|
||||
}
|
||||
|
||||
export type MockEmailHandler = { account: TestAccount; transport: Transporter }
|
||||
export type BuildEmailResult = Promise<
|
||||
| {
|
||||
fromAddress: string
|
||||
fromName: string
|
||||
transport: Mail
|
||||
transportOptions?: SMTPConnection.Options
|
||||
}
|
||||
| MockEmailHandler
|
||||
>
|
||||
|
||||
@@ -33,9 +33,9 @@ import type {
|
||||
ManyOptions as UpdateManyOptions,
|
||||
Options as UpdateOptions,
|
||||
} from './collections/operations/local/update.js'
|
||||
import type { EmailOptions, InitOptions, SanitizedConfig } from './config/types.js'
|
||||
import type { InitOptions, SanitizedConfig } from './config/types.js'
|
||||
import type { BaseDatabaseAdapter, PaginatedDocs } from './database/types.js'
|
||||
import type { BuildEmailResult } from './email/types.js'
|
||||
import type { EmailAdapter } from './email/types.js'
|
||||
import type { TypeWithID as GlobalTypeWithID, Globals } from './globals/config/types.js'
|
||||
import type { Options as FindGlobalOptions } from './globals/operations/local/findOne.js'
|
||||
import type { Options as FindGlobalVersionByIDOptions } from './globals/operations/local/findVersionByID.js'
|
||||
@@ -49,9 +49,9 @@ import { APIKeyAuthentication } from './auth/strategies/apiKey.js'
|
||||
import { JWTAuthentication } from './auth/strategies/jwt.js'
|
||||
import localOperations from './collections/operations/local/index.js'
|
||||
import { validateSchema } from './config/validate.js'
|
||||
import buildEmail from './email/build.js'
|
||||
import { defaults as emailDefaults } from './email/defaults.js'
|
||||
import sendEmail from './email/sendEmail.js'
|
||||
import { createNodemailerAdapter } from './email/adapters/nodemailer/index.js'
|
||||
import { emailDefaults } from './email/defaults.js'
|
||||
import { sendEmail } from './email/sendEmail.js'
|
||||
import { fieldAffectsData } from './exports/types.js'
|
||||
import localGlobalOperations from './globals/operations/local/index.js'
|
||||
import flattenFields from './utilities/flattenTopLevelFields.js'
|
||||
@@ -104,9 +104,8 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
|
||||
return duplicate<T>(this, options)
|
||||
}
|
||||
|
||||
email: BuildEmailResult
|
||||
|
||||
emailOptions: EmailOptions
|
||||
// Do these types need to be injected via GeneratedTypes?
|
||||
email: EmailAdapter<any, unknown>
|
||||
|
||||
// TODO: re-implement or remove?
|
||||
// errorHandler: ErrorHandler
|
||||
@@ -367,17 +366,10 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
|
||||
await this.db.connect()
|
||||
}
|
||||
|
||||
// Configure email service
|
||||
const emailOptions = options.email ? { ...options.email } : this.config.email
|
||||
if (options.email && this.config.email) {
|
||||
this.logger.warn(
|
||||
'Email options provided in both init options and config. Using init options.',
|
||||
)
|
||||
}
|
||||
// TODO: Move nodemailer adapter into separate package after verifying all existing functionality
|
||||
this.email = await createNodemailerAdapter(emailDefaults)
|
||||
|
||||
this.emailOptions = emailOptions ?? emailDefaults
|
||||
this.email = buildEmail(this.emailOptions, this.logger)
|
||||
this.sendEmail = sendEmail.bind(this)
|
||||
this.sendEmail = this.email.sendEmail
|
||||
|
||||
serverInitTelemetry(this)
|
||||
|
||||
|
||||
@@ -31,11 +31,6 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
})
|
||||
|
||||
const email = await payload.sendEmail({
|
||||
to: 'test@example.com',
|
||||
subject: 'This was sent on init',
|
||||
})
|
||||
|
||||
// Create image
|
||||
const imageFilePath = path.resolve(dirname, '../uploads/image.png')
|
||||
const imageFile = await getFileByPath(imageFilePath)
|
||||
|
||||
Reference in New Issue
Block a user