DeliverOutbound is the shared send/reply/forward delivery tail, HTTP-free: HITL hold (HoldForApprovalCore), else self-send loopback, else SES send + record outbound + publish sent event. The caller has already authed, resolved + ownership-checked the agent, rate-limited, domain-verified, run the mes
(ctx context.Context, user *identity.User, agent *identity.AgentIdentity, req outbound.SendRequest, msgType, replyToEmailMessageID string, referenced *identity.Message)
| 1124 | // extraction). On a nil-error return the side effect has committed, so the |
| 1125 | // idempotency key must be Completed (cached), never Released. |
| 1126 | func (a *API) DeliverOutbound(ctx context.Context, user *identity.User, agent *identity.AgentIdentity, req outbound.SendRequest, msgType, replyToEmailMessageID string, referenced *identity.Message) (*OutboundResult, *OutboundError) { |
| 1127 | // Suppression enforcement (decision 9 / Slice 4b): fail fast if any |
| 1128 | // recipient is on this tenant's suppression list. Enforced fresh on every |
| 1129 | // attempt and NOT cached under the idempotency key (it's a clearable state, |
| 1130 | // released like every other error). |
| 1131 | if supErr := a.checkSuppression(ctx, user.ID, req); supErr != nil { |
| 1132 | return nil, supErr |
| 1133 | } |
| 1134 | |
| 1135 | // Outbound screening (Slice 5): the recipient gate (outbound_policy) + content |
| 1136 | // scan (outbound_scan) combine into one applied action. block ⇒ refuse; |
| 1137 | // review ⇒ hold; flag ⇒ send + annotate; allow ⇒ send. |
| 1138 | verdict := a.screenOutbound(ctx, agent, req) |
| 1139 | if verdict.Block() { |
| 1140 | // Egress block: refuse to the caller. No message row is persisted; the |
| 1141 | // audit lives in protection_events keyed to a STABLE soft-ref id so a |
| 1142 | // retried block doesn't write duplicate audit rows / events. |
| 1143 | a.auditRowless(ctx, agent, blockAuditID(agent.ID, req), req, verdict) |
| 1144 | a.emitBlockedOutbound(agent, blockAuditID(agent.ID, req), req, verdict) |
| 1145 | return nil, &OutboundError{http.StatusForbidden, "blocked_by_policy", "message blocked by outbound policy"} |
| 1146 | } |
| 1147 | |
| 1148 | // Hold when outbound screening says review. The outbound recipient gate |
| 1149 | // (outbound_policy: allowlist+review is the trust-ramp) + content scan now |
| 1150 | // fully own the hold decision — hitl_enabled/hitl_mode were retired in |
| 1151 | // Slice 5b (their behavior is mapped forward by migration 042). |
| 1152 | if verdict.Review() { |
| 1153 | msg, err := a.HoldForApprovalCore(ctx, agent, req, msgType, replyToEmailMessageID) |
| 1154 | if err != nil { |
| 1155 | if errors.Is(err, errHoldAttachments) { |
| 1156 | return nil, &OutboundError{http.StatusInternalServerError, "internal_error", "failed to serialize attachments"} |
| 1157 | } |
| 1158 | return nil, &OutboundError{http.StatusInternalServerError, "internal_error", "failed to hold message for approval"} |
| 1159 | } |
| 1160 | // Tag the held row + audit only when screening drove the hold (a pure |
| 1161 | // legacy-HITL hold carries no screening verdict). |
| 1162 | if verdict.Annotate() { |
| 1163 | a.annotateAndAudit(ctx, agent, msg.ID, req, verdict) |
| 1164 | } |
| 1165 | return &OutboundResult{Held: true, PendingMessageID: msg.ID, ApprovalExpiresAt: msg.ApprovalExpiresAt}, nil |
| 1166 | } |
| 1167 | |
| 1168 | // Record usage (side-effect only — never block on quota; the cap |
| 1169 | // pre-check is the gate). |
| 1170 | if _, err := a.usage.RecordAndCheck(ctx, user.ID, agent.ID, agent.Domain, "outbound"); err != nil { |
| 1171 | log.Printf("[api] usage recording error: %v", err) |
| 1172 | } |
| 1173 | |
| 1174 | if isSelfSend(req, agent.EmailAddress()) { |
| 1175 | providerID, err := a.performSelfSend(ctx, agent, req, msgType) |
| 1176 | if err != nil { |
| 1177 | log.Printf("[api] self-send failed: agent=%s error=%v", agent.EmailAddress(), err) |
| 1178 | return nil, &OutboundError{http.StatusInternalServerError, "internal_error", "self-send failed"} |
| 1179 | } |
| 1180 | slug, _, _ := strings.Cut(agent.EmailAddress(), "@") |
| 1181 | log.Printf("[mail] dir=outbound type=%s method=loopback from=%s to=%s slug=%s conv_id=%s subject=%q provider_id=%s", msgType, agent.EmailAddress(), agent.EmailAddress(), slug, req.ConversationID, req.Subject, providerID) |
| 1182 | return &OutboundResult{MessageID: providerID, Method: "loopback"}, nil |
| 1183 | } |