@terreno/mcp 56.0.0-beta.0 → 57.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +10 -11
- package/dist/bootstrap.js.map +1 -1
- package/dist/docs/templates/bootstrap/backend/types/models/userTypes.template.md +5 -4
- package/dist/docs/templates/tools/model.template.md +4 -2
- package/dist/docs/upgrades/0.31.0.md +60 -0
- package/dist/docs/upgrades/56.0.0-beta.2.md +59 -0
- package/dist/docs/upgrades/57.0.0-beta.1.md +41 -0
- package/dist/docs/versioned/next/README.md +1 -0
- package/dist/docs/versioned/next/explanation/README.md +7 -1
- package/dist/docs/versioned/next/explanation/deployment-architecture-gcp.md +65 -0
- package/dist/docs/versioned/next/explanation/deployment-baseline.md +78 -0
- package/dist/docs/versioned/next/explanation/positioning.md +164 -0
- package/dist/docs/versioned/next/explanation/repository-settings.md +44 -0
- package/dist/docs/versioned/next/explanation/roadmap-process.md +330 -0
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +887 -0
- package/dist/docs/versioned/next/how-to/README.md +8 -0
- package/dist/docs/versioned/next/how-to/build-for-web.md +69 -0
- package/dist/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +115 -0
- package/dist/docs/versioned/next/how-to/deploy-to-gcp.md +20 -228
- package/dist/docs/versioned/next/how-to/deploy-web-to-gcs-cdn.md +183 -0
- package/dist/docs/versioned/next/implementationPlans/PLAN_INDEX.md +31 -0
- package/dist/docs/versioned/next/implementationPlans/README.md +56 -30
- package/dist/docs/versioned/next/implementationPlans/agentic-sdlc-plugin.md +2 -1
- package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/b2b-platform-program.md +115 -0
- package/dist/docs/versioned/next/implementationPlans/billing-stripe.md +158 -0
- package/dist/docs/versioned/next/implementationPlans/build-terreno-app-validation.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/comms-abstraction.md +323 -0
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-expo-push.md +107 -0
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-sendgrid.md +157 -0
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-twilio-sms.md +155 -0
- package/dist/docs/versioned/next/implementationPlans/comms-adapter-twilio-verify.md +99 -0
- package/dist/docs/versioned/next/implementationPlans/comms-admin-dashboard.md +180 -0
- package/dist/docs/versioned/next/implementationPlans/deploy-to-gcp.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/deploy-to-vercel.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/deployment-foundation.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/docs-reference-coverage.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/docs-tutorials-ai-first.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/examples-demo-coverage.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/infra-mcp-research.md +188 -0
- package/dist/docs/versioned/next/implementationPlans/infra-mcp.md +269 -0
- package/dist/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci-research.md +126 -0
- package/dist/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci.md +269 -0
- package/dist/docs/versioned/next/implementationPlans/native-module-baseline.md +105 -0
- package/dist/docs/versioned/next/implementationPlans/orgs-and-teams.md +158 -0
- package/dist/docs/versioned/next/implementationPlans/oss-governance-baseline.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/password-reset-and-email-verification.md +147 -0
- package/dist/docs/versioned/next/implementationPlans/positioning-django-rails-universal.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/public-roadmap-github.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/upgrade-guides-and-skill.md +1 -0
- package/dist/docs/versioned/next/implementationPlans/web-ssr-and-admin-spa.md +1 -0
- package/dist/docs/versioned/next/reference/README.md +5 -0
- package/dist/docs/versioned/next/reference/admin-spa.md +130 -0
- package/dist/docs/versioned/next/reference/ai.md +519 -0
- package/dist/docs/versioned/next/reference/comms.md +132 -0
- package/dist/docs/versioned/next/reference/environment-variables.md +229 -382
- package/dist/docs/versioned/next/reference/test.md +108 -0
- package/dist/docs/versioned/next/roadmap.md +9 -0
- package/dist/docs/versioned/next/tasks/README.md +42 -0
- package/dist/docs/versioned/next/tasks/agentic-sdlc-plugin.md +5 -5
- package/dist/docs/versioned/next/tasks/billing-stripe.md +75 -0
- package/dist/docs/versioned/next/tasks/comms-abstraction.md +90 -0
- package/dist/docs/versioned/next/tasks/comms-adapter-expo-push.md +29 -0
- package/dist/docs/versioned/next/tasks/comms-adapter-sendgrid.md +39 -0
- package/dist/docs/versioned/next/tasks/comms-adapter-twilio-sms.md +39 -0
- package/dist/docs/versioned/next/tasks/comms-adapter-twilio-verify.md +19 -0
- package/dist/docs/versioned/next/tasks/comms-admin-dashboard.md +52 -0
- package/dist/docs/versioned/next/tasks/deploy-to-gcp.md +11 -11
- package/dist/docs/versioned/next/tasks/deployment-foundation.md +12 -12
- package/dist/docs/versioned/next/tasks/docs-reference-coverage.md +4 -4
- package/dist/docs/versioned/next/tasks/infra-mcp.md +43 -0
- package/dist/docs/versioned/next/tasks/migrate-cicd-to-circleci.md +215 -0
- package/dist/docs/versioned/next/tasks/native-module-baseline.md +42 -0
- package/dist/docs/versioned/next/tasks/orgs-and-teams.md +70 -0
- package/dist/docs/versioned/next/tasks/oss-governance-baseline.md +18 -18
- package/dist/docs/versioned/next/tasks/password-reset-and-email-verification.md +60 -0
- package/dist/docs/versioned/next/tasks/positioning-django-rails-universal.md +4 -4
- package/dist/docs/versioned/next/tasks/public-roadmap-github.md +15 -15
- package/package.json +4 -3
- package/src/docs/templates/bootstrap/backend/types/models/userTypes.template.md +5 -4
- package/src/docs/templates/tools/model.template.md +4 -2
- package/src/docs/upgrades/0.31.0.md +60 -0
- package/src/docs/upgrades/56.0.0-beta.2.md +59 -0
- package/src/docs/upgrades/57.0.0-beta.1.md +41 -0
- package/src/docs/versioned/next/README.md +1 -0
- package/src/docs/versioned/next/explanation/README.md +7 -1
- package/src/docs/versioned/next/explanation/deployment-architecture-gcp.md +65 -0
- package/src/docs/versioned/next/explanation/deployment-baseline.md +78 -0
- package/src/docs/versioned/next/explanation/positioning.md +164 -0
- package/src/docs/versioned/next/explanation/repository-settings.md +44 -0
- package/src/docs/versioned/next/explanation/roadmap-process.md +330 -0
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +887 -0
- package/src/docs/versioned/next/how-to/README.md +8 -0
- package/src/docs/versioned/next/how-to/build-for-web.md +69 -0
- package/src/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +115 -0
- package/src/docs/versioned/next/how-to/deploy-to-gcp.md +20 -228
- package/src/docs/versioned/next/how-to/deploy-web-to-gcs-cdn.md +183 -0
- package/src/docs/versioned/next/implementationPlans/PLAN_INDEX.md +31 -0
- package/src/docs/versioned/next/implementationPlans/README.md +56 -30
- package/src/docs/versioned/next/implementationPlans/agentic-sdlc-plugin.md +2 -1
- package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +1 -0
- package/src/docs/versioned/next/implementationPlans/b2b-platform-program.md +115 -0
- package/src/docs/versioned/next/implementationPlans/billing-stripe.md +158 -0
- package/src/docs/versioned/next/implementationPlans/build-terreno-app-validation.md +1 -0
- package/src/docs/versioned/next/implementationPlans/comms-abstraction.md +323 -0
- package/src/docs/versioned/next/implementationPlans/comms-adapter-expo-push.md +107 -0
- package/src/docs/versioned/next/implementationPlans/comms-adapter-sendgrid.md +157 -0
- package/src/docs/versioned/next/implementationPlans/comms-adapter-twilio-sms.md +155 -0
- package/src/docs/versioned/next/implementationPlans/comms-adapter-twilio-verify.md +99 -0
- package/src/docs/versioned/next/implementationPlans/comms-admin-dashboard.md +180 -0
- package/src/docs/versioned/next/implementationPlans/deploy-to-gcp.md +1 -0
- package/src/docs/versioned/next/implementationPlans/deploy-to-vercel.md +1 -0
- package/src/docs/versioned/next/implementationPlans/deployment-foundation.md +1 -0
- package/src/docs/versioned/next/implementationPlans/docs-reference-coverage.md +1 -0
- package/src/docs/versioned/next/implementationPlans/docs-tutorials-ai-first.md +1 -0
- package/src/docs/versioned/next/implementationPlans/examples-demo-coverage.md +1 -0
- package/src/docs/versioned/next/implementationPlans/infra-mcp-research.md +188 -0
- package/src/docs/versioned/next/implementationPlans/infra-mcp.md +269 -0
- package/src/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci-research.md +126 -0
- package/src/docs/versioned/next/implementationPlans/migrate-cicd-to-circleci.md +269 -0
- package/src/docs/versioned/next/implementationPlans/native-module-baseline.md +105 -0
- package/src/docs/versioned/next/implementationPlans/orgs-and-teams.md +158 -0
- package/src/docs/versioned/next/implementationPlans/oss-governance-baseline.md +1 -0
- package/src/docs/versioned/next/implementationPlans/password-reset-and-email-verification.md +147 -0
- package/src/docs/versioned/next/implementationPlans/positioning-django-rails-universal.md +1 -0
- package/src/docs/versioned/next/implementationPlans/public-roadmap-github.md +1 -0
- package/src/docs/versioned/next/implementationPlans/upgrade-guides-and-skill.md +1 -0
- package/src/docs/versioned/next/implementationPlans/web-ssr-and-admin-spa.md +1 -0
- package/src/docs/versioned/next/reference/README.md +5 -0
- package/src/docs/versioned/next/reference/admin-spa.md +130 -0
- package/src/docs/versioned/next/reference/ai.md +519 -0
- package/src/docs/versioned/next/reference/comms.md +132 -0
- package/src/docs/versioned/next/reference/environment-variables.md +229 -382
- package/src/docs/versioned/next/reference/test.md +108 -0
- package/src/docs/versioned/next/roadmap.md +9 -0
- package/src/docs/versioned/next/tasks/README.md +42 -0
- package/src/docs/versioned/next/tasks/agentic-sdlc-plugin.md +5 -5
- package/src/docs/versioned/next/tasks/billing-stripe.md +75 -0
- package/src/docs/versioned/next/tasks/comms-abstraction.md +90 -0
- package/src/docs/versioned/next/tasks/comms-adapter-expo-push.md +29 -0
- package/src/docs/versioned/next/tasks/comms-adapter-sendgrid.md +39 -0
- package/src/docs/versioned/next/tasks/comms-adapter-twilio-sms.md +39 -0
- package/src/docs/versioned/next/tasks/comms-adapter-twilio-verify.md +19 -0
- package/src/docs/versioned/next/tasks/comms-admin-dashboard.md +52 -0
- package/src/docs/versioned/next/tasks/deploy-to-gcp.md +11 -11
- package/src/docs/versioned/next/tasks/deployment-foundation.md +12 -12
- package/src/docs/versioned/next/tasks/docs-reference-coverage.md +4 -4
- package/src/docs/versioned/next/tasks/infra-mcp.md +43 -0
- package/src/docs/versioned/next/tasks/migrate-cicd-to-circleci.md +215 -0
- package/src/docs/versioned/next/tasks/native-module-baseline.md +42 -0
- package/src/docs/versioned/next/tasks/orgs-and-teams.md +70 -0
- package/src/docs/versioned/next/tasks/oss-governance-baseline.md +18 -18
- package/src/docs/versioned/next/tasks/password-reset-and-email-verification.md +60 -0
- package/src/docs/versioned/next/tasks/positioning-django-rails-universal.md +4 -4
- package/src/docs/versioned/next/tasks/public-roadmap-github.md +15 -15
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Implementation Plan: Pluggable communications layer (@terreno/comms)
|
|
2
|
+
|
|
3
|
+
**Status:** Draft
|
|
4
|
+
**Roadmap issue:** https://github.com/FlourishHealth/terreno/issues/1018
|
|
5
|
+
**Priority:** High
|
|
6
|
+
**Effort:** Big batch
|
|
7
|
+
**Owner:** unassigned
|
|
8
|
+
**Created:** 2026-08-09
|
|
9
|
+
**Program:** [B2B platform](b2b-platform-program.md)
|
|
10
|
+
**Depends on:** none (inbound-webhooks is needed only by adapter delivery-callback phases)
|
|
11
|
+
**RTK deprecation flag:** None — backend-only package
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
|
|
15
|
+
Terreno backends cannot send email, SMS, or push notifications: there is no mail
|
|
16
|
+
integration at all, `expo-server-sdk` sits unused in `@terreno/api`, and the RTK client's
|
|
17
|
+
`resetPassword` call has no backend to talk to. This IP creates `@terreno/comms`, a new
|
|
18
|
+
workspace package that defines **provider interfaces** for the four outbound channels
|
|
19
|
+
(mail, SMS, push, OTP verification), a **`CommsApp`** TerrenoPlugin that registers
|
|
20
|
+
configured providers and exposes send services to routes and other plugins, **push-token
|
|
21
|
+
registration** routes and model, a **delivery log**, and **console adapters** so every
|
|
22
|
+
channel works in development with zero external accounts.
|
|
23
|
+
|
|
24
|
+
Concrete providers (Twilio, SendGrid, Expo push) are separate IPs — one per adapter — so
|
|
25
|
+
consumer apps install only the SDKs they use.
|
|
26
|
+
|
|
27
|
+
## Non-Goals
|
|
28
|
+
|
|
29
|
+
- Any concrete provider SDK integration (see `comms-adapter-*` IPs).
|
|
30
|
+
- In-app notification center, inbox UI, or user notification preferences
|
|
31
|
+
(`notification-center` item).
|
|
32
|
+
- Password reset / email verification flows (`password-reset-and-email-verification` item
|
|
33
|
+
— a consumer of this layer).
|
|
34
|
+
- Inbound webhook handling (`inbound-webhooks` item); this IP only defines the
|
|
35
|
+
`DeliveryEvent` type adapters will feed from callbacks.
|
|
36
|
+
- Broadcast/bulk campaign tooling.
|
|
37
|
+
|
|
38
|
+
## Decisions
|
|
39
|
+
|
|
40
|
+
| Question | Decision |
|
|
41
|
+
|----------|----------|
|
|
42
|
+
| New package or `@terreno/api` module? | New package `@terreno/comms`, mirroring `@terreno/feature-flags` — keeps provider SDKs out of the core dependency tree |
|
|
43
|
+
| How do provider SDKs stay optional? | Adapters live in subpath exports (`@terreno/comms/adapters/<name>`) with their SDKs as `peerDependencies` marked optional; core package has zero provider SDKs |
|
|
44
|
+
| Precedent to follow | `secretProviders.ts` (env + GCP behind one interface) and the OpenFeature provider in `@terreno/feature-flags` |
|
|
45
|
+
| Templates | Minimal: `{subject, text, html}` render helper with variable interpolation; no template builder |
|
|
46
|
+
| Where do send calls log? | `CommsMessage` Mongoose model, mirroring the `AIRequest.logRequest` pattern — logging failures never break the send |
|
|
47
|
+
| Dev experience | `ConsoleMailProvider` / `ConsoleSmsProvider` / `ConsolePushProvider` / `ConsoleVerificationProvider` print to logger and record to `CommsMessage`; default when no provider configured in non-production |
|
|
48
|
+
| Consumer lifecycle hooks | `CommsApp` options expose `beforeSend` / `onSend` / `onError` / `onRetry` / `onOptOut` / `onDeliveryEvent`. Hooks are awaited but wrapped: a throwing hook is logged (`logger.error`) and never breaks the send. `beforeSend` may mutate or cancel |
|
|
49
|
+
| Error taxonomy | Every `SendResult` failure carries `errorCode` (provider-native code as a string) and `errorClass` (`"permanent"` \| `"transient"` \| `"config"`). Adapters own the mapping; the facade uses `errorClass` to decide its single inline retry (transient only) and the admin dashboard uses it to gate manual retries |
|
|
50
|
+
| Payload retention | `retainPayloadDays` option (default 30) keeps the rendered message on the `CommsMessage` row (post `redactPayload` hook) so the admin dashboard can retry; a TTL-style cleanup clears expired payloads |
|
|
51
|
+
|
|
52
|
+
## Architecture
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
@terreno/comms
|
|
56
|
+
src/
|
|
57
|
+
types.ts # Provider interfaces + message/result types
|
|
58
|
+
commsApp.ts # CommsApp TerrenoPlugin
|
|
59
|
+
commsService.ts # sendMail/sendSms/sendPush/startVerification/checkVerification
|
|
60
|
+
models/
|
|
61
|
+
commsMessage.ts # Delivery log
|
|
62
|
+
pushToken.ts # Device push token registry
|
|
63
|
+
routes/
|
|
64
|
+
pushTokens.ts # modelRouter for token registration
|
|
65
|
+
commsExplorer.ts # Admin-only delivery log explorer
|
|
66
|
+
adapters/
|
|
67
|
+
console.ts # Dev adapters for all four channels
|
|
68
|
+
templates.ts # renderTemplate({subject, text, html}, data)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Provider interfaces (types.ts)
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
export interface SendResult {
|
|
75
|
+
providerMessageId?: string;
|
|
76
|
+
accepted: boolean;
|
|
77
|
+
error?: string;
|
|
78
|
+
errorCode?: string; // provider-native code, e.g. "21610", "sendgrid-400"
|
|
79
|
+
errorClass?: "permanent" | "transient" | "config"; // drives retry eligibility
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MailMessage {
|
|
83
|
+
to: string | string[];
|
|
84
|
+
from?: string; // default from CommsApp options
|
|
85
|
+
subject: string;
|
|
86
|
+
text?: string;
|
|
87
|
+
html?: string;
|
|
88
|
+
replyTo?: string;
|
|
89
|
+
metadata?: Record<string, string>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MailProvider {
|
|
93
|
+
readonly id: string; // "console" | "sendgrid" | ...
|
|
94
|
+
sendMail(message: MailMessage): Promise<SendResult>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SmsProvider {
|
|
98
|
+
readonly id: string;
|
|
99
|
+
sendSms(message: {to: string; body: string}): Promise<SendResult>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PushMessage {
|
|
103
|
+
tokens: string[];
|
|
104
|
+
title: string;
|
|
105
|
+
body: string;
|
|
106
|
+
data?: Record<string, unknown>;
|
|
107
|
+
badge?: number;
|
|
108
|
+
sound?: string | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface PushProvider {
|
|
112
|
+
readonly id: string;
|
|
113
|
+
sendPush(message: PushMessage): Promise<SendResult[]>; // one per token
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface VerificationProvider {
|
|
117
|
+
readonly id: string;
|
|
118
|
+
startVerification(options: {to: string; channel: "sms" | "email"}): Promise<SendResult>;
|
|
119
|
+
checkVerification(options: {to: string; code: string}): Promise<{
|
|
120
|
+
valid: boolean;
|
|
121
|
+
error?: string;
|
|
122
|
+
}>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface DeliveryEvent {
|
|
126
|
+
channel: "mail" | "sms" | "push";
|
|
127
|
+
providerMessageId: string;
|
|
128
|
+
status: "delivered" | "bounced" | "failed" | "opened";
|
|
129
|
+
errorCode?: string; // provider failure code from the callback, when present
|
|
130
|
+
raw?: unknown;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface CommsHookContext {
|
|
134
|
+
channel: "mail" | "sms" | "push" | "verification";
|
|
135
|
+
provider: string;
|
|
136
|
+
message: MailMessage | {to: string; body: string} | PushMessage;
|
|
137
|
+
userId?: string;
|
|
138
|
+
isRetry: boolean; // true for facade inline retries and admin-dashboard retries
|
|
139
|
+
messageId?: string; // CommsMessage._id once the log row exists
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface OptOutEvent {
|
|
143
|
+
channel: "mail" | "sms";
|
|
144
|
+
to: string;
|
|
145
|
+
provider: string;
|
|
146
|
+
reason: string; // e.g. "sms-stop", "unsubscribe", "spam-report"
|
|
147
|
+
raw?: unknown;
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Verification start attempts store `metadata.verificationChannel` while recipient values remain
|
|
152
|
+
redacted. Verification codes are never persisted.
|
|
153
|
+
|
|
154
|
+
### CommsApp (TerrenoPlugin)
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
export interface CommsOptions {
|
|
158
|
+
mail?: MailProvider;
|
|
159
|
+
sms?: SmsProvider;
|
|
160
|
+
push?: PushProvider;
|
|
161
|
+
verification?: VerificationProvider;
|
|
162
|
+
defaultFrom?: string; // mail from fallback
|
|
163
|
+
logMessages?: boolean; // default true → CommsMessage
|
|
164
|
+
retainPayloadDays?: number; // default 30; 0 disables retention (and admin retry)
|
|
165
|
+
redactPayload?: (context: CommsHookContext, payload: unknown) => unknown; // before storage
|
|
166
|
+
|
|
167
|
+
// Lifecycle hooks — all optional, all awaited, all wrapped so a throwing
|
|
168
|
+
// hook is logged and never breaks (or blocks) the send itself.
|
|
169
|
+
beforeSend?: (context: CommsHookContext) =>
|
|
170
|
+
Promise<{cancel?: boolean; message?: CommsHookContext["message"]} | void>;
|
|
171
|
+
onSend?: (context: CommsHookContext, result: SendResult) => Promise<void>;
|
|
172
|
+
onError?: (context: CommsHookContext, result: SendResult) => Promise<void>;
|
|
173
|
+
onRetry?: (context: CommsHookContext, attempt: number) => Promise<void>;
|
|
174
|
+
onOptOut?: (event: OptOutEvent) => Promise<void>;
|
|
175
|
+
onDeliveryEvent?: (event: DeliveryEvent) => Promise<void>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
new TerrenoApp({userModel: User})
|
|
179
|
+
.register(new CommsApp({mail: new ConsoleMailProvider(), push: new ExpoPushProvider()}))
|
|
180
|
+
.start();
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`CommsApp.register()` mounts the push-token routes and admin explorer;
|
|
184
|
+
`getCommsService()` (module-level accessor, mirroring how `FeatureFlagsApp` exposes
|
|
185
|
+
evaluation) gives routes and other plugins `sendMail` / `sendSms` / `sendPushToUser` /
|
|
186
|
+
`startVerification` / `checkVerification`. `sendPushToUser(userId, message)` resolves the
|
|
187
|
+
user's active `PushToken`s and prunes tokens the provider reports dead.
|
|
188
|
+
|
|
189
|
+
Unconfigured channel behavior: in production, throw `APIError({status: 501, title:
|
|
190
|
+
"Comms channel not configured"})`; in development, fall back to the console adapter with a
|
|
191
|
+
`logger.warn`.
|
|
192
|
+
|
|
193
|
+
### Hook order and semantics
|
|
194
|
+
|
|
195
|
+
For every send (including retries) the facade runs:
|
|
196
|
+
|
|
197
|
+
1. `beforeSend` — may replace the message (`{message}`) or cancel (`{cancel: true}`,
|
|
198
|
+
logged as a `CommsMessage` with status `cancelled`). The consuming app uses this for
|
|
199
|
+
quiet hours, per-user notification preferences, and suppression lists.
|
|
200
|
+
2. Provider `send*` → on transient failure (`errorClass: "transient"`), `onRetry` fires
|
|
201
|
+
and the facade retries once inline.
|
|
202
|
+
3. `onSend` (accepted) or `onError` (final failure) — `onError` receives the full
|
|
203
|
+
`SendResult` (`errorCode`, `errorClass`) so apps can alert, escalate, or mark domain
|
|
204
|
+
objects without polling the delivery log.
|
|
205
|
+
4. Asynchronously, adapters feed `onDeliveryEvent` (delivery callbacks) and `onOptOut`
|
|
206
|
+
(STOP replies, unsubscribes, spam reports) once their webhook phases land.
|
|
207
|
+
|
|
208
|
+
Hooks are observation and shaping points, not error channels: exceptions thrown inside a
|
|
209
|
+
hook are caught, logged with `logger.error`, and recorded in `CommsMessage.metadata` —
|
|
210
|
+
they never change the send outcome.
|
|
211
|
+
|
|
212
|
+
## Models
|
|
213
|
+
|
|
214
|
+
All fields carry `description`; both models use `createdUpdatedPlugin` + `isDeletedPlugin`
|
|
215
|
+
and live per the five-type pattern (`CommsMessageDocument`, etc.).
|
|
216
|
+
|
|
217
|
+
**PushToken** — `userId` (ref User, required, indexed), `token` (string, required, unique),
|
|
218
|
+
`platform` ("ios" | "android" | "web"), `deviceId` (string, optional), `active` (boolean,
|
|
219
|
+
default true), `lastSeenAt` (date).
|
|
220
|
+
|
|
221
|
+
**CommsMessage** — `channel` ("mail" | "sms" | "push" | "verification"), `provider`
|
|
222
|
+
(string), `to` (string, redacted-at-rest for sms/mail per option), `subject` (string,
|
|
223
|
+
optional), `userId` (ref User, optional), `status` ("sent" | "failed" | "delivered" |
|
|
224
|
+
"bounced" | "cancelled"), `providerMessageId` (string, indexed), `error` (string,
|
|
225
|
+
optional), `errorCode` (string, indexed), `errorClass` ("permanent" | "transient" |
|
|
226
|
+
"config"), `templateId` (string, optional), `attempts` (array of `{at, provider,
|
|
227
|
+
providerMessageId, errorCode, error}` — one entry per facade attempt including the inline
|
|
228
|
+
retry), `attemptCount` (number), `lastAttemptAt` (date), `retriedFromId` / `retriedById`
|
|
229
|
+
(ref CommsMessage — links between an original and its admin-dashboard retries), `payload`
|
|
230
|
+
(Mixed, retained per `retainPayloadDays` after `redactPayload`, cleared on expiry),
|
|
231
|
+
`metadata` (Mixed). Static `logSend()` mirrors `AIRequest.logRequest`.
|
|
232
|
+
|
|
233
|
+
These fields exist so the [comms-admin-dashboard](comms-admin-dashboard.md) IP can filter
|
|
234
|
+
on error codes/classes, show per-attempt history, and re-send from the retained payload
|
|
235
|
+
without any further schema changes.
|
|
236
|
+
|
|
237
|
+
## APIs
|
|
238
|
+
|
|
239
|
+
| Method | Path | Permissions | Notes |
|
|
240
|
+
|---|---|---|---|
|
|
241
|
+
| POST | `/comms/pushTokens` | IsAuthenticated | Upsert by `token`; sets `userId`, `platform`, `lastSeenAt` |
|
|
242
|
+
| GET | `/comms/pushTokens` | IsOwner via queryFilter | List own device tokens |
|
|
243
|
+
| DELETE | `/comms/pushTokens/:id` | IsOwner | Deactivate on logout/uninstall |
|
|
244
|
+
| GET | `/comms/messages` | IsAdmin | Paginated delivery log explorer; filters: `channel`, `status`, `userId`, date range |
|
|
245
|
+
|
|
246
|
+
Push token registration, owner-scoped list, and deactivation use dedicated handlers because
|
|
247
|
+
`modelRouter` create cannot provide idempotent upsert semantics and `OwnerQueryFilter` targets a
|
|
248
|
+
persisted `ownerId` while `PushToken` persists `userId`. Owner read remains on `modelRouter`.
|
|
249
|
+
The explorer uses `createOpenApiBuilder`, modeled on `addAiRequestsExplorerRoutes`, and is
|
|
250
|
+
deliberately minimal here — the [comms-admin-dashboard](comms-admin-dashboard.md) IP upgrades it
|
|
251
|
+
with error-code/class filtering, free-text search, a detail route, stats, and retry endpoints.
|
|
252
|
+
|
|
253
|
+
## Notifications
|
|
254
|
+
|
|
255
|
+
None beyond the feature itself.
|
|
256
|
+
|
|
257
|
+
## UI
|
|
258
|
+
|
|
259
|
+
None in this IP. example-frontend gains a settings row that registers the Expo push token
|
|
260
|
+
(client helper already exists in `store/utils.ts`) once the Expo adapter lands.
|
|
261
|
+
|
|
262
|
+
## Phases
|
|
263
|
+
|
|
264
|
+
1. **Package + contracts:** `@terreno/comms` package scaffold, `types.ts`, console
|
|
265
|
+
adapters, `CommsMessage` + logging, `commsService`, unit tests.
|
|
266
|
+
2. **CommsApp + routes:** plugin registration, `PushToken` model + routes, admin explorer,
|
|
267
|
+
OpenAPI, supertest coverage.
|
|
268
|
+
3. **Integration:** example-backend registers `CommsApp` (console adapters), seed docs,
|
|
269
|
+
`docs/reference/comms.md`, catalog entries, publish wiring in `publish-on-tag.yml`.
|
|
270
|
+
|
|
271
|
+
## Feature Flags & Migrations
|
|
272
|
+
|
|
273
|
+
None. New collections only; no existing data touched.
|
|
274
|
+
|
|
275
|
+
## Activity Log & User Updates
|
|
276
|
+
|
|
277
|
+
Every send attempt is a `CommsMessage` row (that is the audit surface). No user-facing
|
|
278
|
+
updates.
|
|
279
|
+
|
|
280
|
+
## Not Included / Future Work
|
|
281
|
+
|
|
282
|
+
- Adapter IPs: `comms-adapter-expo-push`, `comms-adapter-twilio-sms`,
|
|
283
|
+
`comms-adapter-twilio-verify`, `comms-adapter-sendgrid`.
|
|
284
|
+
- Admin operations dashboard (rich filtering, log digging, manual retries) —
|
|
285
|
+
[comms-admin-dashboard](comms-admin-dashboard.md); this IP only ships the fields and
|
|
286
|
+
hooks it needs.
|
|
287
|
+
- Notification center, user notification preferences (`beforeSend` is the extension
|
|
288
|
+
point they will plug into).
|
|
289
|
+
- Queue-backed sending with automatic scheduled retries — arrives with `job-queues`;
|
|
290
|
+
until then sends are inline with one transient-error retry plus manual admin retries.
|
|
291
|
+
|
|
292
|
+
## Files to Create / Modify
|
|
293
|
+
|
|
294
|
+
- `comms/` (new workspace package: `package.json`, `tsconfig.json`, `biome.jsonc`, `src/*`
|
|
295
|
+
as in Architecture)
|
|
296
|
+
- Root `package.json` — workspace entry + scripts (`comms:compile|lint|test`)
|
|
297
|
+
- `example-backend/src/server.ts` — register `CommsApp`
|
|
298
|
+
- `docs/reference/comms.md`, `.cursor/rules/comms/00-comms.mdc` (via rulesync)
|
|
299
|
+
- `.github/workflows/publish-on-tag.yml`, new `comms-ci` workflow
|
|
300
|
+
|
|
301
|
+
## Task List
|
|
302
|
+
|
|
303
|
+
See [docs/tasks/comms-abstraction.md](../tasks/comms-abstraction.md).
|
|
304
|
+
|
|
305
|
+
## Acceptance Criteria
|
|
306
|
+
|
|
307
|
+
- [x] A Terreno app with only console adapters can call `sendMail`, `sendSms`,
|
|
308
|
+
`sendPushToUser`, and `startVerification`/`checkVerification` in development, each
|
|
309
|
+
producing a logger line and a `CommsMessage` row.
|
|
310
|
+
- [ ] `beforeSend` can cancel and mutate; `onSend`/`onError` fire with the final
|
|
311
|
+
`SendResult`; `onRetry` fires on the inline transient retry — and a hook that throws
|
|
312
|
+
is logged without changing the send outcome (test per hook).
|
|
313
|
+
- [ ] Failed sends record `errorCode` and `errorClass` on both the `SendResult` and the
|
|
314
|
+
`CommsMessage` row; only `transient` failures trigger the inline retry.
|
|
315
|
+
- [ ] `CommsMessage.attempts` holds one entry per attempt with per-attempt error data;
|
|
316
|
+
`payload` is retained post-redaction and cleared after `retainPayloadDays`.
|
|
317
|
+
- [x] `POST /comms/pushTokens` registers a device token for the authenticated user;
|
|
318
|
+
re-posting the same token updates rather than duplicates; other users cannot list or
|
|
319
|
+
delete it.
|
|
320
|
+
- [x] `GET /comms/messages` is admin-only, paginated, and filterable by channel and status.
|
|
321
|
+
- [x] Production apps with an unconfigured channel get a 501 `APIError`, not a silent no-op.
|
|
322
|
+
- [x] `@terreno/comms` core has zero provider SDKs in `dependencies`.
|
|
323
|
+
- [x] All routes appear in `/openapi.json` and the generated SDK compiles.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Implementation Plan: Comms adapter — Expo push notifications
|
|
2
|
+
|
|
3
|
+
**Status:** Draft
|
|
4
|
+
**Roadmap issue:** https://github.com/FlourishHealth/terreno/issues/1019
|
|
5
|
+
**Priority:** High
|
|
6
|
+
**Effort:** Small batch
|
|
7
|
+
**Owner:** unassigned
|
|
8
|
+
**Created:** 2026-08-09
|
|
9
|
+
**Program:** [B2B platform](b2b-platform-program.md)
|
|
10
|
+
**Depends on:** [comms-abstraction](comms-abstraction.md)
|
|
11
|
+
**RTK deprecation flag:** None — backend adapter; client token registration already exists
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
|
|
15
|
+
Implement `PushProvider` on Expo's push service so `sendPushToUser` delivers real
|
|
16
|
+
notifications to iOS/Android devices. `expo-server-sdk` is already a dependency of
|
|
17
|
+
`@terreno/api` (currently unused — it moves to this adapter's peer deps); the client-side
|
|
18
|
+
token fetch (`getExpoPushTokenAsync`) already exists in `example-frontend/store/utils.ts`.
|
|
19
|
+
|
|
20
|
+
## Non-Goals
|
|
21
|
+
|
|
22
|
+
- Direct FCM/APNs integration (future adapter if demand appears).
|
|
23
|
+
- Notification center / inbox UI.
|
|
24
|
+
- Rich notification content (images, actions) beyond title/body/data/badge/sound.
|
|
25
|
+
|
|
26
|
+
## Decisions
|
|
27
|
+
|
|
28
|
+
| Question | Decision |
|
|
29
|
+
|----------|----------|
|
|
30
|
+
| SDK | `expo-server-sdk` as optional peer of `@terreno/comms` (subpath `adapters/expoPush`); removed from `@terreno/api` deps |
|
|
31
|
+
| Receipts | Poll receipts after send (Expo's required pattern); surface as `DeliveryEvent`s to `onDeliveryEvent` |
|
|
32
|
+
| Dead tokens | `DeviceNotRegistered` receipt → `SendResult.accepted: false` with permanent-failure marker so core pruning deactivates the `PushToken` |
|
|
33
|
+
| Chunking | Use the SDK's `chunkPushNotifications`; one `SendResult` per token preserved through chunks |
|
|
34
|
+
|
|
35
|
+
## Architecture
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
// @terreno/comms/adapters/expoPush
|
|
39
|
+
export class ExpoPushProvider implements PushProvider {
|
|
40
|
+
readonly id = "expo";
|
|
41
|
+
constructor(options?: {accessToken?: string; receiptPollDelayMs?: number});
|
|
42
|
+
sendPush(message: PushMessage): Promise<SendResult[]>;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Send flow: validate tokens with `Expo.isExpoPushToken` → chunk → send → collect tickets →
|
|
47
|
+
schedule one receipt poll (default 15 min, configurable; inline timer until `job-queues`
|
|
48
|
+
lands) → map receipt errors to `DeliveryEvent`s and permanent-failure results.
|
|
49
|
+
|
|
50
|
+
## Models
|
|
51
|
+
|
|
52
|
+
None new — uses `PushToken` and `CommsMessage` from the abstraction.
|
|
53
|
+
|
|
54
|
+
## APIs
|
|
55
|
+
|
|
56
|
+
None new.
|
|
57
|
+
|
|
58
|
+
## Notifications
|
|
59
|
+
|
|
60
|
+
This is the notification plumbing itself.
|
|
61
|
+
|
|
62
|
+
## UI
|
|
63
|
+
|
|
64
|
+
example-frontend: register the push token on login via `POST /comms/pushTokens` (guarded by
|
|
65
|
+
`expo-device` physical-device check once the native baseline lands) and a profile-screen
|
|
66
|
+
test-send row in dev builds.
|
|
67
|
+
|
|
68
|
+
## Phases
|
|
69
|
+
|
|
70
|
+
Single phase.
|
|
71
|
+
|
|
72
|
+
## Feature Flags & Migrations
|
|
73
|
+
|
|
74
|
+
None.
|
|
75
|
+
|
|
76
|
+
## Activity Log & User Updates
|
|
77
|
+
|
|
78
|
+
Sends and receipt failures logged to `CommsMessage`.
|
|
79
|
+
|
|
80
|
+
## Not Included / Future Work
|
|
81
|
+
|
|
82
|
+
- Queue-backed receipt polling (moves to `job-queues` when available).
|
|
83
|
+
- Web push (Expo tokens cover native only; web is a future adapter).
|
|
84
|
+
|
|
85
|
+
## Files to Create / Modify
|
|
86
|
+
|
|
87
|
+
- `comms/src/adapters/expoPush.ts` + tests (mock `Expo` client)
|
|
88
|
+
- `comms/package.json` — optional peer `expo-server-sdk`
|
|
89
|
+
- `api/package.json` — remove unused `expo-server-sdk`
|
|
90
|
+
- `example-frontend/app/(tabs)/profile.tsx`, `store/utils.ts` — token registration wiring
|
|
91
|
+
- `docs/reference/comms.md` — adapter section
|
|
92
|
+
|
|
93
|
+
## Task List
|
|
94
|
+
|
|
95
|
+
See [docs/tasks/comms-adapter-expo-push.md](../tasks/comms-adapter-expo-push.md).
|
|
96
|
+
|
|
97
|
+
## Acceptance Criteria
|
|
98
|
+
|
|
99
|
+
- [ ] With a mocked Expo client, `sendPush` returns one `SendResult` per token across chunk
|
|
100
|
+
boundaries.
|
|
101
|
+
- [ ] A `DeviceNotRegistered` receipt deactivates the corresponding `PushToken` via core
|
|
102
|
+
pruning.
|
|
103
|
+
- [ ] Invalid tokens are rejected before hitting the SDK and logged as failed
|
|
104
|
+
`CommsMessage` rows.
|
|
105
|
+
- [ ] `@terreno/api` no longer lists `expo-server-sdk`; apps not using the adapter install
|
|
106
|
+
nothing new.
|
|
107
|
+
- [ ] example-frontend registers its token after login on a physical device build.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Implementation Plan: Comms adapter — transactional email (SendGrid)
|
|
2
|
+
|
|
3
|
+
**Status:** Draft
|
|
4
|
+
**Roadmap issue:** https://github.com/FlourishHealth/terreno/issues/1022
|
|
5
|
+
**Priority:** High
|
|
6
|
+
**Effort:** Small batch
|
|
7
|
+
**Owner:** unassigned
|
|
8
|
+
**Created:** 2026-08-09
|
|
9
|
+
**Program:** [B2B platform](b2b-platform-program.md)
|
|
10
|
+
**Depends on:** [comms-abstraction](comms-abstraction.md); inbound-webhooks (Phase 2 only)
|
|
11
|
+
**RTK deprecation flag:** None — backend only
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
|
|
15
|
+
Implement `MailProvider` on Twilio SendGrid (decision D2 — first mail adapter, sharing the
|
|
16
|
+
Twilio account story with the SMS and Verify adapters) so `sendMail` delivers real
|
|
17
|
+
transactional email. This unblocks `password-reset-and-email-verification` and
|
|
18
|
+
`invitations-and-seats`.
|
|
19
|
+
|
|
20
|
+
## Non-Goals
|
|
21
|
+
|
|
22
|
+
- Other mail providers (Resend/SES/SMTP — items when demand appears).
|
|
23
|
+
- Marketing campaigns, contact lists, SendGrid dynamic template management.
|
|
24
|
+
- Inbound email parsing.
|
|
25
|
+
|
|
26
|
+
## Decisions
|
|
27
|
+
|
|
28
|
+
| Question | Decision |
|
|
29
|
+
|----------|----------|
|
|
30
|
+
| SDK | `@sendgrid/mail` as optional peer, subpath `@terreno/comms/adapters/sendgrid` |
|
|
31
|
+
| Auth | `SENDGRID_API_KEY` env or constructor option; fail fast at registration when missing |
|
|
32
|
+
| From address | Constructor `fromEmail`/`fromName` falling back to `CommsApp.defaultFrom`; sender verification is a documented SendGrid prerequisite, not automated |
|
|
33
|
+
| Templates | Terreno-side rendering (`templates.ts` from the abstraction) sent as `text`/`html`; SendGrid dynamic templates supported pass-through via `templateId` + `dynamicTemplateData` but not required |
|
|
34
|
+
| Sandbox | SendGrid `mail_settings.sandbox_mode` toggle for CI/e2e so tests never send |
|
|
35
|
+
| Delivery events | Phase 2: SendGrid Event Webhook (delivered/deferred/bounce/dropped/spamreport/unsubscribe/open) with ECDSA signature verification via the inbound-webhooks framework, mapped to `DeliveryEvent`s / `OptOutEvent`s |
|
|
36
|
+
| Error classification | Adapter owns the HTTP-status + event-type → `errorClass` map (table below); 429/5xx are `transient`, other 4xx `permanent`, 401/403 `config` |
|
|
37
|
+
| Suppression / opt-outs | Bounces, spam reports, and unsubscribes emit `OptOutEvent` → consumer `onOptOut`. SendGrid maintains its own suppression lists; a send to a suppressed address comes back as a `dropped` event with the suppression reason, classified `permanent` |
|
|
38
|
+
| Admin dashboard support | Every `CommsMessage` row stores `errorCode`/`errorClass`, the `x-message-id`, and a `metadata.consoleUrl` deep link to SendGrid Email Activity for log digging |
|
|
39
|
+
|
|
40
|
+
## Architecture
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// @terreno/comms/adapters/sendgrid
|
|
44
|
+
export class SendGridMailProvider implements MailProvider {
|
|
45
|
+
readonly id = "sendgrid";
|
|
46
|
+
constructor(options?: {
|
|
47
|
+
apiKey?: string; // default env SENDGRID_API_KEY
|
|
48
|
+
fromEmail?: string;
|
|
49
|
+
fromName?: string;
|
|
50
|
+
sandboxMode?: boolean; // default true when NODE_ENV === "test"
|
|
51
|
+
});
|
|
52
|
+
sendMail(message: MailMessage): Promise<SendResult>;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Error mapping: SendGrid 4xx (bad address, unverified sender) → `accepted: false` with the
|
|
57
|
+
response body's first error message in `SendResult.error` and full body in
|
|
58
|
+
`CommsMessage.metadata`; 429/5xx classified `transient` and retried once by the core
|
|
59
|
+
facade. The provider never throws through `sendMail`.
|
|
60
|
+
|
|
61
|
+
### Error classification
|
|
62
|
+
|
|
63
|
+
Send-time failures classify by HTTP status; post-acceptance failures arrive as events
|
|
64
|
+
(Phase 2) and reclassify the `CommsMessage`.
|
|
65
|
+
|
|
66
|
+
| Signal | Meaning | `errorCode` | `errorClass` |
|
|
67
|
+
|---|---|---|---|
|
|
68
|
+
| 400 (bad address, missing field), 413 | Request is wrong | `sendgrid-<status>` + first body error | `permanent` |
|
|
69
|
+
| 401, 403 (bad key, unverified sender) | App misconfiguration | `sendgrid-<status>` | `config` (also `logger.error` at send time) |
|
|
70
|
+
| 429 | Rate limited | `sendgrid-429` | `transient` |
|
|
71
|
+
| 5xx / network errors | SendGrid-side or transport failure | `sendgrid-<status>` | `transient` |
|
|
72
|
+
| Event: `bounce` (hard) | Mailbox permanently rejects | `bounce` + reason | `permanent` |
|
|
73
|
+
| Event: `bounce` (soft) / `deferred` | Temporary mailbox/receiver issue | `deferred` | `transient` |
|
|
74
|
+
| Event: `dropped` | SendGrid suppressed (prior bounce, spam report, unsubscribe, invalid) | `dropped` + reason | `permanent` |
|
|
75
|
+
| Event: `spamreport`, `unsubscribe`, `group_unsubscribe` | Recipient opted out | event type | `permanent` (also emits `OptOutEvent`) |
|
|
76
|
+
|
|
77
|
+
Permanent-class failures are never auto-retried and show as non-retryable in the admin
|
|
78
|
+
dashboard with the reason (retrying a suppressed or unsubscribed address damages sender
|
|
79
|
+
reputation); transient-class failures retry once inline and stay manually retryable.
|
|
80
|
+
|
|
81
|
+
### Event webhook mapping (Phase 2)
|
|
82
|
+
|
|
83
|
+
| SendGrid event | `DeliveryEvent.status` | `CommsMessage.status` |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `processed` | — | `sent` (unchanged) |
|
|
86
|
+
| `delivered` | `delivered` | `delivered` |
|
|
87
|
+
| `deferred` | — (attempt noted in metadata) | `sent` (SendGrid keeps retrying) |
|
|
88
|
+
| `bounce`, `dropped` | `bounced` / `failed` (+ reason as `errorCode`) | `bounced` / `failed` |
|
|
89
|
+
| `open` | `opened` | unchanged (recorded in metadata) |
|
|
90
|
+
| `spamreport`, `unsubscribe`, `group_unsubscribe` | — | unchanged; emits `OptOutEvent` → `onOptOut` |
|
|
91
|
+
|
|
92
|
+
Events correlate to rows via the `x-message-id` response header captured at send time
|
|
93
|
+
(SendGrid appends per-recipient suffixes; match on the prefix).
|
|
94
|
+
|
|
95
|
+
## Models / APIs / Notifications / UI
|
|
96
|
+
|
|
97
|
+
None new. Phase 2 adds one webhook route via the inbound-webhooks framework.
|
|
98
|
+
|
|
99
|
+
## Phases
|
|
100
|
+
|
|
101
|
+
1. **Send path:** provider, config resolution, sandbox mode, error classification,
|
|
102
|
+
`x-message-id` capture + Email Activity deep link, mocked-client tests;
|
|
103
|
+
example-backend env-gated registration; docs (including the sender-verification
|
|
104
|
+
prerequisite checklist).
|
|
105
|
+
2. **Delivery events + opt-outs** *(gated on inbound-webhooks IP)*: Event Webhook route
|
|
106
|
+
with ECDSA signature verification; event mapping per the table →
|
|
107
|
+
`DeliveryEvent`/`OptOutEvent` → `CommsMessage.status` + consumer hooks.
|
|
108
|
+
|
|
109
|
+
## Feature Flags & Migrations
|
|
110
|
+
|
|
111
|
+
None.
|
|
112
|
+
|
|
113
|
+
## Activity Log & User Updates
|
|
114
|
+
|
|
115
|
+
All sends logged to `CommsMessage` with provider `sendgrid`.
|
|
116
|
+
|
|
117
|
+
## Not Included / Future Work
|
|
118
|
+
|
|
119
|
+
- Suppression-list *management* (viewing/clearing SendGrid suppressions from admin) — the
|
|
120
|
+
adapter only reports suppression outcomes; a management surface can extend the
|
|
121
|
+
[comms-admin-dashboard](comms-admin-dashboard.md) later.
|
|
122
|
+
- Unsubscribe-group configuration.
|
|
123
|
+
- Batch sending beyond the multiple-recipient `to` array.
|
|
124
|
+
|
|
125
|
+
## Files to Create / Modify
|
|
126
|
+
|
|
127
|
+
- `comms/src/adapters/sendgrid.ts` + tests
|
|
128
|
+
- `comms/package.json` — optional peer `@sendgrid/mail`
|
|
129
|
+
- `example-backend/src/server.ts` — env-gated registration
|
|
130
|
+
- `docs/reference/comms.md`, `docs/reference/environment-variables.md`
|
|
131
|
+
|
|
132
|
+
## Task List
|
|
133
|
+
|
|
134
|
+
See [docs/tasks/comms-adapter-sendgrid.md](../tasks/comms-adapter-sendgrid.md).
|
|
135
|
+
|
|
136
|
+
## Acceptance Criteria
|
|
137
|
+
|
|
138
|
+
- [ ] `sendMail` delivers text/html mail with from-address fallback chain (provider option
|
|
139
|
+
→ `CommsApp.defaultFrom`), and passes `templateId`/`dynamicTemplateData` through when
|
|
140
|
+
given.
|
|
141
|
+
- [ ] Sandbox mode is on by default under test and produces accepted results without real
|
|
142
|
+
sends.
|
|
143
|
+
- [ ] SendGrid errors surface as `accepted: false` with the SendGrid error message and a
|
|
144
|
+
correct `errorCode`/`errorClass` per the classification table; nothing throws
|
|
145
|
+
through the facade.
|
|
146
|
+
- [ ] 401/403 classify as `config` and `logger.error` at send time; 429/5xx classify as
|
|
147
|
+
`transient` and retry once; other 4xx are `permanent` and never retried.
|
|
148
|
+
- [ ] Each accepted send captures the `x-message-id` and stores a `metadata.consoleUrl`
|
|
149
|
+
Email Activity deep link.
|
|
150
|
+
- [ ] Consumer `onError` fires with the classified `SendResult` on every failed send
|
|
151
|
+
(mocked-client test).
|
|
152
|
+
- [ ] Missing API key fails fast at `CommsApp` registration with a clear error.
|
|
153
|
+
- [ ] Apps not using the adapter do not install `@sendgrid/mail`.
|
|
154
|
+
- [ ] (Phase 2) Event webhook updates `CommsMessage.status` per the mapping table only
|
|
155
|
+
for signature-verified payloads; bounce/dropped reasons land as `errorCode`.
|
|
156
|
+
- [ ] (Phase 2) `spamreport`/`unsubscribe` events fire `onOptOut`; later sends to the
|
|
157
|
+
suppressed address record `dropped` as `permanent`.
|