Implements Phase 5 of AGENTS.md §11 (FR-8, FR-15): - Message detail pane (GET /partials/message) with body, recommended action, reply identity, template picker, editable draft, and history. - internal/reply: OmniRoute (OpenAI-compatible) chat client and a template-based draft generator. All non-JEV models go through OmniRoute (§10). - internal/mailops: SMTP send over smtp.gmail.com:587 with STARTTLS and the app password, forcing IPv4 (§9); FR-15 reply identity (send as the arrived-at X-Pm-Original-To address, else "Sam Rolfe"); threading via In-Reply-To/References; -check-smtp safe verification; and a [Gmail]/Sent Mail thread check. - Migration v5 stores messages.message_id_header and drafts.sent_at; mailfetch parses Message-ID. - Send is user-triggered only: CSRF + UI confirm + confirm=yes guard, no auto-send, duplicate-send refused. A sent reply files the original under Replied-to/<address> and logs every step (FR-13). - Tests for reply, mailops SMTP/identity/thread check, store drafts and migration v5, and the server draft/edit/send guards. - docs/phase5-replies.md, README, .env.example, docker-compose updated. Co-Authored-By: Paperclip <noreply@paperclip.ing>
86 lines
4.0 KiB
HTML
86 lines
4.0 KiB
HTML
<div class="panel-head"><h2>Message</h2></div>
|
|
<div class="detail-body">
|
|
{{if .Note}}<div class="scan-note" role="status">{{.Note}}</div>{{end}}
|
|
{{if .Error}}<div class="scan-note scan-note-error" role="alert">{{.Error}}</div>{{end}}
|
|
|
|
<div class="detail-meta">
|
|
<div class="detail-subject">{{.Message.Subject}}</div>
|
|
<div class="muted">{{.Message.FromAddr}}</div>
|
|
{{if .Message.SourceAddress}}<div class="msg-source">arrived at <span class="source-addr">{{.Message.SourceAddress}}</span></div>{{end}}
|
|
<div class="detail-pills">
|
|
<span class="pill action-pill">{{.Action}}</span>
|
|
{{if .Classified}}<span class="msg-confidence" title="Classifier confidence">{{.Confidence}}%</span>{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<details class="detail-original" open>
|
|
<summary>Original message</summary>
|
|
<pre class="detail-text">{{.Message.BodyText}}</pre>
|
|
</details>
|
|
|
|
<section class="reply-box" aria-label="Reply">
|
|
<h3>Reply</h3>
|
|
<div class="reply-identity">
|
|
<div><span class="muted">From</span> <span class="reply-value">{{.From}}</span></div>
|
|
<div><span class="muted">To</span> <span class="reply-value">{{.To}}</span></div>
|
|
<div><span class="muted">Subject</span> <span class="reply-value">{{.Subject}}</span></div>
|
|
</div>
|
|
{{if .NoThreadHeader}}<p class="faint">This message has no stored Message-ID, so the reply cannot set threading headers.</p>{{end}}
|
|
|
|
<form class="reply-draft-form" hx-post="/actions/draft" hx-target="#message-detail" hx-swap="innerHTML">
|
|
<input type="hidden" name="csrf" value="{{.CSRFToken}}">
|
|
<input type="hidden" name="id" value="{{.Message.ID}}">
|
|
<div class="field">
|
|
<label for="reply-template">Template</label>
|
|
<select class="text-input" id="reply-template" name="template">
|
|
{{range .Templates}}<option value="{{.ID}}" {{if eq .ID $.TemplateID}}selected{{end}}>{{.Name}} — {{.Description}}</option>{{end}}
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label for="reply-instruction">Note for the model (optional)</label>
|
|
<input class="text-input" id="reply-instruction" name="instruction" maxlength="300"
|
|
placeholder="Say the invoice is paid" autocomplete="off">
|
|
</div>
|
|
<div class="reply-actions">
|
|
<button class="btn btn-primary" type="submit" {{if not .LLMReady}}disabled{{end}}>
|
|
{{if .LLMReady}}Draft reply{{else}}Drafting unavailable{{end}}
|
|
</button>
|
|
<span class="muted faint">model: {{.LLMModel}}</span>
|
|
</div>
|
|
{{if not .LLMReady}}<p class="faint">Reply drafting needs OMNIROUTE_API_KEY.</p>{{end}}
|
|
</form>
|
|
|
|
{{if .HasDraft}}
|
|
<form class="reply-send-form" hx-post="/actions/send" hx-target="#message-detail" hx-swap="innerHTML"
|
|
hx-confirm="Send this reply as {{.From}} to {{.To}}?">
|
|
<input type="hidden" name="csrf" value="{{.CSRFToken}}">
|
|
<input type="hidden" name="id" value="{{.Message.ID}}">
|
|
<input type="hidden" name="draft_id" value="{{.Draft.ID}}">
|
|
<input type="hidden" name="confirm" value="yes">
|
|
<div class="field">
|
|
<label for="reply-body">Draft (editable)</label>
|
|
<textarea class="text-input reply-textarea" id="reply-body" name="body" rows="10">{{.Draft.Body}}</textarea>
|
|
</div>
|
|
<div class="reply-actions">
|
|
<button class="btn btn-utility" type="submit"
|
|
hx-post="/actions/draft/save" hx-target="#message-detail" hx-swap="innerHTML">Save draft</button>
|
|
{{if eq .Draft.Status "sent"}}
|
|
<span class="pill">sent</span>
|
|
{{else}}
|
|
<button class="btn btn-primary" type="submit">Send reply</button>
|
|
{{end}}
|
|
</div>
|
|
</form>
|
|
{{end}}
|
|
</section>
|
|
|
|
{{if .History}}
|
|
<section class="reply-history" aria-label="History">
|
|
<h3>History</h3>
|
|
<ul class="history-list">
|
|
{{range .History}}<li><span class="pill">{{.Action}}</span> <span class="muted">{{.CreatedAt}}</span> {{.Detail}}</li>{{end}}
|
|
</ul>
|
|
</section>
|
|
{{end}}
|
|
</div>
|