feat(email): add safe message detail
CI and Deploy / test (pull_request) Failing after 1m29s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-09 21:44:24 +02:00
parent 6f0e9bb516
commit a20775c24a
6 changed files with 262 additions and 23 deletions
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services.EmailProviders;
using System.Text.Json;
namespace JobTrackerApi.Controllers
{
@@ -42,6 +44,8 @@ namespace JobTrackerApi.Controllers
DateTime Date,
string ContentPreview,
string? ExternalThreadId,
string? ExternalMessageId,
string? Provider,
string? ExternalFrom,
string? ExternalTo,
int LabelCount,
@@ -84,27 +88,50 @@ namespace JobTrackerApi.Controllers
query = query.Where(c => c.ExternalThreadId == null);
}
var items = await query
var rows = await query
.OrderByDescending(c => c.Date)
.Take(200)
.Select(c => new CorrespondenceInboxItemDto(
.Select(c => new
{
c.Id,
c.JobApplicationId,
c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
c.JobApplication.JobTitle,
CompanyName = c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
JobTitle = c.JobApplication.JobTitle,
c.From,
c.Direction,
c.Subject,
c.Channel,
c.Date,
c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220),
ContentPreview = c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220),
c.ExternalThreadId,
c.ExternalMessageId,
c.Provider,
c.ExternalFrom,
c.ExternalTo,
c.ExternalLabelsJson != null ? 1 : 0,
c.AttachmentMetadataJson != null ? 1 : 0))
c.ExternalLabelsJson,
c.AttachmentMetadataJson,
})
.ToListAsync(cancellationToken);
var items = rows.Select(c => new CorrespondenceInboxItemDto(
c.Id,
c.JobApplicationId,
c.CompanyName,
c.JobTitle,
c.From,
c.Direction,
c.Subject,
c.Channel,
c.Date,
c.ContentPreview,
c.ExternalThreadId,
c.ExternalMessageId,
c.Provider,
c.ExternalFrom,
c.ExternalTo,
DeserializeLabels(c.ExternalLabelsJson).Count,
DeserializeAttachments(c.AttachmentMetadataJson).Count)).ToList();
return Ok(items);
}
@@ -123,6 +150,25 @@ namespace JobTrackerApi.Controllers
return Ok(messages);
}
[HttpGet("message/{id:int}")]
public async Task<ActionResult<EmailMessageDetailDto>> GetMessage([FromRoute] int id, CancellationToken cancellationToken)
{
var message = await FindOwnedMessageAsync(id, cancellationToken);
if (message is null) return NotFound();
return Ok(new EmailMessageDetailDto(
message.ExternalMessageId ?? $"correspondence-{message.Id}",
message.ExternalThreadId ?? string.Empty,
message.Subject ?? string.Empty,
message.ExternalFrom ?? message.From,
message.ExternalTo ?? string.Empty,
new DateTimeOffset(DateTime.SpecifyKind(message.Date, DateTimeKind.Local)),
message.Content.Length <= 220 ? message.Content : message.Content[..220],
message.Content,
DeserializeLabels(message.ExternalLabelsJson),
DeserializeAttachments(message.AttachmentMetadataJson)));
}
public sealed record CreateCorrespondenceRequest(int JobApplicationId, string From, string Content);
public sealed record CreateCorrespondenceRequestV2(
int JobApplicationId,
@@ -186,5 +232,27 @@ namespace JobTrackerApi.Controllers
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private static IReadOnlyList<string> DeserializeLabels(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>(); }
catch (JsonException) { return Array.Empty<string>(); }
}
private static IReadOnlyList<EmailAttachmentRef> DeserializeAttachments(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<EmailAttachmentRef>();
try
{
return (JsonSerializer.Deserialize<List<CorrespondenceAttachmentMetadata>>(json) ?? new List<CorrespondenceAttachmentMetadata>())
.Select(item => new EmailAttachmentRef(item.FileName, item.MimeType, item.SizeBytes, item.GmailAttachmentId, item.Inline))
.ToList();
}
catch (JsonException)
{
return Array.Empty<EmailAttachmentRef>();
}
}
}
}
+14 -14
View File
@@ -5,24 +5,24 @@ using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
public sealed record EmailMessageDetailDto(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
string BodyText,
IReadOnlyList<string> Labels,
IReadOnlyList<EmailAttachmentRef> Attachments);
[ApiController]
[Route("api/email")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class EmailController(IEmailProviderRegistry providers) : ControllerBase
{
public sealed record ProviderStatus(string Provider, string DisplayName, bool Connected, string? Address, bool CanRead, bool CanSend);
public sealed record MessageDetail(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
string BodyText,
IReadOnlyList<string> Labels,
IReadOnlyList<EmailAttachmentRef> Attachments);
[HttpGet("providers")]
public async Task<ActionResult<IReadOnlyList<ProviderStatus>>> GetProviders(CancellationToken cancellationToken)
{
@@ -82,7 +82,7 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll
}
[HttpGet("message")]
public async Task<ActionResult<MessageDetail>> GetMessage(
public async Task<ActionResult<EmailMessageDetailDto>> GetMessage(
[FromQuery] string provider,
[FromQuery] string messageId,
CancellationToken cancellationToken)
@@ -97,7 +97,7 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll
return Conflict(new ProblemDetails { Title = "Email provider is not connected." });
var detail = await resolved.GetMessageAsync(ownerUserId, messageId.Trim(), cancellationToken);
return Ok(new MessageDetail(
return Ok(new EmailMessageDetailDto(
detail.Id,
detail.ThreadId,
detail.Subject,