package mailstore import ( "encoding/json" "fmt" "galaxy/mail/internal/domain/common" "galaxy/mail/internal/service/acceptgenericdelivery" ) // attachmentRow stores the on-disk JSONB encoding of one // `common.AttachmentMetadata` entry. The encoding is intentionally explicit // (named JSON keys) so the on-disk shape stays decoupled from accidental Go // struct renames. type attachmentRow struct { Filename string `json:"filename"` ContentType string `json:"content_type"` SizeBytes int64 `json:"size_bytes"` } // marshalAttachments returns the JSONB bytes for the attachments column. A // nil/empty slice round-trips as `[]` to keep the column NOT NULL across // equality tests. func marshalAttachments(attachments []common.AttachmentMetadata) ([]byte, error) { rows := make([]attachmentRow, 0, len(attachments)) for _, attachment := range attachments { rows = append(rows, attachmentRow{ Filename: attachment.Filename, ContentType: attachment.ContentType, SizeBytes: attachment.SizeBytes, }) } payload, err := json.Marshal(rows) if err != nil { return nil, fmt.Errorf("marshal attachments: %w", err) } return payload, nil } // unmarshalAttachments decodes the attachments JSONB column into a // domain-friendly slice. nil/empty payloads decode to a nil slice. func unmarshalAttachments(payload []byte) ([]common.AttachmentMetadata, error) { if len(payload) == 0 { return nil, nil } var rows []attachmentRow if err := json.Unmarshal(payload, &rows); err != nil { return nil, fmt.Errorf("unmarshal attachments: %w", err) } if len(rows) == 0 { return nil, nil } out := make([]common.AttachmentMetadata, 0, len(rows)) for _, row := range rows { out = append(out, common.AttachmentMetadata{ Filename: row.Filename, ContentType: row.ContentType, SizeBytes: row.SizeBytes, }) } return out, nil } // marshalTemplateVariables returns the JSONB bytes for the template_variables // column. nil maps round-trip as SQL NULL. func marshalTemplateVariables(variables map[string]any) ([]byte, error) { if variables == nil { return nil, nil } payload, err := json.Marshal(variables) if err != nil { return nil, fmt.Errorf("marshal template variables: %w", err) } return payload, nil } // unmarshalTemplateVariables decodes the template_variables JSONB column. // SQL NULL payloads decode to a nil map. func unmarshalTemplateVariables(payload []byte) (map[string]any, error) { if len(payload) == 0 { return nil, nil } var variables map[string]any if err := json.Unmarshal(payload, &variables); err != nil { return nil, fmt.Errorf("unmarshal template variables: %w", err) } return variables, nil } // payloadAttachmentRow stores the on-disk JSONB encoding of one // `acceptgenericdelivery.AttachmentPayload`. The base64 body stays inline so // the entire payload bundle round-trips as one JSONB value. type payloadAttachmentRow struct { Filename string `json:"filename"` ContentType string `json:"content_type"` ContentBase64 string `json:"content_base64"` SizeBytes int64 `json:"size_bytes"` } // payloadRow stores the on-disk JSONB encoding of one // `acceptgenericdelivery.DeliveryPayload`. delivery_id is intentionally // excluded — the row is keyed by it via the `delivery_payloads` PRIMARY KEY. type payloadRow struct { Attachments []payloadAttachmentRow `json:"attachments"` } // marshalDeliveryPayload returns the JSONB bytes for the delivery_payloads // row. func marshalDeliveryPayload(payload acceptgenericdelivery.DeliveryPayload) ([]byte, error) { rows := make([]payloadAttachmentRow, 0, len(payload.Attachments)) for _, attachment := range payload.Attachments { rows = append(rows, payloadAttachmentRow{ Filename: attachment.Filename, ContentType: attachment.ContentType, ContentBase64: attachment.ContentBase64, SizeBytes: attachment.SizeBytes, }) } encoded, err := json.Marshal(payloadRow{Attachments: rows}) if err != nil { return nil, fmt.Errorf("marshal delivery payload: %w", err) } return encoded, nil } // unmarshalDeliveryPayload decodes the delivery_payloads row into a // domain-friendly DeliveryPayload using deliveryID as the owning identifier. func unmarshalDeliveryPayload(deliveryID common.DeliveryID, encoded []byte) (acceptgenericdelivery.DeliveryPayload, error) { if len(encoded) == 0 { return acceptgenericdelivery.DeliveryPayload{}, fmt.Errorf("unmarshal delivery payload: empty") } var row payloadRow if err := json.Unmarshal(encoded, &row); err != nil { return acceptgenericdelivery.DeliveryPayload{}, fmt.Errorf("unmarshal delivery payload: %w", err) } out := acceptgenericdelivery.DeliveryPayload{DeliveryID: deliveryID} if len(row.Attachments) == 0 { return out, nil } out.Attachments = make([]acceptgenericdelivery.AttachmentPayload, 0, len(row.Attachments)) for _, attachment := range row.Attachments { out.Attachments = append(out.Attachments, acceptgenericdelivery.AttachmentPayload{ Filename: attachment.Filename, ContentType: attachment.ContentType, ContentBase64: attachment.ContentBase64, SizeBytes: attachment.SizeBytes, }) } return out, nil } // marshalRawFields returns the JSONB bytes for the malformed_commands.raw_fields // column. The map is serialised verbatim so future operator queries can match // arbitrary keys. func marshalRawFields(fields map[string]any) ([]byte, error) { if fields == nil { fields = map[string]any{} } payload, err := json.Marshal(fields) if err != nil { return nil, fmt.Errorf("marshal raw fields: %w", err) } return payload, nil } // unmarshalRawFields decodes the malformed_commands.raw_fields column. func unmarshalRawFields(payload []byte) (map[string]any, error) { out := map[string]any{} if len(payload) == 0 { return out, nil } if err := json.Unmarshal(payload, &out); err != nil { return nil, fmt.Errorf("unmarshal raw fields: %w", err) } return out, nil }