| 127 | } |
| 128 | |
| 129 | func messageViewFromIdentity(m *identity.Message) MessageView { |
| 130 | v := MessageView{ |
| 131 | MessageID: m.ID, |
| 132 | From: m.Sender, |
| 133 | To: orEmptyStrings(m.ToRecipients), |
| 134 | CC: orEmptyStrings(m.CC), |
| 135 | ReplyTo: orEmptyStrings(m.ReplyTo), |
| 136 | Recipient: m.Recipient, |
| 137 | Subject: m.Subject, |
| 138 | ConversationID: m.ConversationID, |
| 139 | Direction: m.Direction, |
| 140 | // `status` is the inbox read-state, identical to the summary view (B2); |
| 141 | // the outbound delivery rollup lives in `delivery_status`, the HITL |
| 142 | // lifecycle in `hitl_status`. (The store resolves m.DeliveryStatus to |
| 143 | // inbox_status for inbound and the rollup for outbound, so the detail |
| 144 | // view must read InboxStatus to agree with the summary.) |
| 145 | Status: m.InboxStatus, |
| 146 | Labels: orEmptyStrings(m.Labels), |
| 147 | CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339), |
| 148 | AuthHeaders: m.AuthHeaders, |
| 149 | Auth: authVerdict(m.Auth), |
| 150 | RawMessage: m.RawMessage, |
| 151 | Flagged: m.Flagged, |
| 152 | FlagReason: m.FlagReason, |
| 153 | } |
| 154 | // Webhook delivery context + raw size — apply to both directions so the |
| 155 | // detail view stays a superset of the summary view (omitempty hides empties). |
| 156 | v.WebhookStatus = m.WebhookStatus |
| 157 | v.WebhookError = m.WebhookError |
| 158 | v.SizeBytes = m.SizeBytes |
| 159 | // Outbound delivery feedback (migration 031). On outbound rows |
| 160 | // identity.Message.DeliveryStatus carries the delivery rollup; on |
| 161 | // inbound rows it carries inbox_status, so these stay empty there. |
| 162 | if m.Direction == "outbound" { |
| 163 | v.DeliveryStatus = m.DeliveryStatus |
| 164 | v.DeliveryDetail = m.DeliveryDetail |
| 165 | v.SentAs = m.SentAs |
| 166 | // HITL lifecycle (status column) — outbound only, mirroring the summary |
| 167 | // view; on inbound rows `status` is not the HITL value (review F1). |
| 168 | v.HITLStatus = m.Status |
| 169 | } |
| 170 | // Parsed view (decision 9): derived from the raw message — any direction |
| 171 | // that carries one (inbound + sent outbound, whose draft body columns were |
| 172 | // scrubbed in favor of the sent MIME). Held outbound drafts have no |
| 173 | // raw_message and surface their body via `body` below instead. |
| 174 | if len(m.RawMessage) > 0 { |
| 175 | pv := mailparse.Parse(m.RawMessage, mailparse.DefaultMaxBytes) |
| 176 | v.Parsed = &MessageParsedView{Text: pv.Text, Truncated: pv.Truncated, HTML: pv.HTML} |
| 177 | } |
| 178 | // Attachment metadata (§6a #5): parsed from raw_message for ANY direction |
| 179 | // that has one (inbound + sent outbound). Always an array; the bytes are |
| 180 | // fetched via the attachment endpoint, never inlined here. |
| 181 | v.Attachments = []AttachmentMetaView{} |
| 182 | if len(m.RawMessage) > 0 { |
| 183 | for i, a := range mailparse.Attachments(m.RawMessage) { |
| 184 | v.Attachments = append(v.Attachments, AttachmentMetaView{ |
| 185 | Index: i, |
| 186 | Filename: a.Filename, |