import { Context } from 'hono'; import { Jwt } from 'hono/utils/jwt' import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting } from './utils'; import { HonoCustomType, UserRole } from './types'; import { unbindTelegramByAddress } from './telegram_api/common'; import { CONSTANTS } from './constants'; import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models'; const DEFAULT_NAME_REGEX = /[^a-z0-9]/g; 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 const newAddress = async ( c: Context, { name, domain, enablePrefix, checkLengthByConfig = true, addressPrefix = null, checkAllowDomains = true, enableCheckNameRegex = true, }: { name: string, domain: string | undefined | null, enablePrefix: boolean, checkLengthByConfig?: boolean, addressPrefix?: string | undefined | null, checkAllowDomains?: boolean, enableCheckNameRegex?: boolean, } ): Promise<{ address: string, jwt: string }> => { // remove special characters name = name.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(`Name too short (min ${minAddressLength})`); } if (name.length > maxAddressLength) { throw new Error(`Name too long (max ${maxAddressLength})`); } // create address with prefix if (typeof addressPrefix === "string") { name = addressPrefix + name; } else if (enablePrefix) { name = getStringValue(c.env.PREFIX) + name; } // check domain const allowDomains = checkAllowDomains ? await getAllowDomains(c) : getDomains(c); // if domain is not set, use the first domain if (!domain && allowDomains.length > 0) { domain = allowDomains[0]; } // check domain is valid if (!domain || !allowDomains.includes(domain)) { throw new Error("Invalid domain") } // create address name = name + "@" + domain; try { const { success } = await c.env.DB.prepare( `INSERT INTO address(name) VALUES(?)` ).bind(name).run(); if (!success) { throw new Error("Failed to create address") } } catch (e) { const message = (e as Error).message; if (message && message.includes("UNIQUE")) { throw new Error("Address already exists") } throw new Error("Failed to create address") } const address_id = await c.env.DB.prepare( `SELECT id FROM address where name = ?` ).bind(name).first("id"); // create jwt const jwt = await Jwt.sign({ address: name, address_id: address_id }, c.env.JWT_SECRET, "HS256") return { jwt: jwt, address: name, } } 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 => { if (!cleanType || typeof cleanDays !== 'number' || cleanDays < 0 || cleanDays > 30) { throw new Error("Invalid cleanType or cleanDays") } console.log(`Cleanup ${cleanType} before ${cleanDays} days`); switch (cleanType) { case "mails": await c.env.DB.prepare(` DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')` ).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 created_at < datetime('now', '-${cleanDays} day')` ).run(); break; default: throw new Error("Invalid cleanType") } return true; } /** * TODO: need senbox delete? */ export const deleteAddressWithData = async ( c: Context, address: string | undefined | null, address_id: number | undefined | null ): Promise => { if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) { throw new Error("Delete email is disabled") } if (!address && !address_id) { throw new Error("Address or address_id required") } // 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("Can't find address"); } // 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: addressSuccess } = await c.env.DB.prepare( `DELETE FROM users_address WHERE address_id = ? ` ).bind(address_id).run(); const { success } = await c.env.DB.prepare( `DELETE FROM address WHERE name = ? ` ).bind(address).run(); if (!success || !mailSuccess || !addressSuccess || !sendAccess) { throw new Error("Failed to delete address") } return true; } export const handleListQuery = async ( c: Context, query: string, countQuery: string, params: string[], limit: string | number | undefined | null, offset: string | number | undefined | null ): Promise => { if (typeof limit === "string") { limit = parseInt(limit); } if (typeof offset === "string") { offset = parseInt(offset); } if (!limit || limit < 0 || limit > 100) { return c.text("Invalid limit", 400) } if (offset == null || offset == undefined || offset < 0) { return c.text("Invalid offset", 400) } const resultsQuery = `${query} order by id desc 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; return c.json({ results, count }); } export const commonParseMail = async (raw_mail: string | undefined | null): Promise<{ sender: string, subject: string, text: string, html: string, headers?: Record[] } | undefined> => { if (!raw_mail) { return undefined; } // TODO: WASM parse email // try { // const { parse_message_wrapper } = await import('mail-parser-wasm-worker'); // const parsedEmail = parse_message_wrapper(raw_mail); // return { // sender: parsedEmail.sender || "", // subject: parsedEmail.subject || "", // text: parsedEmail.text || "", // html: parsedEmail.body_html || "", // }; // } 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); return { sender: parsedEmail.from ? `${parsedEmail.from.name} <${parsedEmail.from.address}>` : "", subject: parsedEmail.subject || "", text: parsedEmail.text || "", html: parsedEmail.html || "", headers: parsedEmail.headers || [], }; } 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 c.env.PREFIX; } const user_role = await commonGetUserRole(c, user.user_id); if (typeof user_role?.prefix === "string") { return user_role.prefix; } return 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); return user_role?.domains || 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)) { /* eslint-disable no-useless-escape */ body = body.replace( new RegExp(`\\$\\{${key}\\}`, "g"), JSON.stringify( formatMap[key as keyof WebhookMail] ).replace(/^"(.*)"$/, '\$1') ); /* eslint-enable no-useless-escape */ } console.log("send webhook", settings.url, settings.method, settings.headers, body); const response = await fetch(settings.url, { method: settings.method, headers: JSON.parse(settings.headers), body: body }); if (!response.ok) { 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, raw_mail: string, message_id: string | 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?.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(raw_mail); 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: raw_mail, parsedText: parsedEmail?.text || "", parsedHtml: parsedEmail?.html || "" } for (const settings of webhookList) { const res = await sendWebhook(settings, webhookMail); if (!res.success) { console.error(res.message); } } }