64f835f719
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>
108 lines
4.8 KiB
C#
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 });
|
|
}
|
|
}
|