import { Context } from 'hono'; import { Jwt } from 'hono/utils/jwt' import { WorkerMailerOptions } from 'worker-mailer'; import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains, getDomainMapValue, isDomainOrSubdomain, normalizeDomains, trimLower } from './utils'; import { unbindTelegramByAddress } from './telegram_api/common'; import { CONSTANTS } from './constants'; import { AddressCreationSettings, AdminWebhookSettings, ExtractResult, WebhookMail, WebhookSettings } from './models'; import i18n from './i18n'; const DEFAULT_NAME_REGEX = /[^a-z0-9]/g; const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8; const MAX_RANDOM_SUBDOMAIN_ATTEMPTS = 5; const MAX_DOMAIN_LENGTH = 253; const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const normalizeDomainValue = (domain: string): string => { return trimLower(domain); } const isValidDomainLabel = (label: string): boolean => { return DOMAIN_LABEL_RE.test(label); } const areValidDomainLabels = (labels: string[]): boolean => { return labels.length > 0 && labels.every((label) => isValidDomainLabel(label)); } /** * Check if send mail is enabled for a specific domain */ export const isSendMailEnabled = ( c: Context, mailDomain: string ): boolean => { // Check resend token for domain or global const resendEnabled = c.env.RESEND_TOKEN || c.env[ `RESEND_TOKEN_${mailDomain.replace(/\./g, "_").toUpperCase()}` ]; if (resendEnabled) return true; // Check SMTP config for domain const smtpConfigMap = getJsonObjectValue>(c.env.SMTP_CONFIG); if (getDomainMapValue(smtpConfigMap, mailDomain)) return true; // Check SEND_MAIL binding if (isSendMailBindingEnabled(c, mailDomain)) return true; return false; } export const isSendMailBindingEnabled = ( c: Context, mailDomain: string ): boolean => { if (!c.env.SEND_MAIL) { return false; } const sendMailDomains = normalizeDomains(getStringArray(c.env.SEND_MAIL_DOMAINS)); if (sendMailDomains.length === 0) { return true; } return sendMailDomains.includes(normalizeDomainValue(mailDomain)); } /** * Check if send mail is enabled for any configured domain */ export const isAnySendMailEnabled = (c: Context): boolean => { const domains = getDomains(c); return domains.some(domain => isSendMailEnabled(c, domain)); } export const generateRandomName = (c: Context): string => { // name min length min 1 const minLength = Math.max( getIntValue(c.env.MIN_ADDRESS_LEN, 1), 1 ); // name max length min 1 const maxLength = Math.max( getIntValue(c.env.MAX_ADDRESS_LEN, 30), 1 ); // Build full name recursively until minimum length is reached const buildName = (currentName: string = ""): string => { return currentName.length >= minLength ? currentName : buildName(currentName + Math.random().toString(36).substring(2, 15)); }; const fullName = buildName(); // Return truncated to max length return fullName.substring(0, Math.min(fullName.length, maxLength)); }; const generateRandomSubdomain = (c: Context): string => { const charset = "abcdefghijklmnopqrstuvwxyz0123456789"; const length = Math.min( Math.max(getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, DEFAULT_RANDOM_SUBDOMAIN_LENGTH), 1), 63 ); let subdomain = ""; for (let i = 0; i < length; i++) { subdomain += charset.charAt(Math.floor(Math.random() * charset.length)); } return subdomain; } const allowRandomSubdomainForDomain = ( c: Context, domain: string ): boolean => { const normalizedDomain = normalizeDomainValue(domain); return getRandomSubdomainDomains(c) .map((item) => normalizeDomainValue(item)) .includes(normalizedDomain); } const isCreateAddressSubdomainMatchEnvConfigured = (c: Context): boolean => { return c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== undefined && c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== null && c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== ""; } export const getAddressCreationSettings = async ( c: Context ): Promise => { const value = await getJsonSetting( c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY ); return new AddressCreationSettings(value); } export const getAddressCreationSubdomainMatchStatus = async ( c: Context, existingSettings?: AddressCreationSettings ): Promise<{ envConfigured: boolean, envEnabled: boolean, storedEnabled: boolean | undefined, effectiveEnabled: boolean, }> => { const envConfigured = isCreateAddressSubdomainMatchEnvConfigured(c); const envEnabled = getBooleanValue(c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH); const addressCreationSettings = existingSettings || await getAddressCreationSettings(c); const storedEnabled = addressCreationSettings.enableSubdomainMatch; // 业务约束:env=false 作为全局 kill switch,后台开关不能强行打开。 const effectiveEnabled = envConfigured && !envEnabled ? false : typeof storedEnabled === "boolean" ? storedEnabled : envEnabled; return { envConfigured, envEnabled, storedEnabled, effectiveEnabled, }; } const findMatchedAllowedDomain = ( domain: string, allowDomains: string[], enableSubdomainMatch: boolean, ): string | null => { const normalizedDomain = normalizeDomainValue(domain); if (normalizedDomain.length > MAX_DOMAIN_LENGTH) { return null; } const domainLabels = normalizedDomain.split('.'); if (!areValidDomainLabels(domainLabels)) { return null; } const normalizedAllowDomains = allowDomains.map((allowDomain) => normalizeDomainValue(allowDomain)); if (normalizedAllowDomains.includes(normalizedDomain)) { return normalizedDomain; } if (!enableSubdomainMatch) { return null; } const matchedDomain = [...normalizedAllowDomains] .sort((a, b) => b.length - a.length) .find((allowDomain) => { if (allowDomain.length > MAX_DOMAIN_LENGTH) { return false; } const allowDomainLabels = allowDomain.split('.'); if (!areValidDomainLabels(allowDomainLabels)) { return false; } if (domainLabels.length <= allowDomainLabels.length) { return false; } const prefixLabels = domainLabels.slice(0, domainLabels.length - allowDomainLabels.length); if (!areValidDomainLabels(prefixLabels)) { return false; } return allowDomainLabels.every((label, index) => { return domainLabels[domainLabels.length - allowDomainLabels.length + index] === label; }); }); return matchedDomain || null; } const checkNameRegex = (c: Context, name: string) => { let error = null; try { const regexStr = getStringValue(c.env.ADDRESS_CHECK_REGEX); if (!regexStr) return; const regex = new RegExp(regexStr); if (!regex.test(name)) { error = new Error(`Name not match regex: /${regexStr}/`); } } catch (e) { console.error("Failed to check address regex", e); } if (error) { throw error; } } const getNameRegex = (c: Context): RegExp => { try { const regex = getStringValue(c.env.ADDRESS_REGEX); if (!regex) { return DEFAULT_NAME_REGEX; } return new RegExp(regex, 'g'); } catch (e) { console.error("Failed to get address regex", e); } return DEFAULT_NAME_REGEX; } export function updateAddressUpdatedAt( c: Context, address: string | undefined | null ): void { if (!address) { return; } // update address updated_at asynchronously c.executionCtx.waitUntil((async () => { try { await c.env.DB.prepare( `UPDATE address SET updated_at = datetime('now')` + ` WHERE name = ?` + ` AND (updated_at IS NULL OR updated_at < datetime('now', '-1 day'))` ).bind(address).run(); } catch (e) { const errorName = e instanceof Error ? e.name : "UnknownError"; console.warn("[updateAddressUpdatedAt] failed:", errorName); } })()); } export function updateUserAddressesUpdatedAt( c: Context, userId: number | string | undefined | null ): void { if (!userId) { return; } c.executionCtx.waitUntil((async () => { try { await c.env.DB.prepare( `UPDATE address SET updated_at = datetime('now')` + ` WHERE id IN (SELECT address_id FROM users_address WHERE user_id = ?)` + ` AND (updated_at IS NULL OR updated_at < datetime('now', '-1 day'))` ).bind(userId).run(); } catch (e) { const errorName = e instanceof Error ? e.name : "UnknownError"; console.warn("[updateUserAddressesUpdatedAt] failed:", errorName); } })()); } export const generateRandomPassword = (): string => { const charset = "abcdefghijklmnopqrstuvwxyz0123456789"; let password = ""; for (let i = 0; i < 8; i++) { password += charset.charAt(Math.floor(Math.random() * charset.length)); } return password; } const generatePasswordForAddress = async ( c: Context, address: string ): Promise => { if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) { return null; } const plainPassword = generateRandomPassword(); const hashedPassword = await hashPassword(plainPassword); const { success } = await c.env.DB.prepare( `UPDATE address SET password = ?, updated_at = datetime('now') WHERE name = ?` ).bind(hashedPassword, address).run(); if (!success) { console.warn("Failed to set generated password for address:", address); return null; } return plainPassword; } const insertAddressRecord = async ( c: Context, address: string, sourceMeta: string | undefined | null, msgs: ReturnType ): Promise => { try { const result = await c.env.DB.prepare( `INSERT INTO address(name, source_meta) VALUES(?, ?)` ).bind(address, sourceMeta).run(); if (!result.success) { throw new Error(msgs.FailedCreateAddressMsg) } } catch (e) { const message = (e as Error).message; // Fallback: source_meta field may not exist, try without it if (message && message.includes("source_meta")) { const result = await c.env.DB.prepare( `INSERT INTO address(name) VALUES(?)` ).bind(address).run(); if (!result.success) { throw new Error(msgs.FailedCreateAddressMsg) } return; } throw e; } } export const newAddress = async ( c: Context, { name, domain, enablePrefix, enableRandomSubdomain = false, checkLengthByConfig = true, addressPrefix = null, checkAllowDomains = true, enableCheckNameRegex = true, sourceMeta = null, }: { name: string, domain: string | undefined | null, enablePrefix: boolean, enableRandomSubdomain?: boolean, checkLengthByConfig?: boolean, addressPrefix?: string | undefined | null, checkAllowDomains?: boolean, enableCheckNameRegex?: boolean, sourceMeta?: string | undefined | null, } ): Promise<{ address: string, jwt: string, password?: string | null, address_id: number }> => { const msgs = i18n.getMessagesbyContext(c); // trim whitespace and remove special characters name = name.trim().replace(getNameRegex(c), '') // check name if (enableCheckNameRegex) { await checkNameBlockList(c, name); checkNameRegex(c, name); } // name min length min 1 const minAddressLength = Math.max( checkLengthByConfig ? getIntValue(c.env.MIN_ADDRESS_LEN, 1) : 1, 1 ); // name max length min 1 const maxAddressLength = Math.max( checkLengthByConfig ? getIntValue(c.env.MAX_ADDRESS_LEN, 30) : 30, 1 ); // check name length if (name.length < minAddressLength) { throw new Error(`${msgs.NameTooShortMsg} (min ${minAddressLength})`); } if (name.length > maxAddressLength) { throw new Error(`${msgs.NameTooLongMsg} (max ${maxAddressLength})`); } // create address with prefix if (typeof addressPrefix === "string") { name = trimLower(addressPrefix) + name; } else if (enablePrefix) { name = trimLower(c.env.PREFIX) + name; } // check domain const allowDomains = checkAllowDomains ? await getAllowDomains(c) : getDomains(c); // if domain is not set, select domain based on environment configuration if (!domain && allowDomains.length > 0) { const createAddressDefaultDomainFirst = getBooleanValue(c.env.CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST); if (createAddressDefaultDomainFirst) { domain = normalizeDomainValue(allowDomains[0]); } else { domain = normalizeDomainValue(allowDomains[Math.floor(Math.random() * allowDomains.length)]); } } else if (typeof domain === "string") { domain = normalizeDomainValue(domain); } const { effectiveEnabled: enableSubdomainMatch } = await getAddressCreationSubdomainMatchStatus(c); const allowManualSubdomain = domain ? allowDomains.some((baseDomain) => allowRandomSubdomainForDomain(c, baseDomain) && isDomainOrSubdomain(domain, baseDomain)) : false; const matchedAllowDomain = domain ? findMatchedAllowedDomain(domain, allowDomains, enableSubdomainMatch || allowManualSubdomain) : null; // check domain is valid if (!domain || !matchedAllowDomain) { throw new Error(msgs.InvalidDomainMsg) } if (enableRandomSubdomain && !allowRandomSubdomainForDomain(c, domain)) { throw new Error(msgs.RandomSubdomainNotAllowedMsg) } const maxAttempts = enableRandomSubdomain ? MAX_RANDOM_SUBDOMAIN_ATTEMPTS : 1; for (let attempt = 0; attempt < maxAttempts; attempt++) { const addressDomain = enableRandomSubdomain ? `${generateRandomSubdomain(c)}.${domain}` : domain; const address = `${name}@${addressDomain}`; try { await insertAddressRecord(c, address, sourceMeta, msgs); await updateAddressUpdatedAt(c, address); const address_id = await c.env.DB.prepare( `SELECT id FROM address where name = ?` ).bind(address).first("id"); if (!address_id) { throw new Error(msgs.FailedCreateAddressMsg); } // 如果启用地址密码功能,自动生成密码 const generatedPassword = await generatePasswordForAddress(c, address); // create jwt const jwt = await Jwt.sign({ address: address, address_id: address_id }, c.env.JWT_SECRET, "HS256") return { jwt: jwt, address: address, password: generatedPassword, address_id: address_id, } } catch (e) { const message = (e as Error).message; if (message && message.includes("UNIQUE")) { if (enableRandomSubdomain && attempt < maxAttempts - 1) { continue; } throw new Error(msgs.AddressAlreadyExistsMsg) } throw new Error(msgs.FailedCreateAddressMsg) } } throw new Error(msgs.FailedCreateAddressMsg) } const checkNameBlockList = async ( c: Context, name: string ): Promise => { // check name block list const blockList = [] as string[]; try { const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY); blockList.push(...(value || [])); } catch (error) { console.error(error); } if (blockList.some((item) => name.includes(item))) { throw new Error(`Name[${name}]is blocked`); } } export const cleanup = async ( c: Context, cleanType: string | undefined | null, cleanDays: number | undefined | null ): Promise => { const msgs = i18n.getMessagesbyContext(c); if (!cleanType || typeof cleanDays !== 'number' || cleanDays < 0 || cleanDays > 1000) { throw new Error(msgs.InvalidCleanupConfigMsg) } let cleanupBatchSize = getIntValue(c.env.CLEANUP_BATCH_SIZE, 3000); if (!Number.isInteger(cleanupBatchSize) || cleanupBatchSize < 1 || cleanupBatchSize > 5000) { cleanupBatchSize = 3000; } console.log(`Cleanup ${cleanType} before ${cleanDays} days`); switch (cleanType) { case "inactiveAddress": await batchDeleteAddressWithData( c, `id IN (` + `SELECT id FROM address WHERE updated_at < datetime('now', '-${cleanDays} day') ` + `ORDER BY updated_at, id LIMIT ${cleanupBatchSize})` ) break; case "addressCreated": await batchDeleteAddressWithData( c, `id IN (` + `SELECT id FROM address WHERE created_at < datetime('now', '-${cleanDays} day') ` + `ORDER BY created_at, id LIMIT ${cleanupBatchSize})` ) break; case "unboundAddress": await batchDeleteAddressWithData( c, `id NOT IN (SELECT address_id FROM users_address) AND created_at < datetime('now', '-${cleanDays} day')` ) break; case "mails": await c.env.DB.prepare(` DELETE FROM raw_mails WHERE id IN ( SELECT id FROM raw_mails WHERE created_at < datetime('now', ?) ORDER BY created_at, id LIMIT ? )` ).bind(`-${cleanDays} day`, cleanupBatchSize).run(); break; case "mails_unknow": await c.env.DB.prepare(` DELETE FROM raw_mails WHERE address NOT IN (select name from address) AND created_at < datetime('now', '-${cleanDays} day')` ).run(); break; case "sendbox": await c.env.DB.prepare(` DELETE FROM sendbox WHERE id IN ( SELECT id FROM sendbox WHERE created_at < datetime('now', ?) ORDER BY created_at, id LIMIT ? )` ).bind(`-${cleanDays} day`, cleanupBatchSize).run(); break; case "emptyAddress": // Delete addresses that have no emails and were created more than N days ago await batchDeleteAddressWithData( c, `name NOT IN (SELECT DISTINCT address FROM raw_mails WHERE address IS NOT NULL) AND created_at < datetime('now', '-${cleanDays} day')` ) break; default: throw new Error(msgs.InvalidCleanTypeMsg) } return true; } const batchDeleteAddressWithData = async ( c: Context, addressQueryCondition: string, ): Promise => { await c.env.DB.prepare( `DELETE FROM raw_mails WHERE address IN ( ` + `SELECT name FROM address WHERE ${addressQueryCondition})` ).run(); await c.env.DB.prepare( `DELETE FROM sendbox WHERE address IN ( ` + `SELECT name FROM address WHERE ${addressQueryCondition})` ).run(); await c.env.DB.prepare( `DELETE FROM auto_reply_mails WHERE address IN ( ` + `SELECT name FROM address WHERE ${addressQueryCondition})` ).run(); await c.env.DB.prepare( `DELETE FROM address_sender WHERE address IN ( ` + `SELECT name FROM address WHERE ${addressQueryCondition})` ).run(); await c.env.DB.prepare( `DELETE FROM users_address WHERE address_id IN ( ` + `SELECT id FROM address WHERE ${addressQueryCondition})` ).run(); // delete address await c.env.DB.prepare(` DELETE FROM address WHERE ${addressQueryCondition}` ).run(); return true; } export const deleteAddressWithData = async ( c: Context, address: string | undefined | null, address_id: number | undefined | null ): Promise => { const msgs = i18n.getMessagesbyContext(c); if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) { throw new Error(msgs.UserDeleteEmailDisabledMsg) } if (!address && !address_id) { throw new Error(msgs.RequiredFieldMsg) } // get address_id or address if (!address_id) { address_id = await c.env.DB.prepare( `SELECT id FROM address where name = ?` ).bind(address).first("id"); } else if (!address) { address = await c.env.DB.prepare( `SELECT name FROM address where id = ?` ).bind(address_id).first("name"); } // check address again if (!address || !address_id) { throw new Error(msgs.AddressNotFoundMsg); } // unbind telegram await unbindTelegramByAddress(c, address); // delete address and related data const { success: mailSuccess } = await c.env.DB.prepare( `DELETE FROM raw_mails WHERE address = ? ` ).bind(address).run(); const { success: sendAccess } = await c.env.DB.prepare( `DELETE FROM address_sender WHERE address = ? ` ).bind(address).run(); const { success: sendboxSuccess } = await c.env.DB.prepare( `DELETE FROM sendbox WHERE address = ? ` ).bind(address).run(); const { success: addressSuccess } = await c.env.DB.prepare( `DELETE FROM users_address WHERE address_id = ? ` ).bind(address_id).run(); const { success: autoReplySuccess } = await c.env.DB.prepare( `DELETE FROM auto_reply_mails WHERE address = ? ` ).bind(address).run(); const { success } = await c.env.DB.prepare( `DELETE FROM address WHERE name = ? ` ).bind(address).run(); if (!success || !mailSuccess || !sendboxSuccess || !addressSuccess || !sendAccess || !autoReplySuccess) { throw new Error(msgs.OperationFailedMsg) } return true; } export const handleListQuery = async ( c: Context, query: string, countQuery: string, params: string[], limit: string | number | undefined | null, offset: string | number | undefined | null, /** Must be pre-validated (e.g. whitelist), NOT raw user input. Interpolated directly into SQL. */ orderBy?: string, hiddenFields: string[] = [] ): Promise => { const msgs = i18n.getMessagesbyContext(c); if (typeof limit === "string") { limit = parseInt(limit); } if (typeof offset === "string") { offset = parseInt(offset); } if (!limit || limit < 0 || limit > 100) { return c.text(msgs.InvalidLimitMsg, 400) } if (offset == null || offset == undefined || offset < 0) { return c.text(msgs.InvalidOffsetMsg, 400) } const orderClause = orderBy || 'id desc'; const resultsQuery = `${query} order by ${orderClause} limit ? offset ?`; const { results } = await c.env.DB.prepare(resultsQuery).bind( ...params, limit, offset ).all(); const count = offset == 0 ? await c.env.DB.prepare( countQuery ).bind(...params).first("count") : 0; if (hiddenFields.length === 0) { return c.json({ results, count }); } const filteredResults = results.map((row) => hideObjectFields(row, hiddenFields)); return c.json({ results: filteredResults, count }); } export const hideObjectFields = >( row: T, fields: string[] ): T => { const filteredRow = { ...row }; for (const field of fields) { delete filteredRow[field]; } return filteredRow; } /** * handleListQuery variant for raw_mails: resolves raw_blob → raw after query. */ export const handleMailListQuery = async ( c: Context, query: string, countQuery: string, params: string[], limit: string | number | undefined | null, offset: string | number | undefined | null, orderBy?: string ): Promise => { const { resolveRawEmailList } = await import('./gzip'); const msgs = i18n.getMessagesbyContext(c); if (typeof limit === "string") limit = parseInt(limit); if (typeof offset === "string") offset = parseInt(offset); if (!limit || limit < 0 || limit > 100) return c.text(msgs.InvalidLimitMsg, 400); if (offset == null || offset == undefined || offset < 0) return c.text(msgs.InvalidOffsetMsg, 400); const orderClause = orderBy || 'id desc'; const resultsQuery = `${query} order by ${orderClause} limit ? offset ?`; const { results } = await c.env.DB.prepare(resultsQuery).bind( ...params, limit, offset ).all(); const resolvedResults = await resolveRawEmailList( results, getBooleanValue(c.env.ENABLE_MAIL_FLAGS), ); const count = offset == 0 ? await c.env.DB.prepare( countQuery ).bind(...params).first("count") : 0; return c.json({ results: resolvedResults, count }); } export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): Promise<{ sender: string, subject: string, text: string, html: string, headers?: Record[], attachments?: ParsedEmailAttachment[], } | undefined> => { // check parsed email context is valid if (!parsedEmailContext || !parsedEmailContext.rawEmail) { return undefined; } // return parsed email if already parsed if (parsedEmailContext.parsedEmail) { return parsedEmailContext.parsedEmail; } const raw_mail = parsedEmailContext.rawEmail; // NOTE: WASM parse email // try { // const { parse_message_wrapper } = await import('mail-parser-wasm-worker'); // const parsedEmail = parse_message_wrapper(raw_mail); // parsedEmailContext.parsedEmail = { // sender: parsedEmail.sender || "", // subject: parsedEmail.subject || "", // text: parsedEmail.text || "", // headers: parsedEmail.headers?.map( // (header) => ({ key: header.key, value: header.value }) // ) || [], // html: parsedEmail.body_html || "", // attachments: (parsedEmail.attachments || []).map(att => ({ // filename: att.filename || "attachment", // mimeType: att.content_type || "application/octet-stream", // content: att.content, // disposition: "attachment", // })), // }; // return parsedEmailContext.parsedEmail; // } catch (e) { // console.error("Failed use mail-parser-wasm-worker to parse email", e); // } try { const { default: PostalMime } = await import('postal-mime'); const parsedEmail = await PostalMime.parse(raw_mail); parsedEmailContext.parsedEmail = { sender: parsedEmail.from ? `${parsedEmail.from.name} <${parsedEmail.from.address}>` : "", subject: parsedEmail.subject || "", text: parsedEmail.text || "", html: parsedEmail.html || "", headers: parsedEmail.headers || [], attachments: (parsedEmail.attachments || []).map(att => ({ filename: att.filename || "attachment", mimeType: att.mimeType || "application/octet-stream", content: new Uint8Array(att.content), disposition: att.disposition || "attachment", })), }; return parsedEmailContext.parsedEmail; } catch (e) { console.error("Failed use PostalMime to parse email", e); } return undefined; } export const commonGetUserRole = async ( c: Context, user_id: number ): Promise => { const user_roles = getUserRoles(c); const role_text = await c.env.DB.prepare( `SELECT role_text FROM user_roles where user_id = ?` ).bind(user_id).first("role_text"); return role_text ? user_roles.find((r) => r.role === role_text) : null; } export const getAddressPrefix = async (c: Context): Promise => { const user = c.get("userPayload"); if (!user) { return trimLower(c.env.PREFIX); } const user_role = await commonGetUserRole(c, user.user_id); if (typeof user_role?.prefix === "string") { return trimLower(user_role.prefix); } return trimLower(c.env.PREFIX); } export const getAllowDomains = async (c: Context): Promise => { const user = c.get("userPayload"); if (!user) { return getDefaultDomains(c); } const user_role = await commonGetUserRole(c, user.user_id); if (user_role?.domains && user_role.domains.length > 0) { return normalizeDomains(user_role.domains); } return getDefaultDomains(c); } export async function sendWebhook( settings: WebhookSettings, formatMap: WebhookMail ): Promise<{ success: boolean, message?: string }> { // send webhook let body = settings.body; for (const key of Object.keys(formatMap)) { body = body.replace( new RegExp(`\\$\\{${key}\\}`, "g"), JSON.stringify( formatMap[key as keyof WebhookMail] ).replace(/^"(.*)"$/, '$1') ); } const response = await fetch(settings.url, { method: settings.method, headers: JSON.parse(settings.headers), body: body }); if (!response.ok) { console.log("send webhook error", settings.url, settings.method, settings.headers, body); console.log("send webhook error", response.status, response.statusText); return { success: false, message: `send webhook error: ${response.status} ${response.statusText}` }; } return { success: true } } export async function triggerWebhook( c: Context, address: string, parsedEmailContext: ParsedEmailContext, message_id: string | null, aiExtract?: ExtractResult | null ): Promise { if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) { return } const webhookList: WebhookSettings[] = [] // admin mail webhook const adminMailWebhookSettings = await c.env.KV.get(CONSTANTS.WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY, "json"); if (adminMailWebhookSettings?.enabled) { webhookList.push(adminMailWebhookSettings) } // user mail webhook const adminSettings = await c.env.KV.get(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json"); if (!adminSettings?.enableAllowList || adminSettings?.allowList.includes(address)) { const settings = await c.env.KV.get( `${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json" ); if (settings?.enabled) { webhookList.push(settings) } } // no webhook if (webhookList.length === 0) { return } const mailId = await c.env.DB.prepare( `SELECT id FROM raw_mails where address = ? and message_id = ?` ).bind(address, message_id).first("id"); const parsedEmail = await commonParseMail(parsedEmailContext); const usableAiExtract = aiExtract?.type !== "none" && aiExtract?.result ? aiExtract : null; const webhookMail = { id: mailId || "", url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "", from: parsedEmail?.sender || "", to: address, subject: parsedEmail?.subject || "", raw: parsedEmailContext.rawEmail || "", parsedText: parsedEmail?.text || "", parsedHtml: parsedEmail?.html || "", aiExtract: usableAiExtract, aiExtractType: usableAiExtract?.type || "", aiExtractResult: usableAiExtract?.result || "", aiExtractResultText: usableAiExtract?.result_text || "", } for (const settings of webhookList) { const res = await sendWebhook(settings, webhookMail); if (!res.success) { console.error(res.message); } } } export async function triggerAnotherWorker( c: Context, rpcEmailMessage: RPCEmailMessage, parsedText: string | undefined | null ): Promise { if (!parsedText) { return; } const anotherWorkerList: AnotherWorker[] = getAnotherWorkerList(c); if (!getBooleanValue(c.env.ENABLE_ANOTHER_WORKER) || anotherWorkerList.length === 0) { return; } const parsedTextLowercase: string = parsedText.toLowerCase(); for (const worker of anotherWorkerList) { const keywords = worker?.keywords ?? []; const bindingName = worker?.binding ?? ""; const methodName = worker.method ?? "rpcEmail"; const serviceBinding = (c.env as any)[bindingName] ?? {}; const method = serviceBinding[methodName]; if (!method || typeof method !== "function") { console.log(`method = ${methodName} not found or not function`); continue; } if (!keywords.some(keyword => keyword && parsedTextLowercase.includes(keyword.toLowerCase()))) { console.log(`worker.binding = ${bindingName} not match keywords, parsedText = ${parsedText}`); continue; } try { const bodyObj = { ...rpcEmailMessage } as any; if (bodyObj.headers && typeof bodyObj.headers.forEach === "function") { const headerObj: any = {} bodyObj.headers.forEach((value: string, key: string) => { headerObj[key] = value; }); bodyObj.headers = headerObj } const requestBody = JSON.stringify(bodyObj); console.log(`exec worker , binding = ${bindingName} , requestBody = ${requestBody}`); await method(requestBody); } catch (e1) { console.error(`execute method = ${methodName} error`, e1); } } }