Outbound (SMTP)
Vị trí mã nguồn
Phần tiêu đề “Vị trí mã nguồn”Thư mụcbackend/extensions/helpdesk/src/endpoints/
- services/outbound/smtp-sender.ts resolveOutbound, sendReply, deliverOutgoing
- services/attachments.ts loadOutgoingAttachments (trong smtp-sender.ts)
- controllers/messages.controller.ts maybeDeliver — gọi deliverOutgoing sau linkFileAttachments
Vì sao gửi email KHÔNG nằm trong create-hook
Phần tiêu đề “Vì sao gửi email KHÔNG nằm trong create-hook”hd_messages.items.create hook (xem Hooks) chỉ lo bump last_activity_at + trigger automation message_received. Việc gửi SMTP thực tế được gọi tường minh ngay sau khi controller đã:
svc.createOne(body)— tạo message.linkFileAttachments(ctx, id, attachment_file_ids)— link fileodp_filesđã upload trước đó vàohd_message_attachments.maybeDeliver(ctx, id, direction)— chỉ sau bước 2 mới gọideliverOutgoing.
async function maybeDeliver(ctx: AppContext, id: any, direction: string | undefined) { if (direction !== 'outgoing') return; try { await deliverOutgoing(ctx, Number(id)); } catch (err: any) { ctx.log?.error?.({ err: err?.message, messageId: id }, 'helpdesk outbound send failed'); }}Lý do thứ tự này: nếu gửi mail ngay trong create-hook (chạy đồng bộ với createOne), attachment chưa kịp link vào hd_message_attachments — email sẽ đi ra không có đính kèm. Tách gửi ra khỏi hook và gọi lại ở controller sau khi attachment đã link loại bỏ hoàn toàn race đó. Cùng logic được tái dùng ở automation action reply/send_template (actions.ts gọi deliverOutgoing ngay sau createOne, vì automation không có bước link-attachment).
Lỗi gửi mail được catch và log, không bao giờ làm fail việc tạo message — message đã lưu trong app dù email đi lỗi.
Resolve SMTP: 3 chế độ
Phần tiêu đề “Resolve SMTP: 3 chế độ”hd_inboxes.outbound_override (JSON) quyết định cách gửi:
| Mode | Ý nghĩa | Nguồn SMTP creds |
|---|---|---|
off |
Không gửi email — reply chỉ lưu trong app | — |
custom |
Inbox tự có SMTP riêng | outbound_override.smtp_host/username/password |
shared |
Dùng SMTP của connection chung | hd_connections.smtp_host/username/password |
| (không khớp mode nào) | fallback |
ODP system MailService — không set được From/threading header |
export function resolveOutbound(inbox: any, connection: any, secret: string) { const ob = parseOverride(inbox?.outbound_override); const mode = ob.mode ?? (connection?.smtp_host ? 'shared' : 'fallback');
if (mode === 'off') return { skip: true };
if (mode === 'custom' && ob.smtp_host && ob.smtp_username) { return { smtp: { host: ob.smtp_host, /* ... */ pass: safeDecrypt(ob.smtp_password, secret) } }; } if (mode === 'shared' && connection?.smtp_host && connection?.smtp_username) { return { smtp: { host: connection.smtp_host, /* ... */ pass: safeDecrypt(connection.smtp_password, secret) } }; } return { fallback: true };}Mật khẩu SMTP lưu mã hoá tại chỗ (cùng crypto.ts AES-256-GCM ở Inbound) — safeDecrypt() chỉ decrypt lúc gửi, không bao giờ trả plaintext ra ngoài. Ở tầng UI/BFF, secret luôn về client dưới dạng mask (***) — người dùng “reveal” phải gọi lại một route riêng, không phải đọc thẳng field.
Threading ngược: deliverOutgoing
Phần tiêu đề “Threading ngược: deliverOutgoing”export async function deliverOutgoing(ctx: AppContext, messageId: number): Promise<void> { const message = await db('hd_messages').where('id', messageId).first(); if (!message) return; if (message.direction !== 'outgoing') return; // notes/activities/incoming never send if (message.external_message_id) return; // already delivered — guard re-entry
const inbox = await db('hd_inboxes').where('id', conv.inbox_id).first(); if (!inbox || inbox.channel_type !== 'email') return; // only email inboxes send email
const lastInbound = await db('hd_messages') .where({ conversation_id: conv.id, direction: 'incoming' }) .whereNotNull('external_message_id') .orderBy('id', 'desc') .first(); const inReplyTo = lastInbound?.external_message_id ?? null; // ... const sentId = await sendReply(ctx, inbox, connection, { to, subject, html, text, inReplyTo, references, loopMarker: String(inbox.id), attachments });
if (sentId) { const svc = await createItemsService(ctx, 'hd_messages'); await svc.updateOne(messageId, { external_message_id: sentId }, { emitEvents: false }); }}Điểm đáng chú ý:
- Idempotent:
message.external_message_idđã có → coi là đã gửi, không gửi lại (guard re-entry nếudeliverOutgoingbị gọi 2 lần). emitEvents: falsekhi ghi lạiexternal_message_idsau khi gửi — tránh tự fire lại hookhd_messages.items.update(loop-guard kiểu SDK idiomatic, không phải flag tự chế).- Reply-to threading:
reply+<convId>@được set làm reply-to ở tầng inbound routing (không phải ở đây) — chiều outbound chỉ setIn-Reply-To/Referencestheo message inbound cuối cùng để mail client nhóm đúng thread. - Subject: tự thêm
Re:nếu chưa có. - Loop marker:
loopMarker: String(inbox.id)→ headerX-Helpdesk-LooptrongsendReply, để nếu email này (hoặc bounce/CC) quay lại IMAP inbox,loopGuardReason()ở pipeline inbound drop nó ngay (reason: helpdesk-loop).
Attachment: đọc từ odp_files local storage
Phần tiêu đề “Attachment: đọc từ odp_files local storage”async function loadOutgoingAttachments(ctx: AppContext, messageId: number) { const rows = await ctx.database('hd_message_attachments').where('message_id', messageId); const root = String(ctx.env?.STORAGE_LOCAL_ROOT ?? 'data/uploads'); // ... out.push({ filename: r.name || file.filename_download || 'attachment', path: resolve(process.cwd(), root, String(file.filename_disk)), });}Loop-guard cả 2 chiều
Phần tiêu đề “Loop-guard cả 2 chiều”| Chiều | Cơ chế |
|---|---|
| Inbound → drop mail tự-gửi | loopGuardReason() kiểm X-Helpdesk-Loop header trong mail đến |
| Outbound → gắn marker | sendReply() set headers: { 'X-Helpdesk-Loop': msg.loopMarker } trên mọi mail gửi ra |
Nhờ vậy một reply agent gửi ra, nếu bounce hoặc bị auto-CC quay lại chính inbox, không tự tạo thành ticket mới ở vòng lặp vô hạn.
Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Inbound (IMAP → ticket) — chiều nhận, loop-guard, sanitize
- Automation engine — action
reply/send_templatecũng gọideliverOutgoing - Master/Satellite & Trust model — mask/reveal credential pattern