Files
Inboxintel/src/InboxIntel.Api/Controllers/EmailController.cs
T
cesnimda 64f835f719 feat: AI categorisation fallback, unsubscribe confidence scoring, one-line summaries
Heuristic classifier falls back to AI only when it can't determine a category;
unsubscribe confidence blends method reliability with an AI safety opinion and
surfaces it in the Unsubscribe Manager; emails get an on-demand AI one-line
summary in the detail pane. All AI calls degrade gracefully when AI is disabled
or the provider errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 22:21:10 +02:00

108 lines
4.8 KiB
C#

using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Api.Controllers;
/// <summary>
/// Single-email quick actions. All writes are scoped to the authenticated user's
/// UserId so one user cannot mutate another user's email.
/// Read/star are non-destructive and execute without confirmation.
/// Trash is reversible and also executes without a separate confirm step —
/// the single-email context makes the intent unambiguous.
/// </summary>
public class EmailController : ApiControllerBase
{
private readonly ICleanupService _cleanup;
private readonly IUnsubscribeService _unsub;
private readonly IAiService _ai;
private readonly AppDbContext _db;
public EmailController(ICleanupService cleanup, IUnsubscribeService unsub, IAiService ai, AppDbContext db)
{
_cleanup = cleanup;
_unsub = unsub;
_ai = ai;
_db = db;
}
/// <summary>Full email detail including body text, for the inline detail view.</summary>
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
var e = await _db.Emails
.Where(e => e.Id == id && e.UserId == UserId)
.Select(e => new EmailDetailDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.BodyText,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes,
e.Category, e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe))
.FirstOrDefaultAsync(ct);
return e is null ? NotFound() : Ok(e);
}
/// <summary>One-line AI summary, fetched on demand (returns the snippet verbatim if AI is disabled).</summary>
[HttpGet("{id:guid}/summary")]
public async Task<IActionResult> Summary(Guid id, CancellationToken ct)
{
var e = await _db.Emails.FirstOrDefaultAsync(e => e.Id == id && e.UserId == UserId, ct);
if (e is null) return NotFound();
var summary = await _ai.SummarizeEmailAsync(e.Subject, e.Snippet, e.BodyText, ct);
return Ok(new { summary });
}
[HttpPost("{id:guid}/read")]
public Task<IActionResult> MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct);
[HttpPost("{id:guid}/unread")]
public Task<IActionResult> MarkUnread(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkUnread, ct);
[HttpPost("{id:guid}/star")]
public Task<IActionResult> Star(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Star, ct);
[HttpPost("{id:guid}/unstar")]
public Task<IActionResult> Unstar(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Unstar, ct);
[HttpPost("{id:guid}/trash")]
public Task<IActionResult> Trash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Trash, ct);
[HttpPost("{id:guid}/untrash")]
public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
/// <summary>
/// Inline unsubscribe. Detects the unsubscribe mechanism for the email's sender,
/// then executes it (HTTP one-click or HTTP link). mailto targets cannot be sent
/// automatically — the mailto URI is returned so the user can action it themselves.
/// </summary>
[HttpPost("{id:guid}/unsubscribe")]
public async Task<IActionResult> Unsubscribe(Guid id, CancellationToken ct)
{
var email = await _db.Emails.FirstOrDefaultAsync(e => e.Id == id && e.UserId == UserId, ct);
if (email is null) return NotFound();
if (!email.HasListUnsubscribe) return BadRequest(new { error = "Email has no List-Unsubscribe header." });
await _unsub.DetectAsync(UserId, ct);
var item = await _db.UnsubscribeItems
.FirstOrDefaultAsync(u => u.UserId == UserId && u.SenderId == email.SenderId, ct);
if (item is null) return BadRequest(new { error = "No unsubscribe target found." });
if (item.Method == UnsubscribeMethod.MailTo)
return Ok(new { method = "mailto", target = item.UnsubscribeTarget });
var result = await _unsub.ProcessQueueAsync(UserId, new UnsubscribeRequestDto(new[] { item.Id }, Confirmed: true), ct);
return result.Succeeded ? Ok(new { method = "auto", succeeded = result.Value!.SucceededCount > 0 })
: BadRequest(new { error = result.Error });
}
private async Task<IActionResult> Act(Guid id, CleanupActionType action, CancellationToken ct)
{
var req = new CleanupRequestDto(action, new[] { id }, null, null, Confirmed: true);
var result = await _cleanup.ExecuteAsync(UserId, req, ct);
return result.Succeeded ? Ok() : BadRequest(new { error = result.Error });
}
}