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

+24 -1
View File
@@ -63,7 +63,7 @@ export const tickets = sqliteTable("tickets", {
priority: text("priority", { enum: ["low", "normal", "high", "urgent"] })
.notNull()
.default("normal"),
channel: text("channel", { enum: ["telegram", "portal", "manual"] }).notNull(),
channel: text("channel", { enum: ["telegram", "portal", "manual", "email"] }).notNull(),
customerId: text("customer_id")
.notNull()
.references(() => customers.id, { onDelete: "cascade" }),
@@ -85,6 +85,10 @@ export const messages = sqliteTable("messages", {
authorName: text("author_name").notNull(),
body: text("body").notNull(),
direction: text("direction", { enum: ["in", "out"] }).notNull(),
// RFC 5322 Message-ID of this email, when the message came in/out over the
// email channel — lets a customer's future reply be matched back to the
// exact ticket via In-Reply-To/References, even if they have other tickets.
emailMessageId: text("email_message_id"),
createdAt: timestamps.createdAt,
});
@@ -97,3 +101,22 @@ export const telegramConfig = sqliteTable("telegram_config", {
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
createdAt: timestamps.createdAt,
});
/** Single-row-ish config for the email channel (one support mailbox per deployment). */
export const mailboxConfig = sqliteTable("mailbox_config", {
id: id(),
imapHost: text("imap_host").notNull(),
imapPort: integer("imap_port").notNull().default(993),
smtpHost: text("smtp_host").notNull(),
smtpPort: integer("smtp_port").notNull().default(587),
user: text("user").notNull(),
passwordEnc: text("password_enc").notNull(),
// Both IMAP and SMTP presented an expired cert on support@top-sysops.ru at
// setup time. This is a LAN-only mailbox (never exposed to the internet),
// so skipping verification is an accepted risk — not a default for
// arbitrary/public mail servers. Revisit once the cert is renewed.
allowInsecureTls: integer("allow_insecure_tls", { mode: "boolean" }).notNull().default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
createdAt: timestamps.createdAt,
});