Bỏ qua để đến nội dung

Outbound (SMTP)

  • 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 đã:

  1. svc.createOne(body) — tạo message.
  2. linkFileAttachments(ctx, id, attachment_file_ids) — link file odp_files đã upload trước đó vào hd_message_attachments.
  3. maybeDeliver(ctx, id, direction)chỉ sau bước 2 mới gọi deliverOutgoing.
controllers/messages.controller.ts
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.

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
services/outbound/smtp-sender.ts
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.

smtp-sender.ts — 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ếu deliverOutgoing bị gọi 2 lần).
  • emitEvents: false khi ghi lại external_message_id sau khi gửi — tránh tự fire lại hook hd_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ỉ set In-Reply-To/References theo 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) → header X-Helpdesk-Loop trong sendReply, để nếu email này (hoặc bounce/CC) quay lại IMAP inbox, loopGuardReason() ở pipeline inbound drop nó ngay (reason: helpdesk-loop).
smtp-sender.ts — loadOutgoingAttachments
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)),
});
}
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.