Đóng góp adapter có sẵn
Hướng dẫn này cho bạn biết cách đóng góp adapter email bằng Go cho ReputeCast: một adapter có sẵn cho một nhà cung cấp gửi thư, được biên dịch vào tệp nhị phân, kiểm thử bằng bộ kiểm thử hợp đồng dùng chung và phát hành cùng mọi phiên bản. Nếu bạn chỉ cần hệ thống của mình kết nối được với một nhà cung cấp, một ánh xạ HTTP chung hoặc một adapter cộng đồng sẽ nhanh hơn; adapter nhà cung cấp so sánh cả ba cách.
Trước khi bắt đầu
Phần tiêu đề “Trước khi bắt đầu”- Một bản checkout kho mã ReputeCast và Go 1.27 (toolchain tự tải về). Chạy ReputeCast từ mã nguồn hướng dẫn thiết lập
make dev. - golangci-lint v2, cho
make lint. - Tài liệu API của nhà cung cấp, chính sách sử dụng được chấp nhận (acceptable-use policy) hiện hành của họ, và các payload webhook mẫu từ tài liệu đó.
docs/dev/CONVENTIONS.mdtrong kho mã, bộ quy ước mà mọi thay đổi đều tuân theo.
Viết adapter
Phần tiêu đề “Viết adapter”-
Đọc hợp đồng trong
internal/providers/provider.go. Với tác giả adapter, tệp này chỉ để đọc. Mọi adapter đều triển khaiproviders.Provider:Phương thức Chức năng Adapter() stringKhóa trong sổ đăng ký, như "mailgun".Capabilities() CapabilitiesCác huy hiệu: chính sách gửi hàng loạt, webhook, API tên miền, đồng bộ danh sách chặn gửi, kích thước lô, IP riêng, sandbox, tự phát hiện hạn mức và tốc độ, giá, theo dõi. Send(ctx, msgs) ([]SendResult, error)Gửi tối đa BatchSizethư và trả về một kết quả cho mỗi thư, theo đúng thứ tự đầu vào.ParseWebhook(ctx, r, body) ([]Event, error)Xác minh yêu cầu và ánh xạ payload của yêu cầu thành sự kiện chuẩn hóa. Tiếp đó, hãy triển khai mọi interface tùy chọn mà API của nhà cung cấp thực sự hỗ trợ. Mã lõi kiểm tra kiểu (type assertion) trước khi dùng các interface này, và các màn hình giải thích những interface còn thiếu:
Interface Dùng cho Verifier,QuotaReporterBước kết nối và tác vụ kiểm tra tình trạng (5 phút một lần, mỗi phút khi nhà cung cấp đang gián đoạn). Quotađược ưu tiên khi có cả hai.ReputationReporterTỷ lệ trả thư và tỷ lệ khiếu nại do chính nhà cung cấp đo, là các tỷ lệ mà ngưỡng xem xét của họ áp dụng. DomainManagerBản ghi DNS kèm trạng thái trực tiếp cho bước tên miền và mục Khả năng vào hộp thư. WebhookRegistrar,WebhookUnregistrarĐăng ký webhook của ReputeCast, và gỡ webhook đó khi nhà cung cấp bị xóa. WebhookInspector,WebhookTesterChẩn đoán “webhook im lặng” và nút “Gửi sự kiện thử”. WebhookVerifier,WebhookHandshakerXác minh trước khi bộ tiếp nhận (ingest) lưu một yêu cầu; trả lời các lần bắt tay (đăng ký SNS, xác thực Event Grid) mà không lưu chúng. WebhookSecretsKhai báo các header mang nguyên văn một khóa bí mật webhook dùng chung, để bộ tiếp nhận bỏ chúng ra khỏi sự kiện gốc được lưu. SuppressionSyncerĐồng bộ hai chiều với danh sách chặn gửi dùng chung. Trả về providers.ErrNotSupportedtừAddSuppressionsnếu chỉ kéo về.TrackingManagerĐọc và tắt tính năng theo dõi phía nhà cung cấp, để lượt mở và lượt nhấp không bị tính hai lần. EventBackfillerLấy trạng thái thư khi webhook im lặng. StatusPage,StatusReporterNút trạng thái và nội dung sự cố khi có sự cố gián đoạn. BroadcastStreamCreatorNhà cung cấp có điều khoản yêu cầu một luồng riêng cho thư hàng loạt (Postmark). ExtrasSenderGửi bản sao và tệp đính kèm; thư có phần bổ sung (extras) chỉ đi qua nhà cung cấp hỗ trợ chúng. -
Tạo package.
internal/providers/resendlà một ví dụ gọn để làm theo:Thư mụcinternal/providers/example/
- example.go chú thích package nêu điều khoản của nhà cung cấp, Descriptor, settings, credentials, New, Send
- webhook.go VerifyWebhook, ParseWebhook, chuẩn hóa
- account.go Verify hoặc Quota, tên miền, đăng ký webhook, danh sách chặn gửi, theo dõi, lấy bù trạng thái
- lang.go catalog thông điệp và các thông điệp của adapter (văn bản cho người đọc)
Thư mụcmessages/
- en.json câu tiếng Anh mà người dùng đọc
- vi.json bản dịch của nó
- example_test.go API giả lập, contracttest.Run, kiểm thử riêng của adapter
Thư mụctestdata/ payload webhook đã ghi lại
- …
-
Viết descriptor, đăng ký nó từ
init, và lấy câu chữ của nó từ catalog thông điệp của adapter:internal/providers/example/example.go //go:embed messages/*.jsonvar messagesFS embed.FSvar msgs = i18n.MustLoad(messagesFS, "providers.example")func init() { providers.Register(Descriptor()) }func Descriptor() providers.Descriptor {return providers.Descriptor{Adapter: "example",Name: "Example ESP",Kind: "api", // api, smtp, self_hosted, httpCapabilities: providers.Capabilities{Bulk: providers.BulkAllowed, // cite the AUP in the package commentWebhooks: "Registered",DomainAPI: "full",SuppressionSync: true,BatchSize: 1,Sandbox: "mode",QuotaDiscovery: true,// Pricing and DedicatedIPs are in messages/en.json.},Version: version.Version,Maintainer: "HostRepute",Terms: []providers.Terms{{Bulk: providers.BulkAllowed,Streams: []providers.Stream{providers.StreamBroadcast, providers.StreamAutomation, providers.StreamSystem}}},Fields: []providers.Field{{Key: "api_key", Secret: true, Required: true},{Key: "region", Options: []string{"us", "eu"}},},New: New,}.WithMessages(msgs)}Câu chữ mà người dùng đọc nằm trong
messages/en.json, kèm mộtnotekhi người dịch cần giải thích, còn bản dịch nằm trongmessages/vi.json, theo thuật ngữ củadocs/i18n/glossary-vi.md:internal/providers/example/messages/en.json {"pricing": { "message": "plan, from $10 per month", "note": "on a capability chip: Pricing · …" },"terms.0.note": { "message": "Opted-in lists only…", "note": "what the terms allow, on Routing" },"events": "Webhooks","domainSetup": "API","suppressionMode": "Two-way","field.api_key.label": "API key","field.api_key.help": { "message": "Example › Settings › API keys.", "note": "Example's own menus" },"field.region.label": "Region"}Các huy hiệu phải đúng với nhà cung cấp tại thời điểm hiện tại:
- Bulk:
BulkAllowed,BulkBroadcastStreams(chỉ trên một luồng riêng),BulkForbiddenhoặcBulkYourPolicy(tự triển khai). Trích dẫn chính sách sử dụng được chấp nhận của nhà cung cấp trong chú thích package, kèm URL và ngày bạn kiểm tra, và ghi các điều kiện quan trọng vàoTerms.Note(thông điệpterms.0.note): màn hình định tuyến hiển thị nội dung này. - Sandbox:
detection,test token,modehoặctest addresses; để trống nếu không có. - Tự phát hiện hạn mức và tốc độ: chỉ đặt khi một lệnh gọi API trả về các giá trị này.
- Giá và IP riêng: tóm tắt ngắn trong các thông điệp
pricingvàdedicatedIps; nên ghi tên gói thay vì một mức giá sẽ sớm lỗi thời. - Theo dõi: đặt
RewritesLinkshoặcAddsOpenPixelkhi nhà cung cấp theo dõi theo mặc định và adapter không tắt được tính năng này cho từng thư. - Các thông điệp
events,domainSetupvàsuppressionModelà các từ mà tab Adapter hiển thị, như “SNS webhooks”, “API” và “Two-way”.
- Bulk:
-
Viết văn bản cho người đọc. Những gì adapter nói với người dùng hiển thị bằng ngôn ngữ của từng người đọc: văn bản máy chủ theo ngôn ngữ ứng dụng của người đọc (
i18n.LangFrom(ctx)), còn client API không gửiAccept-Languagenhận tiếng Anh. Catalog là toàn cục, nên mã lõi đọc được một thông điệp đã lưu mà không cần import adapter.- Câu chữ của descriptor.
WithMessages(msgs)điền descriptor từ các khóa quy ước:field.<key>.labelvàfield.<key>.help,terms.<n>.variantvàterms.<n>.note(Termsthứ n, tính từ 0),pricing,dedicatedIps, và các từ của tab Adapterevents,domainSetupvàsuppressionMode. Logic vẫn so sánh theo câu tiếng Anh. - Những gì adapter viết lúc chạy giữ thông điệp của nó bên cạnh câu tiếng Anh:
Quota.AccountText(tình trạng tài khoản),ConnectionReport.SummaryText,ProvisionStep.LabelText,ProvisionResult.DoneText,Tracking.DetailTextvàScopeText,ServiceStatus.SummaryText, vàCallError.Textcho lỗi do adapter tự diễn đạt. Hãy trả về câu tiếng Anh như trước và thông điệp bằngmsg.Keep(args); phần lớn adapter có một hàm hỗ trợsay(m, args)tronglang.gotrả về cả hai. Truyền số dưới dạng số và thời gian dưới dạngtime.Time, để{n, number}và{at, time}theo định dạng khu vực của người đọc. - Tên giữ nguyên: tên nhà cung cấp và từ ngữ riêng của họ cho gói, khóa, menu và loại luồng, các phản hồi của họ (mã lỗi, thông điệp API), tên sự kiện, log và lỗi nội bộ.
- Văn bản được lưu. Mã lưu văn bản của adapter cho người đọc sau này (tình trạng nhà cung cấp, trạng thái thiết lập, một sự cố) lưu câu tiếng Anh và thông điệp. Một dòng được ghi trước khi thông điệp của nó được lưu sẽ hiển thị bằng tiếng Anh.
Adapter SMS (
internal/sms/<name>) hoạt động theo cùng cách: namespace catalog của chúng làsms.<name>,Manifest.WithMessagesđọc các khóasummary,badge.<capability>.note,field.<key>.labelvàfield.<key>.help, vàinternal/sms/contracttestkiểm tra các văn bản này. - Câu chữ của descriptor.
-
Xử lý settings và thông tin xác thực.
providers.Config.Settingschứa các trường không bí mật;Config.Credentialschứa các giá trị bí mật đã giải mã dưới dạng một đối tượng JSON phẳng, có khóa là khóa của các trườngSecret. Thông tin xác thực là chỉ ghi: được niêm phong khi lưu trữ, không bao giờ được API trả về, và được thay thế từng khóa một. Hãy đánh dấuSecretcho mọi trường có khóa trông giống bí mật (password,token,secret, hoặc kết thúc bằngapi_key), nếu không bộ kiểm thử hợp đồng sẽ báo lỗi.Newkhông được thực hiện I/O mạng và không được thất bại vì thiếu một giá trị tùy chọn; hãy báo thiếu thông tin xác thực từ lệnh gọi đầu tiên. Factory tạo một adapter cho mỗi yêu cầu, nên hãy giữ những gì đáng dùng lại trong một bộ nhớ đệm cấp package, theo khóa là tài khoản. Hãy giữ vật liệu ký (signing material) đọc từ API của nhà cung cấp trong mộtwebhookauth.Material; nó đọc lại vật liệu này khi một chữ ký không khớp (tối đa mỗi phút một lần) và mỗi giờ. -
Triển khai
Send:- Trả về một
SendErrorcho từng thư khi lời từ chối chỉ liên quan đến thư đó (địa chỉ không hợp lệ, người gửi chưa xác minh). Trả về một lỗi khi tài khoản hoặc dịch vụ gặp sự cố, bọcproviders.ErrUnauthorized(thông tin xác thực bị từ chối) hoặcproviders.ErrUnavailable(sự cố gián đoạn).apiclientthực hiện việc ánh xạ này:Docho lệnh gọi JSON,Execcho nội dung dạng form hoặc multipart, yêu cầu có ký và header phản hồi. 401 và 403 trở thành thông tin xác thực bị từ chối, 429 là giới hạn tốc độ, 408 và 5xx là sự cố gián đoạn;apiclient.MessageErrorbiến các phản hồi 4xx khác thành mộtSendErrorvĩnh viễn. - Với API nhận một lệnh gọi cho mỗi thư, hãy dùng
apiclient.Fanout: hàm này giữ đúng thứ tự kết quả, không bắt đầu lệnh gọi mới sau một lỗi cấp tài khoản, và gán lỗi tạm thời cho các thư chưa gửi để bộ máy gửi thử lại. - Gửi kèm mã thư ReputeCast vào bất kỳ trường nào mà nhà cung cấp trả lại trong sự kiện (custom args, tag, metadata), và gửi header
X-ReputeCast-Message. - Nếu nhà cung cấp hỗ trợ idempotency, hãy suy ra khóa từ mã thư, để một lần thử lại sau khi hết thời gian chờ không bao giờ gửi hai lần.
- Tắt tính năng theo dõi lượt mở và lượt nhấp của nhà cung cấp trên mọi thư: ReputeCast tự theo dõi.
- Không bao giờ đánh dấu một thư đã được chấp nhận là thất bại. Nếu nhà cung cấp chấp nhận thư nhưng không trả về mã, hãy dùng mã ReputeCast làm mã của nhà cung cấp.
- Trả về một
-
Xác minh và chuẩn hóa webhook:
VerifyWebhookphải trả về một lỗi bọcproviders.ErrBadSignaturecho mọi thất bại, kể cả “không lấy được khóa”. Nếu nhận bất kỳ lỗi nào khác, bộ tiếp nhận vẫn lưu yêu cầu như thể đã được xác minh.ParseWebhookxác minh, trừ khiproviders.SignatureVerified(ctx)đã được đặt. Tác vụ chuẩn hóa đặt giá trị này khi phân tích lại một payload đã lưu, vốn không còn URL gốc hay headerAuthorization.- Dùng cơ chế mà nhà cung cấp dùng để ký;
internal/providers/webhookauthcó Standard Webhooks, các hàm hỗ trợ HMAC và phép so sánh thời gian hằng định. Nếu nhà cung cấp không ký, hãy đăng ký webhook bằng basic auth hoặc một bearer token mangConfig.WebhookSecret; giá trị này đi trongAuthorizationvà không bao giờ được lưu. - Giới hạn timestamp đã ký: năm phút với
webhookauth.Fresh, hoặc một khoảng bao trùm các lần thử lại của nhà cung cấp vớiwebhookauth.Within. - Ánh xạ mọi sự kiện liên quan đến việc gửi thành
delivered,deferred,hard_bounce,soft_bounce,complaint,rejectedhoặcunsubscribe. Giữ loại sự kiện của nhà cung cấp trongOriginalType, mã của họ trongOriginalCode(tách bằngbounce.Split), phần còn lại trongDiagnostic, và đặtBounceClassbằngbounce.Classify. Viết thường địa chỉ người nhận, giữ JSON gốc trongRaw, và không trả về sự kiện nào cho những thứ không phải kết quả gửi (sent, processed, thư trả lời tự động, ping thử). - Đừng chặn gửi dựa trên phỏng đoán: một thất bại vĩnh viễn do phía người gửi gây ra (bị chặn vì spam hoặc uy tín, hết lượt thử lại) là trả thư tạm thời. Chỉ lỗi về địa chỉ mới là trả thư vĩnh viễn, vì các trường hợp trả thư vĩnh viễn và khiếu nại được đưa vào danh sách chặn gửi dùng chung.
-
Kiểm thử bằng bộ kiểm thử hợp đồng. Mọi adapter đều phải vượt qua
internal/providers/contracttest. Bộ kiểm thử này kiểm tra việc đăng ký, khả năng, một lần gửi, một lần từ chối vĩnh viễn, một sự cố gián đoạn và thông tin xác thực bị từ chối với API giả lập của bạn, cùng mọi webhook đã ghi lại: được xác minh, được chuẩn hóa, được phân tích lại với context đã xác minh, và bị từ chối khi bị sửa đổi. Bài kiểm thử conTextskiểm tra văn bản cho người đọc: một adapter có sẵn thuộc loạiapi,smtp,self_hostedhoặchttpphải có catalog, mọi văn bản nó khai báo đều có một thông điệp với câu tiếng Anh đúng bằng văn bản đó, và mọi thông điệp đều được dịch sang từng ngôn ngữ của ứng dụng.contracttest.CheckTextkiểm tra một văn bản mà adapter viết lúc chạy.internal/providers/example/example_test.go func TestContract(t *testing.T) {contracttest.Run(t, contracttest.Suite{Adapter: example.Adapter,API: fakeAPI, // reads env.Mode(): "", contracttest.ModeOutage, contracttest.ModeAuthNew: newProvider,Delivered: func(t *testing.T, env *contracttest.Env, msgs []providers.Message, res []providers.SendResult) {// check env.Requests(): headers, the echoed message id, tracking off},Webhooks: []contracttest.Webhook{{Name: "HardBounce",Request: signed("testdata/bounce.json"), // signed at test time with a test keyWant: []providers.Event{{Type: providers.EventHardBounce, Recipient: "[email protected]", OriginalCode: "550 5.1.1", BounceClass: "mailbox_unknown"}},}},// Tamper defaults to flipping a body byte; override it when the// signature doesn't cover the body or auth is a header.})}Kiểm thử không dùng mạng: hãy chạy một bản giả lập API của nhà cung cấp, ghi lại payload webhook từ tài liệu của nhà cung cấp làm fixture, và ký chúng ngay trong kiểm thử bằng một khóa thử. Nếu nhà cung cấp công bố vector kiểm thử chữ ký, hãy kiểm thử với vector đó. Hãy kiểm thử các interface tùy chọn, việc ánh xạ lỗi và mọi điểm bất thường mà bạn phải xử lý riêng (mã lớn hơn 2^53, một trường lúc là chuỗi lúc là mảng, một phản hồi 400 mang kết quả theo từng thư).
-
Đăng ký adapter vào bản dựng:
- Thêm một blank import của package vào
internal/app/wire_adapters.go. Đăng ký cùng một khóa hai lần sẽ gây panic khi khởi động. - Thêm khóa vào
adapterOrdertronginternal/providers/manage/adapters.go(thứ tự trên tab Adapter) và vàocatalogOrdertronginternal/connect/catalog.go(thứ tự trong danh mục thiết lập). - Không bắt buộc: thêm câu chữ cho trình hướng dẫn kết nối trong
copyTabletạiinternal/connect/copy.go, với câu chữ là các thông điệpadapter.<key>.*tronginternal/connect/messages/en.jsonvàvi.json; nếu không có mục nào, trình hướng dẫn dùng tên và các trường của descriptor. - Adapter có sẵn mang phiên bản ReputeCast (
version.Version) và người duy trì “HostRepute”.
- Thêm một blank import của package vào
Kiểm tra kết quả
Phần tiêu đề “Kiểm tra kết quả”-
Chạy kiểm thử của adapter và linter:
Terminal go test ./internal/providers/example/golangci-lint run ./internal/providers/...make testchạy mọi kiểm thử Go và web, với Postgres phát triển nếu Postgres đang chạy;make lintchạy mọi linter. -
Khởi động ứng dụng bằng
make dev, đăng nhập và mở Nhà cung cấp gửi thư, rồi Adapter. Adapter xuất hiện trong danh sách adapter có sẵn với phiên bảndevvà người duy trì HostRepute, và Nhà cung cấp gửi thư, rồi Thêm nhà cung cấp có adapter này trong danh mục.
Khắc phục sự cố
Phần tiêu đề “Khắc phục sự cố”- Máy chủ panic với
providers: duplicate adapter <key>: một adapter khác đã đăng ký khóa đó. Hãy chọn một khóaAdapterkhông trùng. - Bộ kiểm thử hợp đồng báo lỗi một trường kết nối: khóa của trường trông giống bí mật (
password,token,secret,…api_key). Hãy đặtSecret: true. - Bài kiểm thử
Textscủa bộ kiểm thử hợp đồng báo adapter “keeps no catalog”: descriptor không nạp thông điệp của nó. Hãy nhúngmessages/*.json, nạp chúng bằngi18n.MustLoad, và kết thúcDescriptor()bằng.WithMessages(msgs). Textsbáo một văn bản “has no message” hoặc catalog “isn’t translated into Tiếng Việt”: hãy thêm khóa vàomessages/en.jsonvới đúng câu tiếng Anh mà adapter dùng, hoặc thêm bản dịch của nó vàomessages/vi.json.- Một webhook bị sửa đổi vẫn được chấp nhận, hoặc một webhook bị từ chối lại được lưu là đã xác minh:
VerifyWebhookđã trả về một lỗi không bọcproviders.ErrBadSignature. - Webhook đã lưu thất bại khi được chuẩn hóa:
ParseWebhookxác minh lại dùproviders.SignatureVerified(ctx)đã được đặt; yêu cầu đã lưu không có headerAuthorization. - Sự kiện không khớp với thư: nhà cung cấp không gửi lại mã thư ReputeCast. Hãy truyền mã này trong metadata, tag hoặc custom args của nhà cung cấp, và gửi header
X-ReputeCast-Message. - Lượt mở hoặc lượt nhấp bị tính hai lần: nhà cung cấp vẫn đang theo dõi. Hãy tắt tính năng theo dõi của họ trên mọi thư, hoặc triển khai
TrackingManagerđể tắt tính năng này cho cả tài khoản.
Liên quan
Phần tiêu đề “Liên quan”- Adapter nhà cung cấp giải thích ba loại adapter và cách chọn.
- Sự kiện gửi thư và đồng bộ danh sách chặn gửi mô tả các sự kiện chuẩn hóa mà adapter của bạn tạo ra.
- Viết adapter cộng đồng xây dựng một adapter chạy trong tiến trình riêng.
- Chạy ReputeCast từ mã nguồn trình bày
make deps,make dev,make testvàmake lint.