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:
1 parent
cf0b6cf58e
commit
7e62ed739b
18 files changed
+1739
-7
No files matched your search
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user