626 lines
20 KiB
Go
626 lines
20 KiB
Go
package internalhttp
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/mail/internal/domain/attempt"
|
|
"galaxy/mail/internal/domain/common"
|
|
deliverydomain "galaxy/mail/internal/domain/delivery"
|
|
"galaxy/mail/internal/service/listdeliveries"
|
|
)
|
|
|
|
const (
|
|
// ErrorCodeDeliveryNotFound identifies a missing trusted delivery lookup
|
|
// target.
|
|
ErrorCodeDeliveryNotFound = "delivery_not_found"
|
|
|
|
// ErrorCodeResendNotAllowed identifies resend requests against non-terminal
|
|
// deliveries.
|
|
ErrorCodeResendNotAllowed = "resend_not_allowed"
|
|
|
|
deliveryIDPathValue = "delivery_id"
|
|
)
|
|
|
|
// DeliveryListQuery stores the raw trusted query-string values accepted by the
|
|
// operator delivery-list route before normalization.
|
|
type DeliveryListQuery struct {
|
|
// Recipient stores the optional recipient filter covering `to`, `cc`, and
|
|
// `bcc`.
|
|
Recipient string
|
|
|
|
// Status stores the optional delivery-status filter.
|
|
Status string
|
|
|
|
// Source stores the optional delivery-source filter.
|
|
Source string
|
|
|
|
// TemplateID stores the optional template-family filter.
|
|
TemplateID string
|
|
|
|
// IdempotencyKey stores the optional idempotency-key filter.
|
|
IdempotencyKey string
|
|
|
|
// FromCreatedAtMS stores the optional inclusive lower creation-time bound.
|
|
FromCreatedAtMS string
|
|
|
|
// ToCreatedAtMS stores the optional inclusive upper creation-time bound.
|
|
ToCreatedAtMS string
|
|
|
|
// Limit stores the optional page size.
|
|
Limit string
|
|
|
|
// Cursor stores the optional opaque continuation cursor.
|
|
Cursor string
|
|
}
|
|
|
|
// DeliverySummaryResponse stores one brief operator-facing delivery record.
|
|
type DeliverySummaryResponse struct {
|
|
DeliveryID string `json:"delivery_id"`
|
|
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
|
|
Source string `json:"source"`
|
|
PayloadMode string `json:"payload_mode"`
|
|
TemplateID string `json:"template_id,omitempty"`
|
|
To []string `json:"to"`
|
|
Cc []string `json:"cc"`
|
|
Bcc []string `json:"bcc"`
|
|
ReplyTo []string `json:"reply_to"`
|
|
Locale string `json:"locale,omitempty"`
|
|
LocaleFallbackUsed bool `json:"locale_fallback_used"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
Status string `json:"status"`
|
|
AttemptCount int `json:"attempt_count"`
|
|
LastAttemptStatus string `json:"last_attempt_status,omitempty"`
|
|
ProviderSummary string `json:"provider_summary,omitempty"`
|
|
CreatedAtMS int64 `json:"created_at_ms"`
|
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
|
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
|
|
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
|
|
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
|
|
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
|
|
}
|
|
|
|
// DeliveryListResponse stores one deterministic page of brief delivery
|
|
// summaries.
|
|
type DeliveryListResponse struct {
|
|
Items []DeliverySummaryResponse `json:"items"`
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
}
|
|
|
|
// AttachmentResponse stores one durable attachment audit record.
|
|
type AttachmentResponse struct {
|
|
Filename string `json:"filename"`
|
|
ContentType string `json:"content_type"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
}
|
|
|
|
// DeadLetterResponse stores one operator-visible dead-letter entry.
|
|
type DeadLetterResponse struct {
|
|
DeliveryID string `json:"delivery_id"`
|
|
FinalAttemptNo int `json:"final_attempt_no"`
|
|
FailureClassification string `json:"failure_classification"`
|
|
ProviderSummary string `json:"provider_summary,omitempty"`
|
|
CreatedAtMS int64 `json:"created_at_ms"`
|
|
RecoveryHint string `json:"recovery_hint,omitempty"`
|
|
}
|
|
|
|
// DeliveryDetailResponse stores one full operator-facing delivery view.
|
|
type DeliveryDetailResponse struct {
|
|
DeliveryID string `json:"delivery_id"`
|
|
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
|
|
Source string `json:"source"`
|
|
PayloadMode string `json:"payload_mode"`
|
|
TemplateID string `json:"template_id,omitempty"`
|
|
TemplateVariables map[string]any `json:"template_variables,omitempty"`
|
|
To []string `json:"to"`
|
|
Cc []string `json:"cc"`
|
|
Bcc []string `json:"bcc"`
|
|
ReplyTo []string `json:"reply_to"`
|
|
Subject string `json:"subject,omitempty"`
|
|
TextBody string `json:"text_body,omitempty"`
|
|
HTMLBody string `json:"html_body,omitempty"`
|
|
Attachments []AttachmentResponse `json:"attachments"`
|
|
Locale string `json:"locale,omitempty"`
|
|
LocaleFallbackUsed bool `json:"locale_fallback_used"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
Status string `json:"status"`
|
|
AttemptCount int `json:"attempt_count"`
|
|
LastAttemptStatus string `json:"last_attempt_status,omitempty"`
|
|
ProviderSummary string `json:"provider_summary,omitempty"`
|
|
CreatedAtMS int64 `json:"created_at_ms"`
|
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
|
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
|
|
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
|
|
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
|
|
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
|
|
DeadLetter *DeadLetterResponse `json:"dead_letter,omitempty"`
|
|
}
|
|
|
|
// AttemptResponse stores one operator-facing delivery-attempt record.
|
|
type AttemptResponse struct {
|
|
DeliveryID string `json:"delivery_id"`
|
|
AttemptNo int `json:"attempt_no"`
|
|
ScheduledForMS int64 `json:"scheduled_for_ms"`
|
|
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
|
|
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
|
|
Status string `json:"status"`
|
|
ProviderClassification string `json:"provider_classification,omitempty"`
|
|
ProviderSummary string `json:"provider_summary,omitempty"`
|
|
}
|
|
|
|
// DeliveryAttemptsResponse stores the attempt history of one accepted
|
|
// delivery.
|
|
type DeliveryAttemptsResponse struct {
|
|
Items []AttemptResponse `json:"items"`
|
|
}
|
|
|
|
// DeliveryResendResponse stores the identifier of the clone delivery created
|
|
// by one resend request.
|
|
type DeliveryResendResponse struct {
|
|
DeliveryID string `json:"delivery_id"`
|
|
}
|
|
|
|
// DecodeDeliveryListInput validates one trusted operator delivery-list
|
|
// request and returns the normalized list input.
|
|
func DecodeDeliveryListInput(request *http.Request) (listdeliveries.Input, error) {
|
|
if request == nil {
|
|
return listdeliveries.Input{}, errors.New("delivery list request must not be nil")
|
|
}
|
|
|
|
query, err := decodeDeliveryListQuery(request)
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
|
|
input, err := query.Normalize()
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
|
|
return input, nil
|
|
}
|
|
|
|
// DecodeDeliveryIDFromPath validates one trusted path delivery identifier.
|
|
func DecodeDeliveryIDFromPath(request *http.Request) (common.DeliveryID, error) {
|
|
if request == nil {
|
|
return "", errors.New("delivery lookup request must not be nil")
|
|
}
|
|
|
|
return parseDeliveryID(request.PathValue(deliveryIDPathValue))
|
|
}
|
|
|
|
// EncodeDeliveryListCursor encodes cursor into the frozen opaque base64url
|
|
// format `created_at_ms:delivery_id`.
|
|
func EncodeDeliveryListCursor(cursor listdeliveries.Cursor) (string, error) {
|
|
if err := cursor.Validate(); err != nil {
|
|
return "", fmt.Errorf("encode delivery list cursor: %w", err)
|
|
}
|
|
|
|
payload := fmt.Sprintf("%d:%s", cursor.CreatedAt.UTC().UnixMilli(), cursor.DeliveryID.String())
|
|
|
|
return base64.RawURLEncoding.EncodeToString([]byte(payload)), nil
|
|
}
|
|
|
|
// DecodeDeliveryListCursor decodes raw from the frozen opaque cursor format.
|
|
func DecodeDeliveryListCursor(raw string) (listdeliveries.Cursor, error) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return listdeliveries.Cursor{}, errors.New("cursor must not be empty")
|
|
}
|
|
if strings.TrimSpace(raw) != raw {
|
|
return listdeliveries.Cursor{}, errors.New("cursor must not contain surrounding whitespace")
|
|
}
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor: %w", err)
|
|
}
|
|
|
|
createdAtRaw, deliveryIDRaw, ok := strings.Cut(string(payload), ":")
|
|
if !ok {
|
|
return listdeliveries.Cursor{}, errors.New("decode cursor: invalid cursor payload")
|
|
}
|
|
|
|
createdAtMS, err := strconv.ParseInt(createdAtRaw, 10, 64)
|
|
if err != nil {
|
|
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor created_at_ms: %w", err)
|
|
}
|
|
|
|
cursor := listdeliveries.Cursor{
|
|
CreatedAt: time.UnixMilli(createdAtMS).UTC(),
|
|
DeliveryID: common.DeliveryID(deliveryIDRaw),
|
|
}
|
|
if err := cursor.Validate(); err != nil {
|
|
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor: %w", err)
|
|
}
|
|
|
|
return cursor, nil
|
|
}
|
|
|
|
// Normalize converts the raw trusted query-string shape into the operator list
|
|
// input consumed by the service layer.
|
|
func (query DeliveryListQuery) Normalize() (listdeliveries.Input, error) {
|
|
var input listdeliveries.Input
|
|
|
|
recipient, err := parseOptionalEmail(query.Recipient, "recipient")
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
status, err := parseOptionalStatus(query.Status)
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
source, err := parseOptionalSource(query.Source)
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
templateID, err := parseOptionalTemplateID(query.TemplateID)
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
idempotencyKey, err := parseOptionalIdempotencyKey(query.IdempotencyKey)
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
fromCreatedAt, err := parseOptionalUnixMilli(query.FromCreatedAtMS, "from_created_at_ms")
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
toCreatedAt, err := parseOptionalUnixMilli(query.ToCreatedAtMS, "to_created_at_ms")
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
limit, err := parseOptionalLimit(query.Limit)
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
cursor, err := parseOptionalCursor(query.Cursor)
|
|
if err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
|
|
input = listdeliveries.Input{
|
|
Limit: limit,
|
|
Cursor: cursor,
|
|
Filters: listdeliveries.Filters{
|
|
Recipient: recipient,
|
|
Status: status,
|
|
Source: source,
|
|
TemplateID: templateID,
|
|
IdempotencyKey: idempotencyKey,
|
|
FromCreatedAt: fromCreatedAt,
|
|
ToCreatedAt: toCreatedAt,
|
|
},
|
|
}
|
|
if err := input.Validate(); err != nil {
|
|
return listdeliveries.Input{}, err
|
|
}
|
|
|
|
return input, nil
|
|
}
|
|
|
|
func decodeDeliveryListQuery(request *http.Request) (DeliveryListQuery, error) {
|
|
values := request.URL.Query()
|
|
|
|
recipient, err := singleQueryValue(values, "recipient")
|
|
if err != nil {
|
|
return DeliveryListQuery{}, err
|
|
}
|
|
status, err := singleQueryValue(values, "status")
|
|
if err != nil {
|
|
return DeliveryListQuery{}, err
|
|
}
|
|
source, err := singleQueryValue(values, "source")
|
|
if err != nil {
|
|
return DeliveryListQuery{}, err
|
|
}
|
|
templateID, err := singleQueryValue(values, "template_id")
|
|
if err != nil {
|
|
return DeliveryListQuery{}, err
|
|
}
|
|
idempotencyKey, err := singleQueryValue(values, "idempotency_key")
|
|
if err != nil {
|
|
return DeliveryListQuery{}, err
|
|
}
|
|
fromCreatedAtMS, err := singleQueryValue(values, "from_created_at_ms")
|
|
if err != nil {
|
|
return DeliveryListQuery{}, err
|
|
}
|
|
toCreatedAtMS, err := singleQueryValue(values, "to_created_at_ms")
|
|
if err != nil {
|
|
return DeliveryListQuery{}, err
|
|
}
|
|
limit, err := singleQueryValue(values, "limit")
|
|
if err != nil {
|
|
return DeliveryListQuery{}, err
|
|
}
|
|
cursor, err := singleQueryValue(values, "cursor")
|
|
if err != nil {
|
|
return DeliveryListQuery{}, err
|
|
}
|
|
|
|
return DeliveryListQuery{
|
|
Recipient: recipient,
|
|
Status: status,
|
|
Source: source,
|
|
TemplateID: templateID,
|
|
IdempotencyKey: idempotencyKey,
|
|
FromCreatedAtMS: fromCreatedAtMS,
|
|
ToCreatedAtMS: toCreatedAtMS,
|
|
Limit: limit,
|
|
Cursor: cursor,
|
|
}, nil
|
|
}
|
|
|
|
func singleQueryValue(values map[string][]string, key string) (string, error) {
|
|
rawValues := values[key]
|
|
switch len(rawValues) {
|
|
case 0:
|
|
return "", nil
|
|
case 1:
|
|
return rawValues[0], nil
|
|
default:
|
|
return "", fmt.Errorf("query parameter %q must appear at most once", key)
|
|
}
|
|
}
|
|
|
|
func parseDeliveryID(raw string) (common.DeliveryID, error) {
|
|
deliveryID := common.DeliveryID(raw)
|
|
if err := deliveryID.Validate(); err != nil {
|
|
return "", fmt.Errorf("delivery id: %w", err)
|
|
}
|
|
|
|
return deliveryID, nil
|
|
}
|
|
|
|
func parseOptionalEmail(raw string, name string) (common.Email, error) {
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
|
|
email := common.Email(strings.TrimSpace(raw))
|
|
if err := email.Validate(); err != nil {
|
|
return "", fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
|
|
return email, nil
|
|
}
|
|
|
|
func parseOptionalStatus(raw string) (deliverydomain.Status, error) {
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
|
|
status := deliverydomain.Status(strings.TrimSpace(raw))
|
|
if !status.IsKnown() {
|
|
return "", fmt.Errorf("status %q is unsupported", raw)
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
func parseOptionalSource(raw string) (deliverydomain.Source, error) {
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
|
|
source := deliverydomain.Source(strings.TrimSpace(raw))
|
|
if !source.IsKnown() {
|
|
return "", fmt.Errorf("source %q is unsupported", raw)
|
|
}
|
|
|
|
return source, nil
|
|
}
|
|
|
|
func parseOptionalTemplateID(raw string) (common.TemplateID, error) {
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
|
|
templateID := common.TemplateID(strings.TrimSpace(raw))
|
|
if err := templateID.Validate(); err != nil {
|
|
return "", fmt.Errorf("template id: %w", err)
|
|
}
|
|
|
|
return templateID, nil
|
|
}
|
|
|
|
func parseOptionalIdempotencyKey(raw string) (common.IdempotencyKey, error) {
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
|
|
key := common.IdempotencyKey(strings.TrimSpace(raw))
|
|
if err := key.Validate(); err != nil {
|
|
return "", fmt.Errorf("idempotency key: %w", err)
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
func parseOptionalUnixMilli(raw string, name string) (*time.Time, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
timestamp := time.UnixMilli(value).UTC()
|
|
if err := common.ValidateTimestamp(name, timestamp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ×tamp, nil
|
|
}
|
|
|
|
func parseOptionalLimit(raw string) (int, error) {
|
|
if raw == "" {
|
|
return 0, nil
|
|
}
|
|
|
|
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("limit: %w", err)
|
|
}
|
|
if value < 1 {
|
|
return 0, errors.New("limit must be at least 1")
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
func parseOptionalCursor(raw string) (*listdeliveries.Cursor, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
cursor, err := DecodeDeliveryListCursor(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &cursor, nil
|
|
}
|
|
|
|
func summaryResponseFromDelivery(record deliverydomain.Delivery) DeliverySummaryResponse {
|
|
return DeliverySummaryResponse{
|
|
DeliveryID: record.DeliveryID.String(),
|
|
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
|
|
Source: string(record.Source),
|
|
PayloadMode: string(record.PayloadMode),
|
|
TemplateID: record.TemplateID.String(),
|
|
To: emailStrings(record.Envelope.To),
|
|
Cc: emailStrings(record.Envelope.Cc),
|
|
Bcc: emailStrings(record.Envelope.Bcc),
|
|
ReplyTo: emailStrings(record.Envelope.ReplyTo),
|
|
Locale: record.Locale.String(),
|
|
LocaleFallbackUsed: record.LocaleFallbackUsed,
|
|
IdempotencyKey: record.IdempotencyKey.String(),
|
|
Status: string(record.Status),
|
|
AttemptCount: record.AttemptCount,
|
|
LastAttemptStatus: string(record.LastAttemptStatus),
|
|
ProviderSummary: record.ProviderSummary,
|
|
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
|
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
|
SentAtMS: unixMilliPtr(record.SentAt),
|
|
SuppressedAtMS: unixMilliPtr(record.SuppressedAt),
|
|
FailedAtMS: unixMilliPtr(record.FailedAt),
|
|
DeadLetteredAtMS: unixMilliPtr(record.DeadLetteredAt),
|
|
}
|
|
}
|
|
|
|
func detailResponseFromDelivery(record deliverydomain.Delivery, deadLetter *deliverydomain.DeadLetterEntry) DeliveryDetailResponse {
|
|
response := DeliveryDetailResponse{
|
|
DeliveryID: record.DeliveryID.String(),
|
|
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
|
|
Source: string(record.Source),
|
|
PayloadMode: string(record.PayloadMode),
|
|
TemplateID: record.TemplateID.String(),
|
|
TemplateVariables: cloneJSONObject(record.TemplateVariables),
|
|
To: emailStrings(record.Envelope.To),
|
|
Cc: emailStrings(record.Envelope.Cc),
|
|
Bcc: emailStrings(record.Envelope.Bcc),
|
|
ReplyTo: emailStrings(record.Envelope.ReplyTo),
|
|
Subject: record.Content.Subject,
|
|
TextBody: record.Content.TextBody,
|
|
HTMLBody: record.Content.HTMLBody,
|
|
Attachments: attachmentResponses(record.Attachments),
|
|
Locale: record.Locale.String(),
|
|
LocaleFallbackUsed: record.LocaleFallbackUsed,
|
|
IdempotencyKey: record.IdempotencyKey.String(),
|
|
Status: string(record.Status),
|
|
AttemptCount: record.AttemptCount,
|
|
LastAttemptStatus: string(record.LastAttemptStatus),
|
|
ProviderSummary: record.ProviderSummary,
|
|
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
|
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
|
SentAtMS: unixMilliPtr(record.SentAt),
|
|
SuppressedAtMS: unixMilliPtr(record.SuppressedAt),
|
|
FailedAtMS: unixMilliPtr(record.FailedAt),
|
|
DeadLetteredAtMS: unixMilliPtr(record.DeadLetteredAt),
|
|
}
|
|
if deadLetter != nil {
|
|
response.DeadLetter = &DeadLetterResponse{
|
|
DeliveryID: deadLetter.DeliveryID.String(),
|
|
FinalAttemptNo: deadLetter.FinalAttemptNo,
|
|
FailureClassification: deadLetter.FailureClassification,
|
|
ProviderSummary: deadLetter.ProviderSummary,
|
|
CreatedAtMS: deadLetter.CreatedAt.UTC().UnixMilli(),
|
|
RecoveryHint: deadLetter.RecoveryHint,
|
|
}
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
func attemptResponseFromRecord(record attempt.Attempt) AttemptResponse {
|
|
return AttemptResponse{
|
|
DeliveryID: record.DeliveryID.String(),
|
|
AttemptNo: record.AttemptNo,
|
|
ScheduledForMS: record.ScheduledFor.UTC().UnixMilli(),
|
|
StartedAtMS: unixMilliPtr(record.StartedAt),
|
|
FinishedAtMS: unixMilliPtr(record.FinishedAt),
|
|
Status: string(record.Status),
|
|
ProviderClassification: record.ProviderClassification,
|
|
ProviderSummary: record.ProviderSummary,
|
|
}
|
|
}
|
|
|
|
func attachmentResponses(attachments []common.AttachmentMetadata) []AttachmentResponse {
|
|
if len(attachments) == 0 {
|
|
return []AttachmentResponse{}
|
|
}
|
|
|
|
result := make([]AttachmentResponse, len(attachments))
|
|
for index, attachment := range attachments {
|
|
result[index] = AttachmentResponse{
|
|
Filename: attachment.Filename,
|
|
ContentType: attachment.ContentType,
|
|
SizeBytes: attachment.SizeBytes,
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func emailStrings(values []common.Email) []string {
|
|
if len(values) == 0 {
|
|
return []string{}
|
|
}
|
|
|
|
result := make([]string, len(values))
|
|
for index, value := range values {
|
|
result[index] = value.String()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func unixMilliPtr(value *time.Time) *int64 {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
|
|
encoded := value.UTC().UnixMilli()
|
|
return &encoded
|
|
}
|
|
|
|
func cloneJSONObject(value map[string]any) map[string]any {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
|
|
cloned := make(map[string]any, len(value))
|
|
for key, entry := range value {
|
|
cloned[key] = entry
|
|
}
|
|
|
|
return cloned
|
|
}
|