Add email channel: IMAP IDLE inbound, SMTP outbound, thread matching

Inbound mail on support@top-sysops.ru creates/updates tickets in realtime
(IMAP IDLE, not polling). Replies thread via In-Reply-To/References against
stored Message-IDs, falling back to the customer's open ticket. Mailbox
credentials configured and encrypted via Settings -> Почта, same pattern as
the Telegram channel. TLS verification is opt-in-skippable per mailbox
(mail.top-sysops.ru's cert is currently expired; LAN-only server).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 13:01:21 +00:00
1 parent cf0b6cf58e
commit 7e62ed739b
18 files changed
+1739 -7

No files matched your search

+102
View File
@@ -55,6 +55,7 @@ async function appendMessage(params: {
authorName: string;
body: string;
direction: "in" | "out";
emailMessageId?: string;
}) {
const [message] = await db
.insert(messages)
@@ -65,6 +66,7 @@ async function appendMessage(params: {
authorName: params.authorName,
body: params.body,
direction: params.direction,
emailMessageId: params.emailMessageId,
})
.returning();
@@ -99,6 +101,35 @@ export async function findOrCreateCustomerByTelegramChatId(chatId: string, displ
return created;
}
export async function findOrCreateCustomerByEmail(email: string, displayName: string) {
const normalized = email.toLowerCase().trim();
const existing = await db.query.customers.findFirst({
where: eq(customers.email, normalized),
});
if (existing) return existing;
const [created] = await db
.insert(customers)
.values({ displayName, email: normalized })
.returning();
return created;
}
/**
* Finds the ticket a reply belongs to by matching the email's In-Reply-To /
* References headers against Message-IDs we've previously sent or received.
* Falls back to null if none match (caller then uses the open-ticket
* find-or-create heuristic instead).
*/
async function findTicketIdByEmailReferences(candidateMessageIds: string[]): Promise<string | null> {
if (candidateMessageIds.length === 0) return null;
const match = await db.query.messages.findFirst({
where: (m, { inArray }) => inArray(m.emailMessageId, candidateMessageIds),
});
return match?.ticketId ?? null;
}
/** The customer's most recent ticket that isn't closed. */
async function findOpenTicketForCustomer(customerId: string) {
return db.query.tickets.findFirst({
@@ -146,6 +177,54 @@ export async function recordTelegramInboundMessage(params: {
});
}
/**
* Records an inbound email. Prefers exact thread matching via
* In-Reply-To/References (so a reply to a specific closed ticket lands on
* that ticket, not a random other open one); falls back to the same
* find-or-create-open-ticket heuristic used for Telegram.
*/
export async function recordEmailInboundMessage(params: {
fromEmail: string;
fromName: string;
subject: string;
body: string;
messageId: string | null;
referencedMessageIds: string[];
}) {
const customer = await findOrCreateCustomerByEmail(params.fromEmail, params.fromName);
const matchedTicketId = await findTicketIdByEmailReferences(params.referencedMessageIds);
let ticket = matchedTicketId
? await db.query.tickets.findFirst({ where: eq(tickets.id, matchedTicketId) })
: await findOpenTicketForCustomer(customer.id);
let ticketIsNew = false;
if (!ticket) {
[ticket] = await db
.insert(tickets)
.values({
subject: params.subject || "(без темы)",
channel: "email" satisfies TicketChannel,
customerId: customer.id,
})
.returning();
ticketIsNew = true;
} else if (ticket.status === "closed") {
ticket = await touchTicket(ticket.id, { status: "open" });
}
return appendMessage({
ticket,
ticketIsNew,
authorType: "customer",
authorId: customer.id,
authorName: params.fromName,
body: params.body,
direction: "in",
emailMessageId: params.messageId ?? undefined,
});
}
/** Appends the customer's reply to a specific, already-known ticket (portal thread). */
export async function appendCustomerReply(params: {
ticketId: string;
@@ -177,6 +256,7 @@ export async function recordAgentReply(params: {
agentId: string;
agentName: string;
body: string;
emailMessageId?: string;
}) {
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
if (!existing) throw new Error("Ticket not found");
@@ -192,6 +272,7 @@ export async function recordAgentReply(params: {
authorName: params.agentName,
body: params.body,
direction: "out",
emailMessageId: params.emailMessageId,
});
}
@@ -243,6 +324,27 @@ export async function getTicketWithMessages(ticketId: string) {
};
}
/** Recipient address + Message-ID to reply to, for sending an agent's email reply. */
export async function getEmailThreadContext(ticketId: string) {
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId) });
if (!ticket) return null;
const customer = await db.query.customers.findFirst({ where: eq(customers.id, ticket.customerId) });
if (!customer?.email) return null;
const lastEmailMessage = await db.query.messages.findFirst({
where: (m, { eq: eqOp, and: andOp, isNotNull }) =>
andOp(eqOp(m.ticketId, ticketId), isNotNull(m.emailMessageId)),
orderBy: desc(messages.createdAt),
});
return {
customerEmail: customer.email,
subject: ticket.subject,
inReplyTo: lastEmailMessage?.emailMessageId ?? null,
};
}
export async function listAgents() {
return db.query.users.findMany({ orderBy: users.name });
}