Giải phápKhách hàng
Tài liệu

29/9/2026

Tóm tắt

blog.share_blog

Bắt đầu với notification và email

Trước tiên, gửi cho chính bạn một notification hiện trong danh sách thông báo của Cogover. Sau đó, cho phép Project gửi email và gửi email từ hộp thư của bạn: một lần gửi thẳng, một lần ghi lên dòng thời gian của một record. Thử toàn bộ trên local rồi mới publish. Phần 1 không cần Object; bước thử cuối của phần 2 dùng một record có sẵn.

Chuẩn bị

  • Cài Node.js 20 trở lên, Git, curl và jq.
  • Dùng Workspace đã thiết lập notification. Với phần 2, kết nối một hộp thư cá nhân vào tài khoản Cogover của bạn, tức hộp thư bạn dùng để gửi email từ trang record.
  • Có quyền SuperAdmin cho Custom Backend Module: tạo, publish, activate Project và quản lý identity policy của Project.
  • Chuẩn bị Project key cho phép ghi để chạy local, và Workspace API key để publish và gọi API quản trị. Đây là hai khóa khác nhau. Gửi notification hay email đều tính là thao tác ghi, nên Project key chỉ đọc không đủ.

Xem cách tạo Project trong Bắt đầu với Custom Backend Module.

Mọi thứ tài liệu này gửi đi đều là thật: notification đến người thật và email rời khỏi hộp thư của bạn. Trong lúc học, hãy gửi cho chính mình.

Phần 1. Gửi notification

1. Tạo Project

Trong Cogover, mở Custom Backend Module, tạo Project tên Notify demo với slug notify_demo. Sao chép Project ID, rồi chạy:

git clone https://github.com/cogover/custom-backend-module-starter-project.git notify-demo
cd notify-demo
npm install --global @cogover/dev-cli@latest
npm install
npm install @cogover/sdk@latest
cogover-dev --version
npm ls @cogover/sdk
cp cogover.example.json cogover.json

Dùng CLI từ 0.15.0 và SDK từ 0.12.0; phiên bản cũ hơn chưa có notifications và email. Sửa cogover.json cho giống nội dung dưới đây, với hostname của Workspace (ví dụ example.cogover.net) và Project ID của bạn. Đặt projectSlug là notify_demo: mọi URL trong tài liệu này dùng slug đó.

{
  "version": 1,
  "runtimeUrl": "https://{WORKSPACE_DOMAIN}",
  "projectId": "{PROJECT_ID}",
  "projectSlug": "notify_demo"
}

Tạo .env trong thư mục này với Workspace API key của bạn:

COGOVER_API_KEY={WORKSPACE_API_KEY}

Starter đã bỏ qua .env và cogover.json trong Git. Không đặt khóa trong source code. Chạy các lệnh còn lại từ thư mục này.

2. Viết handler

Tạo file src/main.ts:

import {
  CogoverApiError, createRouter, NotFoundError, PermissionDeniedError, RateLimitError, ValidationError,
  type ResponseApi,
} from "@cogover/sdk";
 
const router = createRouter();
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
 
router.post("/notify", async ({ request, invocation, notifications, response }) => {
  const body = (request.body ?? {}) as { to?: unknown; title?: unknown; content?: unknown; key?: unknown };
  const me = invocation.user?.membership.personnelId;
  const to = body.to ?? (me === undefined ? [] : [me]);
  if (!Array.isArray(to) || to.length === 0 || !to.every((id): id is string => typeof id === "string")) {
    return response.json({ error: "to must list at least one personnel ID" }, { status: 400 });
  }
  if (typeof body.title !== "string" || typeof body.content !== "string") {
    return response.json({ error: "title and content are required" }, { status: 400 });
  }
  if (body.key !== undefined && (typeof body.key !== "string" || !KEY_PATTERN.test(body.key))) {
    return response.json({ error: "key must be 1 to 128 letters, digits, '.', '_', ':' or '-'" }, { status: 400 });
  }
 
  try {
    return await notifications.send({
      to,
      title: body.title,
      content: body.content,
      ...(typeof body.key === "string" ? { idempotencyKey: body.key } : {}),
    });
  } catch (error) {
    return failure(error, response);
  }
});
 
function failure(error: unknown, response: ResponseApi) {
  if (error instanceof ValidationError) {
    return response.json({ error: error.message }, { status: 400 });
  }
  if (error instanceof NotFoundError) {
    return response.json({ error: "Not found", resource: error.resource, id: error.resourceId }, { status: 404 });
  }
  if (error instanceof PermissionDeniedError) {
    const reason = (error.details as { reason?: unknown } | undefined)?.reason ?? null;
    return response.json({ error: "Not allowed", reason }, { status: 403 });
  }
  if (error instanceof RateLimitError) {
    // details.budget có giá trị khi lần thực thi này đã dùng hết một ngân sách: gửi lại đúng request đó không giúp được gì.
    const budget = (error.details as { budget?: unknown } | undefined)?.budget;
    return budget === undefined
      ? response.json({ error: "The hourly limit was reached; try again later" }, { status: 429 })
      : response.json({ error: "This request used up a budget of its execution", budget }, { status: 429 });
  }
  if (error instanceof CogoverApiError && error.code === "DUPLICATE_IN_PROGRESS") {
    return response.json({ error: "The same message is still being sent; retry shortly" }, { status: 409 });
  }
  throw error;
}
 
export default router.toHandler();

POST /notify gửi notification tới các personnel ID trong to, hoặc tới chính người gọi khi không có to. Nếu có key, giá trị này thành idempotencyKey, nên gọi lại với cùng key sẽ không thông báo hai lần. failure chuyển các lỗi SDK mà một lần gửi có thể ném thành HTTP response rõ ràng; lỗi khác được ném tiếp.

3. Thử trên local

Đăng nhập bằng Project key khi được hỏi, rồi chạy server local:

cogover-dev login --profile notify_demo
cogover-dev doctor --profile notify_demo
COGOVER_LOCAL_PORT=3100 cogover-dev run --profile notify_demo -- npm run dev

Trên máy không có kho lưu khóa của hệ điều hành, như nhiều server Linux, login lưu Project key vào .env cạnh Workspace API key và thêm quy tắc bỏ qua vào .gitignore. Không đưa .env vào Git, và commit .gitignore đã cập nhật.

Giữ terminal đó chạy. Ở terminal thứ hai, gửi thông báo cho chính bạn. Server local chạy với danh tính của personnel được cấp Project key, nên khi không có to, notification gửi tới personnel đó:

curl -s -X POST 'http://127.0.0.1:3100/api/v1/ts-projects/notify_demo/notify' \
  -H 'Content-Type: application/json' \
  --data '{"title":"Hello from notify_demo","content":"This notification was sent by a Custom Backend Module."}' | jq

Kết quả mong đợi là body tương tự:

{
  "requestId": "NTFQ7K2M9X4B1C8D5E6",
  "notified": 1,
  "skippedPersonnelIds": [],
  "duplicate": false
}

notified là số tài khoản người dùng nhận notification. Mở Cogover: notification xuất hiện trong danh sách thông báo của bạn sau vài giây. Lời gọi không truyền kênh thông báo nên mọi hình thức gửi đều bật: bạn có thể nhận thêm push trên web hoặc điện thoại, và một bản sao email từ địa chỉ thông báo của Cogover.

Gửi lại với một key, hai lần:

curl -s -X POST 'http://127.0.0.1:3100/api/v1/ts-projects/notify_demo/notify' \
  -H 'Content-Type: application/json' \
  --data '{"title":"Order SO-1001 shipped","content":"The order left the warehouse.","key":"order-shipped-SO-1001"}' | jq '{requestId, duplicate}'

Lần đầu trả "duplicate": false. Lần thứ hai trả cùng requestId với "duplicate": true, và không có notification thứ hai. Một key được ghi nhận là đã dùng trong 7 ngày.

Thử với người nhận không tồn tại. Thêm -i để xem status:

curl -s -i -X POST 'http://127.0.0.1:3100/api/v1/ts-projects/notify_demo/notify' \
  -H 'Content-Type: application/json' \
  --data '{"to":["PERUNKNOWN00000"],"title":"Hello","content":"Nobody should get this."}'

Kết quả mong đợi là HTTP 404 với "resource": "personnel". Khi có người nhận không tồn tại, không có gì được gửi. Sau đó thử phần kiểm tra của chính handler với body rỗng:

curl -s -i -X POST 'http://127.0.0.1:3100/api/v1/ts-projects/notify_demo/notify' \
  -H 'Content-Type: application/json' --data '{}'

Thiếu title và content thì trả 400 với "error": "title and content are required".

Server local gửi qua Project thật trên Cogover bằng Development Session. Notification không cần version đã publish hay identity policy.

Phần 2. Gửi email

4. Cho phép Project gửi email

Project chỉ gửi được email từ các hộp thư mà identity policy cho phép. Section email của policy liệt kê các hộp thư đó: allowActorMailbox: true cho phép hộp thư cá nhân mặc định của người dùng đã khởi động lần chạy, còn workspaceMailboxIds liệt kê ID các hộp thư chung của Workspace.

Dừng server local bằng Ctrl+C: đổi policy sẽ kết thúc Development Session đang chạy. Sau đó tạo file session Workspace bằng CLI. CLI cũng tự thêm file này vào .gitignore. CLI không ghi đè file đã có, nên hãy xóa .cogover-session.curl cũ trước, ví dụ khi session đã hết hạn:

cogover-dev auth session --format curl --output .cogover-session.curl

Đặt hai biến cho các lời gọi API quản trị. Thay {WORKSPACE_DOMAIN} và {PROJECT_ID}. Bước 7 và 8 cũng dùng hai biến này, nên hãy tiếp tục dùng terminal này; nếu mở terminal mới, đặt lại chúng:

DEMO_ORIGIN='https://{WORKSPACE_DOMAIN}'
DEMO_PROJECT_ID='{PROJECT_ID}'

Mỗi Project có một policy chỉnh sửa được, và lưu policy sẽ thay thế nó. Kiểm tra xem Project đã có policy chưa:

curl -s --config .cogover-session.curl -X POST \
  "$DEMO_ORIGIN/api/v1/ts-projects/$DEMO_PROJECT_ID/identity-policy/get" \
  -H 'x-req-type: 9' -H 'x-req-service: 4' \
  -H 'Content-Type: application/json' --data '{}' | jq -c '.'

Project mới chưa có policy, lệnh in ra {"msg":"Project identity policy not found","r":404}: hãy ghi policy bên dưới. Nếu lệnh in ra một policy, giữ nguyên các grant của nó và thêm section email bằng lệnh sau, rồi bỏ qua lệnh cat bên dưới. Lệnh này bỏ các mục null có trong response, vì khi lưu, các mục đó bị từ chối, và thay thế section email mà policy đang có; sau đó sửa file để giữ lại các hộp thư bạn vẫn cần:

curl -s --config .cogover-session.curl -X POST \
  "$DEMO_ORIGIN/api/v1/ts-projects/$DEMO_PROJECT_ID/identity-policy/get" \
  -H 'x-req-type: 9' -H 'x-req-service: 4' \
  -H 'Content-Type: application/json' --data '{}' \
  | jq '.policy | with_entries(select(.value != null))
        | .email = {"workspaceMailboxIds": [], "allowActorMailbox": true}' > email-policy.json

Với Project mới, ghi policy ra file:

cat > email-policy.json <<'EOF'
{
  "schemaVersion": 2,
  "callerPersonnelIds": { "mode": "ALL", "personnelIds": [] },
  "allowInternalSystem": false,
  "email": { "workspaceMailboxIds": [], "allowActorMailbox": true }
}
EOF

Policy này cho mọi người gọi gửi từ hộp thư mặc định của chính họ và không cấp gì khác. Nếu quản trị viên đã đưa bạn ID của một hộp thư chung của Workspace, thêm ID đó vào workspaceMailboxIds, ví dụ ["{MAILBOX_ID}"]. File này không chứa bí mật; bước 9 sẽ xóa nó.

Lưu policy rồi kích hoạt:

curl -s --config .cogover-session.curl -X POST \
  "$DEMO_ORIGIN/api/v1/ts-projects/$DEMO_PROJECT_ID/identity-policy" \
  -H 'x-req-type: 9' -H 'x-req-service: 4' \
  -H 'Content-Type: application/json' --data @email-policy.json | jq '.status // .'
curl -s --config .cogover-session.curl -X POST \
  "$DEMO_ORIGIN/api/v1/ts-projects/$DEMO_PROJECT_ID/identity-policy/activate" \
  -H 'x-req-type: 9' -H 'x-req-service: 4' \
  -H 'Content-Type: application/json' --data '{}' | jq '.status // .'

Kết quả mong đợi là "DRAFT", rồi "ACTIVE". Response của API quản trị chính là kết quả JSON của Runtime, không có envelope; khi lời gọi lỗi, lệnh in ra toàn bộ object lỗi, gồm msg và r. Luôn gửi cả x-req-type: 9 lẫn x-req-service: 4: thiếu x-req-type, request bị coi là lệnh đăng xuất và kết thúc session.

Policy chỉnh sửa được đang ACTIVE áp dụng cho Development Session trên local. Version đã publish dùng bản policy được duyệt cho version đó, bạn sẽ tạo ở bước 7.

Để đổi policy về sau, ví dụ thêm một hộp thư chung, dừng server local, sửa email-policy.json, chạy lại cả hai lệnh, rồi chạy lại cogover-dev run: mỗi lần lưu, policy trở về DRAFT cho tới khi bạn activate. Version đã publish vẫn giữ bản policy đã duyệt cho nó, nên hãy duyệt lại policy cho version đó như ở bước 7.

5. Thêm route gửi email

Thay src/main.ts bằng nội dung sau. /notify và failure giữ nguyên; phần mới là import EmailSender, ba route email và helper sender:

import {
  CogoverApiError, createRouter, NotFoundError, PermissionDeniedError, RateLimitError, ValidationError,
  type EmailSender, type ResponseApi,
} from "@cogover/sdk";
 
const router = createRouter();
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const MAILBOX_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
 
router.post("/notify", async ({ request, invocation, notifications, response }) => {
  const body = (request.body ?? {}) as { to?: unknown; title?: unknown; content?: unknown; key?: unknown };
  const me = invocation.user?.membership.personnelId;
  const to = body.to ?? (me === undefined ? [] : [me]);
  if (!Array.isArray(to) || to.length === 0 || !to.every((id): id is string => typeof id === "string")) {
    return response.json({ error: "to must list at least one personnel ID" }, { status: 400 });
  }
  if (typeof body.title !== "string" || typeof body.content !== "string") {
    return response.json({ error: "title and content are required" }, { status: 400 });
  }
  if (body.key !== undefined && (typeof body.key !== "string" || !KEY_PATTERN.test(body.key))) {
    return response.json({ error: "key must be 1 to 128 letters, digits, '.', '_', ':' or '-'" }, { status: 400 });
  }
 
  try {
    return await notifications.send({
      to,
      title: body.title,
      content: body.content,
      ...(typeof body.key === "string" ? { idempotencyKey: body.key } : {}),
    });
  } catch (error) {
    return failure(error, response);
  }
});
 
router.get("/email/senders", async ({ email }) => {
  return { senders: await email.senders() };
});
 
router.post("/email/send", async ({ request, email, response }) => {
  const body = (request.body ?? {}) as {
    mailboxId?: unknown; to?: unknown; subject?: unknown; text?: unknown; key?: unknown;
  };
  const from = sender(body.mailboxId);
  if (from === null) {
    return response.json({ error: "mailboxId must be a mailbox ID" }, { status: 400 });
  }
  if (typeof body.to !== "string" || typeof body.subject !== "string" || typeof body.text !== "string") {
    return response.json({ error: "to, subject and text are required" }, { status: 400 });
  }
  if (body.key !== undefined && (typeof body.key !== "string" || !KEY_PATTERN.test(body.key))) {
    return response.json({ error: "key must be 1 to 128 letters, digits, '.', '_', ':' or '-'" }, { status: 400 });
  }
 
  try {
    return await email.send({
      from,
      to: [body.to],
      subject: body.subject,
      text: body.text,
      ...(typeof body.key === "string" ? { idempotencyKey: body.key } : {}),
    });
  } catch (error) {
    return failure(error, response);
  }
});
 
router.post("/email/records/:object/:recordId", async ({ request, email, response }) => {
  const body = (request.body ?? {}) as { mailboxId?: unknown; emailField?: unknown; subject?: unknown; html?: unknown };
  const from = sender(body.mailboxId);
  if (from === null) {
    return response.json({ error: "mailboxId must be a mailbox ID" }, { status: 400 });
  }
  if (typeof body.emailField !== "string" || typeof body.subject !== "string" || typeof body.html !== "string") {
    return response.json({ error: "emailField, subject and html are required" }, { status: 400 });
  }
 
  try {
    return await email.send({
      from,
      subject: body.subject,
      html: body.html,
      record: { object: request.params.object ?? "", recordId: request.params.recordId ?? "" },
      recordEmailFields: [body.emailField],
    });
  } catch (error) {
    return failure(error, response);
  }
});
 
// Without a mailbox ID, send from the caller's own mailbox.
function sender(mailboxId: unknown): EmailSender | null {
  if (mailboxId === undefined) return "actor";
  return typeof mailboxId === "string" && MAILBOX_PATTERN.test(mailboxId) ? { mailbox: mailboxId } : null;
}
 
function failure(error: unknown, response: ResponseApi) {
  if (error instanceof ValidationError) {
    return response.json({ error: error.message }, { status: 400 });
  }
  if (error instanceof NotFoundError) {
    return response.json({ error: "Not found", resource: error.resource, id: error.resourceId }, { status: 404 });
  }
  if (error instanceof PermissionDeniedError) {
    const reason = (error.details as { reason?: unknown } | undefined)?.reason ?? null;
    return response.json({ error: "Not allowed", reason }, { status: 403 });
  }
  if (error instanceof RateLimitError) {
    // details.budget có giá trị khi lần thực thi này đã dùng hết một ngân sách: gửi lại đúng request đó không giúp được gì.
    const budget = (error.details as { budget?: unknown } | undefined)?.budget;
    return budget === undefined
      ? response.json({ error: "The hourly limit was reached; try again later" }, { status: 429 })
      : response.json({ error: "This request used up a budget of its execution", budget }, { status: 429 });
  }
  if (error instanceof CogoverApiError && error.code === "DUPLICATE_IN_PROGRESS") {
    return response.json({ error: "The same message is still being sent; retry shortly" }, { status: 409 });
  }
  throw error;
}
 
export default router.toHandler();
  • GET /email/senders liệt kê các hộp thư mà lần chạy này được phép gửi.
  • POST /email/send gửi email văn bản thường tới một địa chỉ. Không có mailboxId thì gửi từ hộp thư của chính người gọi (from: "actor"); có thì gửi từ hộp thư chung đó (from: { mailbox }).
  • POST /email/records/:object/:recordId gửi email HTML về một record. record ghi email lên dòng thời gian của record đó, còn recordEmailFields lấy người nhận từ một field email của record, nên request không cần mang địa chỉ.

6. Thử gửi email trên local

Chạy lại server local:

COGOVER_LOCAL_PORT=3100 cogover-dev run --profile notify_demo -- npm run dev

Ở terminal thứ hai, xem danh sách hộp thư gửi:

curl -s 'http://127.0.0.1:3100/api/v1/ts-projects/notify_demo/email/senders' | jq
{
  "senders": [
    {
      "id": "EAq7K2m9X4b1C",
      "kind": "actor",
      "email": "giang.ho@example.com",
      "displayName": "Giang Ho - Sales"
    }
  ]
}

Mục actor là hộp thư mà from: "actor" sử dụng. displayName là tên đặt cho hộp thư trong Cogover, có thể khác tên của người đó. Hộp thư chung có trong policy hiện với kind: "workspace" khi đã được kết nối để gửi thư; hộp thư không gửi được bị bỏ qua. Danh sách rỗng nghĩa là policy chưa active, hoặc tài khoản của bạn chưa có hộp thư cá nhân đang hoạt động.

Gửi email cho chính bạn. Thay {YOUR_EMAIL} bằng một địa chỉ bạn đọc được:

curl -s -X POST 'http://127.0.0.1:3100/api/v1/ts-projects/notify_demo/email/send' \
  -H 'Content-Type: application/json' \
  --data '{"to":"{YOUR_EMAIL}","subject":"Hello from notify_demo","text":"Sent with email.send.","key":"hello-email-1"}' | jq
{
  "requestId": "EMLQ7K2M9X4B1C8D5E6",
  "delivery": "direct",
  "recipients": 1,
  "duplicate": false
}

delivery: "direct" nghĩa là email chỉ được gửi từ hộp thư của bạn, không gắn với record nào. Lời gọi trả về khi Cogover đã nhận email; hộp thư gửi đi ngay sau đó. Kiểm tra hộp thư đến, và thư mục đã gửi của hộp thư. Chạy lại đúng lệnh trên: nhờ key, lần này trả "duplicate": true mà không gửi email thứ hai.

Thử một hộp thư policy không cho phép:

curl -s -i -X POST 'http://127.0.0.1:3100/api/v1/ts-projects/notify_demo/email/send' \
  -H 'Content-Type: application/json' \
  --data '{"mailboxId":"NOTGRANTED","to":"{YOUR_EMAIL}","subject":"Hello","text":"This is refused."}'

Kết quả mong đợi là HTTP 403 với "reason": "EMAIL_SENDER_NOT_GRANTED". Cogover kiểm tra policy trước khi tra cứu bất cứ thứ gì, nên câu trả lời giống nhau dù hộp thư có tồn tại hay không.

Giờ ghi một email lên record. Chọn một record bạn đọc được, thuộc Object có field Email chứa địa chỉ bạn đọc được, ví dụ một liên hệ mang địa chỉ của chính bạn. Thay {OBJECT_SLUG}, {RECORD_ID} và {EMAIL_FIELD} bằng slug của Object, ID của record và slug của field Email:

curl -s -X POST 'http://127.0.0.1:3100/api/v1/ts-projects/notify_demo/email/records/{OBJECT_SLUG}/{RECORD_ID}' \
  -H 'Content-Type: application/json' \
  --data '{"emailField":"{EMAIL_FIELD}","subject":"Your documents","html":"<p>Hello,</p><p>This email is logged on your record.</p>"}' | jq
{
  "requestId": "EMLR3T8W1Z5Y2V6U9S4",
  "delivery": "activity",
  "recipients": 0,
  "duplicate": false
}

delivery: "activity" nghĩa là email còn được ghi thành một hoạt động email trên record. Mở record trong Cogover: email hiện trên dòng thời gian của record, nơi Cogover theo dõi lượt mở và lượt bấm link. recipients là 0 vì chỉ đếm địa chỉ trong to, cc và bcc; địa chỉ từ recordEmailFields được đọc sau, lúc gửi email, nên lời gọi không cho biết field có chứa địa chỉ hay không. Record bạn không đọc được trả 404 với "resource": "record", còn field không phải field Email trả 400.

7. Publish, duyệt và activate

Dừng server local bằng Ctrl+C. Từ thư mục project, chạy:

npm run build
cogover-dev publish

Output của CLI kết thúc bằng các dòng tương tự:

Published version 1 (TSVXXXXXXXXXXXX) is READY.
Next: cogover-dev activate TSVXXXXXXXXXXXX

Version ID là giá trị TSV trong ngoặc. Chưa chạy lệnh activate mà CLI gợi ý: hãy duyệt policy cho version trước. Thay {VERSION_ID} bằng version ID:

curl -s --config .cogover-session.curl -X POST \
  "$DEMO_ORIGIN/api/v1/ts-projects/$DEMO_PROJECT_ID/versions/{VERSION_ID}/identity-policy/approve" \
  -H 'x-req-type: 9' -H 'x-req-service: 4' \
  -H 'Content-Type: application/json' --data '{}' | jq '.status // .'

Kết quả mong đợi là "ACTIVE". Duyệt sẽ chép policy chỉnh sửa được hiện tại vào version này. Mỗi version mới publish cần được duyệt riêng; nếu không, version đó vẫn gửi được notification nhưng mọi email.send đều bị từ chối với EMAIL_SENDER_NOT_GRANTED. Sau đó activate version:

cogover-dev activate {VERSION_ID}

8. Gọi Project đã publish

Gọi version đang active qua session Workspace của bạn:

curl -s --config .cogover-session.curl \
  "$DEMO_ORIGIN/api/v1/ts-projects/notify_demo/email/senders" \
  -H 'x-req-type: 9' -H 'x-req-service: 3' | jq '.'
curl -s --config .cogover-session.curl -X POST \
  "$DEMO_ORIGIN/api/v1/ts-projects/notify_demo/notify" \
  -H 'x-req-type: 9' -H 'x-req-service: 3' \
  -H 'Content-Type: application/json' \
  --data '{"title":"Hello from the published version","content":"notify_demo is live."}' | jq '.'

Response chính là kết quả của route, cùng các field như ở bước 3 và 6. Session thuộc về người dùng của Workspace API key, nên notification gửi tới người dùng đó, và actor trong /email/senders là hộp thư của người đó. Người này có thể khác personnel được cấp Project key.

9. Dọn dữ liệu thử

Xóa file session và file policy khi xong:

rm -f .cogover-session.curl email-policy.json

Notification và email đã gửi không thu hồi được. Deactivate Project trong Cogover nếu không cần dùng nữa.

Notification, push message hay email

Push message (push) Notification (notifications) Email (email)
Đến đâu Các web client đang mở ngay lúc đó Danh sách thông báo của từng người nhận, cùng push trên web, trên điện thoại và bản sao email theo kênh cho phép Bất kỳ địa chỉ email nào
Còn lại sau khi gửi Không Có, trong danh sách thông báo Có, trong hộp thư; trên dòng thời gian record khi có record
Người nhận Người đang xem record, hoặc danh sách personnel ID 1 đến 200 personnel ID Tối đa 50 địa chỉ hoặc personnel
Gửi từ Cogover Workspace, hoặc người dùng đã khởi động lần chạy Hộp thư được identity policy cho phép
Identity policy Không cần Không cần Bắt buộc có section email

Dùng notification cho việc người nhận phải xử lý sau, như một yêu cầu duyệt. Dùng email cho người ngoài Cogover, hoặc khi thư cần lưu cùng record. Dùng push message để làm mới màn hình hoặc hiện một toast ngắn.

Tùy chọn của notification

notifications.send nhận một thông báo:

Tùy chọn Ý nghĩa
to 1 đến 200 personnel ID. Personnel chưa có tài khoản người dùng bị bỏ qua và trả về trong skippedPersonnelIds.
exclude Personnel không bao giờ nhận. "actor" là người dùng đã khởi động lần chạy, ví dụ để không tự thông báo cho chính mình về thay đổi mình vừa làm.
title, content Bắt buộc. Tối đa 200 và 16.384 ký tự. title cũng là tiêu đề của bản sao email.
contentType "text" (mặc định) hoặc "html". HTML chỉ giữ định dạng cơ bản như đoạn văn, danh sách, bảng và link; script, style và ảnh bị loại bỏ.
subtitle, display subtitle chỉ hiện ở kiểu hiển thị "FULL", là mặc định khi có subtitle.
link Trang mở ra khi bấm: đường dẫn web app bắt đầu bằng /, hoặc URL tuyệt đối http/https. openIn là "SAME_TAB" hoặc "NEW_TAB".
sender "workspace" (mặc định) hoặc "actor", hiển thị tên và avatar của người dùng đã khởi động lần chạy.
channel Record ID của một kênh thông báo của Workspace. Kênh quyết định hình thức gửi nào được bật, và từng người dùng có thể ghi đè trong cài đặt của họ. Không truyền thì mọi hình thức đều bật.
email false bỏ bản sao email kể cả khi kênh bật email. Bản sao gửi từ địa chỉ thông báo của Cogover và tính vào giới hạn email thông báo hằng ngày theo gói của Workspace.
idempotencyKey Xem Gửi lại và idempotency.

Khi mọi người nhận đều bị loại hoặc bỏ qua, không có gì được gửi và notified là 0.

Tùy chọn của email

email.send nhận một email:

Tùy chọn Ý nghĩa
from { mailbox: id } cho hộp thư chung của Workspace, hoặc "actor" cho hộp thư cá nhân mặc định của người dùng đã khởi động lần chạy. Cả hai đều phải được policy cho phép.
to, cc, bcc Một địa chỉ, { email, name } hoặc { personnelId }. Personnel được đổi thành email công việc, nếu không có thì email tài khoản. Tổng ba danh sách tối đa 50 người nhận. to bắt buộc trừ khi có recordEmailFields.
subject Bắt buộc. Một dòng, tối đa 255 ký tự.
html hoặc text Đúng một trong hai. Tối đa 192 KB theo UTF-8.
record { object, recordId }. Ghi email lên dòng thời gian của record; kết quả có delivery: "activity". Record phải đọc được.
recordEmailFields Tối đa 20 field Email của record, địa chỉ trong đó được thêm vào to. Cần record.
attachments Các mục { object, recordId, field }: đính kèm mọi file của field file đó. Tối đa 10 file và tổng 20 MB. Cần record.
appendSignature true thêm chữ ký mặc định của người gửi. Cần record và from: "actor".
idempotencyKey Xem Gửi lại và idempotency.

File đính kèm chỉ lấy từ field file của record, và record phải đọc được. Script không bao giờ xử lý URL hay nội dung file.

Section email của policy

Field Ý nghĩa
workspaceMailboxIds Các hộp thư chung của Workspace mà Project được gửi, tối đa 50 ID. Hỏi quản trị viên Workspace để lấy ID của một hộp thư đã kết nối để gửi thư, như hộp thư Gmail, Outlook, SMTP hoặc SendGrid.
allowActorMailbox true cho phép from: "actor".
  • Section phải cấp ít nhất một quyền: một ID hộp thư, allowActorMailbox: true, hoặc cả hai. Không có section này thì Project không gửi được email.
  • Development Session trên local dùng policy chỉnh sửa được khi policy ở trạng thái ACTIVE. Version đã publish dùng bản policy đã duyệt cho version đó.
  • Section chỉ áp dụng cho người gọi được chọn bởi callerPersonnelIds, và cho lần chạy không có người dùng, như job theo lịch và inbound webhook, chỉ khi allowInternalSystem là true. Các lần chạy đó không có actor nên phải dùng hộp thư chung.
  • Hộp thư có tồn tại và đang hoạt động hay không được kiểm tra ở mỗi lần gửi, không phải lúc lưu policy.

Xem tài liệu identity policy.

Gửi lại và idempotency

Lời gọi thành công nghĩa là Cogover đã nhận notification hoặc email. Việc gửi diễn ra sau đó, và lỗi xảy ra sau, như địa chỉ bị từ chối, không được báo lại cho script. Hãy lưu kết quả là "đã nhận để gửi", không phải bằng chứng đã tới nơi. Tin đã gửi không thu hồi được.

Background job được giao ít nhất một lần, và after-change trigger có thể chạy nhiều hơn một lần. Hãy đặt cho mỗi tin một idempotencyKey không đổi qua các lần thử lại, dựng từ những gì định danh sự kiện nghiệp vụ:

// In an after-change trigger: the same change of the same record always gives the same key.
idempotencyKey: `order-shipped:${trigger.changeId}:${record.id}`,
// In a background job: the same run always gives the same key.
idempotencyKey: `weekly-report:${job.id}`,

Key bắt đầu bằng chữ hoặc số và có tối đa 128 chữ, số, ., _, : hoặc -. Lời gọi dùng key mà Project đã dùng trong 7 ngày gần nhất sẽ không gửi gì và trả kết quả của lần đầu với duplicate: true. Lời gọi trong lúc lần đầu cùng key vẫn đang gửi sẽ ném CogoverApiError với code: "DUPLICATE_IN_PROGRESS"; hãy thử lại sau giây lát. Lời gọi thất bại không giữ key. Key của notification và key của email tách biệt nhau.

Nơi dùng được

Lần chạy notifications.send email.send email.senders
Route do người dùng gọi, hoặc Development Session trên local cho phép ghi Có Có Có
After-change trigger, background job Có Có; khi không có người dùng, chỉ khi có allowInternalSystem: true và dùng hộp thư chung Có
Inbound webhook Có Chỉ khi có allowInternalSystem: true và dùng hộp thư chung Có
Before-change trigger Không: PermissionDeniedError, reason TRIGGER_READ_ONLY Không Không
Preview chỉ đọc hoặc Development Session chỉ đọc Không Không Có

Dùng "actor" làm sender hoặc from trong lần chạy không có người dùng sẽ ném ValidationError.

Giới hạn và lỗi

Giới hạn Notification Email
Mỗi lời gọi 1 đến 200 personnel Tối đa 50 người nhận
Mỗi Project mỗi giờ, mặc định 5.000 người nhận 500 email
Nội dung Tiêu đề 200 ký tự, nội dung 16.384 ký tự Tiêu đề 255 ký tự, nội dung 192 KB

Lời gọi làm vượt giới hạn theo giờ sẽ ném RateLimitError và không gửi gì. Chia các đợt gửi lớn ra nhiều lần chạy job.

Lỗi Khi nào
ValidationError Dữ liệu không hợp lệ, như tiêu đề rỗng, có cả html lẫn text, có subtitle với display: "SIMPLE", "actor" khi không có người dùng, hoặc người nhận là personnel không có địa chỉ email.
NotFoundError resource là "personnel", "notificationChannel", "mailbox" hoặc "record". "object" với resourceId "notification_channel" nghĩa là Workspace chưa thiết lập notification.
PermissionDeniedError details.reason là "EMAIL_SENDER_NOT_GRANTED" khi policy không cho phép người gửi, hoặc "TRIGGER_READ_ONLY" trong before-change trigger. Session chỉ đọc cũng từ chối gửi.
RateLimitError Đã chạm giới hạn theo giờ, hoặc, khi có details.budget, lần thực thi đã dùng hết một ngân sách (xem "Giới hạn của một lần thực thi" trong SDK API reference).
CogoverApiError code là "DUPLICATE_IN_PROGRESS" khi lời gọi cùng key vẫn đang gửi, hoặc "NOTIFICATIONS_DISABLED" / "EMAIL_DISABLED" khi tính năng bị tắt.

Xử lý sự cố

Vấn đề Cần kiểm tra
npm run build báo không có notifications hoặc email Cài SDK từ 0.12.0 bằng npm install @cogover/sdk@latest.
403 hoặc PERMISSION_DENIED khi gọi /notify trên local Dùng Project key cho phép ghi, và không truyền --allow-writes=false.
Request trên local lỗi PROJECT_POLICY_CHANGED Là bình thường sau khi đổi policy: Development Session đã kết thúc. Dừng server local rồi chạy lại cogover-dev run.
404 với "resource": "object" khi gọi /notify Workspace chưa thiết lập notification; hỏi quản trị viên.
notified là 0 Mọi người nhận đều bị loại hoặc chưa có tài khoản người dùng; xem skippedPersonnelIds.
Không thấy notification trong danh sách Chờ vài giây. Kiểm tra kênh thông báo và cài đặt thông báo của chính bạn.
/email/senders trả danh sách rỗng Trên local, policy chỉnh sửa được phải ACTIVE; với version đã publish, duyệt policy cho version đó. "actor" còn cần một hộp thư cá nhân đang hoạt động, còn hộp thư chung phải được kết nối để gửi thư.
403 với EMAIL_SENDER_NOT_GRANTED Thêm hộp thư vào policy, hoặc đặt allowActorMailbox: true. Duyệt lại policy cho version đang active.
404 với "resource": "mailbox" Hộp thư đã bị vô hiệu hóa hoặc xóa, chưa được kết nối để gửi thư, hoặc tài khoản của bạn chưa có hộp thư cá nhân đang hoạt động.
404 với "resource": "record" Kiểm tra slug Object và record ID, và người gọi có đọc được record hay không.
Lời gọi thành công nhưng không nhận được email Kiểm tra thư mục spam và kết nối của hộp thư. Lỗi gửi không được báo lại cho script. Với delivery: "activity", kiểm tra field email của record có địa chỉ hay không.
401 hoặc bị đăng xuất khi gọi API quản trị Tạo lại .cogover-session.curl, và gửi cả x-req-type: 9 lẫn x-req-service: 4.
429 Project đã chạm giới hạn theo giờ; thử lại sau hoặc giãn việc gửi ra.
409 từ ví dụ này Lời gọi cùng key vẫn đang gửi; thử lại sau giây lát.

Đọc thêm

avatar

blog.blogs_related

Giải pháp tùy biến và hợp nhất

Số hóa và tự động hóa hoàn toàn công tác vận hành và quản trị doanh nghiệp với Cogover!

Bắt đầu đổi mới phương thức vận hành và tự chủ hệ thống quản trị công việc của bạn

Dùng thử ngay
CogoverCogover

CÔNG TY CỔ PHẦN STRINGEE

GPKD số 0107938504, đăng ký ngày 27-07-2017, nơi cấp Sở Tài chính Thành phố Hà Nội
Địa chỉ: Tầng 19, Leadvisors Tower, số 643 Phạm Văn Đồng, Phường Nghĩa Đô, Thành phố Hà Nội, Việt Nam

Văn phòng

Hà Nội: Tầng 17, VP2, tòa Sun Square, 21 Lê Đức Thọ, phường Từ Liêm, TP Hà Nội
TP Hồ Chí Minh: Tòa nhà Lô A, Khu chung cư Tân Phước, 153 Lý Thường Kiệt, phường Minh Phụng, TP Hồ Chí Minh
Mỹ: 838 Walker Road, Suite 21-2 Dover, Delaware 19904, United States
Ấn Độ: 804/805, Kosha Kommercial Komplex, Primal Nagar, Malad East, Mumbai - 400097
Dubai: CWS-1V-792740, Amber Gem Tower, Ajman, United Arab Emirates
Đã xác nhận
© 2026 Cogover LLC