Compare commits
41 Commits
2e69fac250
...
86a07e5017
| Author | SHA1 | Date | |
|---|---|---|---|
| 86a07e5017 | |||
| 25d0c009ee | |||
| 14180860d3 | |||
| 50eaefb868 | |||
| 84da5cb646 | |||
| 25b55ef651 | |||
| 8b4d1e48f5 | |||
| 893addb45d | |||
| 00ae8e1ba0 | |||
| 5c37b2ef92 | |||
| be28a61d9c | |||
| b2857c558f | |||
| 91581649ba | |||
| 4dd0974c6f | |||
| 5e0de6d52a | |||
| 176e6dd6c5 | |||
| 48796d9917 | |||
| f6ada2ad9e | |||
| 7b5596f3e5 | |||
| 5d3997b7e1 | |||
| cb3b2e09ab | |||
| 118612206e | |||
| a446162afa | |||
| f521683608 | |||
| f3761e96d9 | |||
| e840d026aa | |||
| 962cb52e0b | |||
| 1162a07454 | |||
| 59e8780345 | |||
| 5ff38391d0 | |||
| 1fb7dcf98a | |||
| ee279c407b | |||
| 74ec00ddd3 | |||
| 763ba67d34 | |||
| 8e2e9af451 | |||
| 53ba40b316 | |||
| 52a416f6d9 | |||
| 79db1c3a69 | |||
| 18c5dca9f4 | |||
| c47d78112c | |||
| 20de7da93d |
@@ -37,8 +37,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingServic
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Client", "src\presentation\DigitalData.MessagingService.Client\DigitalData.MessagingService.Client.csproj", "{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Abstraction", "src\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj", "{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -77,10 +75,6 @@ Global
|
||||
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Debug|Any CPU.Build.0 = Release|Any CPU
|
||||
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -97,7 +91,6 @@ Global
|
||||
{8BF22107-3CB9-C326-B94B-C40C99DA9B68} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
||||
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
|
||||
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a single email account configuration.
|
||||
/// </summary>
|
||||
public record EmailAccountDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Logical name to identify this account (e.g. "default", "support").
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
#if NET
|
||||
public required string Username { get; set; }
|
||||
#else
|
||||
public string Username { get; set; } = null!;
|
||||
#endif
|
||||
|
||||
#if NET
|
||||
public required string SmtpServer { get; set; }
|
||||
#else
|
||||
public string SmtpServer { get; set; } = null!;
|
||||
#endif
|
||||
|
||||
public int SmtpPort { get; set; }
|
||||
|
||||
public bool SmtpUseSsl { get; set; }
|
||||
|
||||
public bool UseOAuth2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IMAP server hostname (e.g. "imap.example.com").
|
||||
/// Leave empty when this account is send-only.
|
||||
/// </summary>
|
||||
public string? ImapServer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
|
||||
/// </summary>
|
||||
public int ImapPort { get; set; } = 993;
|
||||
|
||||
/// <summary>
|
||||
/// Use SSL/TLS when connecting to the IMAP server.
|
||||
/// </summary>
|
||||
public bool ImapUseSsl { get; set; } = true;
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
namespace DigitalData.MessagingService.Abstraction;
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a single email account configuration.
|
||||
/// </summary>
|
||||
public class EmailAccountDto
|
||||
public record EmailAccountModificationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Logical name to identify this account (e.g. "default", "support").
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
#if NET
|
||||
public required string Username { get; set; }
|
||||
#else
|
||||
@@ -22,8 +17,6 @@ public class EmailAccountDto
|
||||
public string Password { get; set; } = null!;
|
||||
#endif
|
||||
|
||||
public bool PasswordEncrypted { get; set; } = false;
|
||||
|
||||
#if NET
|
||||
public required string SmtpServer { get; set; }
|
||||
#else
|
||||
@@ -35,4 +28,20 @@ public class EmailAccountDto
|
||||
public bool SmtpUseSsl { get; set; }
|
||||
|
||||
public bool UseOAuth2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IMAP server hostname (e.g. "imap.example.com").
|
||||
/// Leave empty when this account is send-only.
|
||||
/// </summary>
|
||||
public string? ImapServer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
|
||||
/// </summary>
|
||||
public int ImapPort { get; set; } = 993;
|
||||
|
||||
/// <summary>
|
||||
/// Use SSL/TLS when connecting to the IMAP server.
|
||||
/// </summary>
|
||||
public bool ImapUseSsl { get; set; } = true;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace DigitalData.MessagingService.Abstraction;
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single email attachment.
|
||||
/// </summary>
|
||||
public sealed class EmailAttachmentContext
|
||||
public sealed class EmailAttachmentDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Display name of the attachment (e.g. "invoice.pdf").
|
||||
@@ -1,11 +1,13 @@
|
||||
namespace DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
public record EmailContext
|
||||
{
|
||||
#if NETFRAMEWORK
|
||||
public EmailAccountDto Sender { get; set; } = null!;
|
||||
public EmailAccount Sender { get; set; } = null!;
|
||||
#else
|
||||
public required EmailAccountDto Sender { get; init; }
|
||||
public required EmailAccount Sender { get; init; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
@@ -48,8 +50,8 @@ public record EmailContext
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of attachments to include with the email.
|
||||
/// Each entry may carry its content as a byte array (<see cref="EmailAttachmentContext.Content"/>)
|
||||
/// or reference a file on disk via <see cref="EmailAttachmentContext.FilePath"/>.
|
||||
/// Each entry may carry its content as a byte array (<see cref="EmailAttachmentDto.Content"/>)
|
||||
/// or reference a file on disk via <see cref="EmailAttachmentDto.FilePath"/>.
|
||||
/// </summary>
|
||||
public IEnumerable<EmailAttachmentContext> Attachments { get; set; } = [];
|
||||
public IEnumerable<EmailAttachmentDto> Attachments { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#if NET
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Constrains the search to messages within an arrival-date range.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// At least one of <see cref="After"/> or <see cref="Before"/> must be provided.
|
||||
/// When both are set <see cref="After"/> must be earlier than <see cref="Before"/>.
|
||||
/// Both bounds are <b>inclusive</b>.
|
||||
/// </remarks>
|
||||
/// <param name="After">Earliest date to include (inclusive). Maps to IMAP <c>SINCE</c>.</param>
|
||||
/// <param name="Before">Latest date to include (inclusive). Maps to IMAP <c>BEFORE</c> (next day is used internally).</param>
|
||||
public record DateFilter(DateTime? After = null, DateTime? Before = null);
|
||||
#endif
|
||||
@@ -0,0 +1,80 @@
|
||||
#if NET
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Describes all criteria and options used when searching an IMAP mailbox.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All text-match properties are <b>case-insensitive</b> substring searches performed server-side via IMAP SEARCH.
|
||||
/// Combine multiple criteria freely; an implicit AND is applied across all non-null fields.
|
||||
/// </remarks>
|
||||
public record MailSearchFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Mailbox folder to search. Defaults to <c>"INBOX"</c>.
|
||||
/// </summary>
|
||||
public string Folder { get; init; } = "INBOX";
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, only unread (UNSEEN) messages are returned.
|
||||
/// </summary>
|
||||
public bool UnseenOnly { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, attachment data is included in the results; otherwise attachments are omitted.
|
||||
/// Defaults to <see langword="false"/>.
|
||||
/// </summary>
|
||||
public bool WithAttachments { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of messages to retrieve. <c>0</c> means unlimited.
|
||||
/// Applied after sorting; defaults to <c>50</c>.
|
||||
/// </summary>
|
||||
public int? MaxCount { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Controls the order of the returned messages. Defaults to <see cref="MailSortOrder.NewestFirst"/>.
|
||||
/// </summary>
|
||||
public MailSortOrder SortOrder { get; init; } = MailSortOrder.NewestFirst;
|
||||
|
||||
// ── Text filters ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive substring the message subject must contain.
|
||||
/// Maps to IMAP <c>SUBJECT</c>.
|
||||
/// </summary>
|
||||
public string? SubjectContains { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive substring that must appear in the <c>From</c> header.
|
||||
/// Maps to IMAP <c>FROM</c>.
|
||||
/// </summary>
|
||||
public string? SenderContains { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive substring that must appear in <c>To</c> or <c>Cc</c>.
|
||||
/// Maps to IMAP <c>TO</c>.
|
||||
/// </summary>
|
||||
public string? RecipientContains { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive substring that must appear anywhere in the message body (text or HTML part).
|
||||
/// Maps to IMAP <c>BODY</c>.
|
||||
/// </summary>
|
||||
public string? BodyContains { get; init; } = null;
|
||||
|
||||
// ── Structured filters ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Constrains results to a specific UID or a UID range.
|
||||
/// See <see cref="UidFilter"/> for mutual-exclusion rules between its fields.
|
||||
/// </summary>
|
||||
public UidFilter? Uid { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Constrains results to messages received within a date range.
|
||||
/// See <see cref="DateFilter"/> for rules between its fields.
|
||||
/// </summary>
|
||||
public DateFilter? Date { get; init; } = null;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Controls the order in which fetched messages are returned.
|
||||
/// </summary>
|
||||
public enum MailSortOrder
|
||||
{
|
||||
/// <summary>
|
||||
/// Newest messages first (default).
|
||||
/// </summary>
|
||||
NewestFirst,
|
||||
|
||||
/// <summary>
|
||||
/// Oldest messages first.
|
||||
/// </summary>
|
||||
OldestFirst
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#if NET
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Constrains the search to a specific UID or a UID range.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use <see cref="Absolute"/> for an exact single-message lookup.
|
||||
/// Use <see cref="Min"/> and/or <see cref="Max"/> for an open or closed range.
|
||||
/// Mixing <see cref="Absolute"/> with <see cref="Min"/> or <see cref="Max"/> is not allowed.
|
||||
/// </remarks>
|
||||
/// <param name="Min">Lower bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
|
||||
/// <param name="Max">Upper bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
|
||||
/// <param name="Absolute">Exact UID to match. When set, <see cref="Min"/> and <see cref="Max"/> must be <see langword="null"/>.</param>
|
||||
public record UidFilter(long? Min = null, long? Max = null, long? Absolute = null);
|
||||
#endif
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an email message received via IMAP.
|
||||
/// </summary>
|
||||
public sealed record ReceivedEmailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier of the message on the IMAP server (UID).
|
||||
/// </summary>
|
||||
#if NET
|
||||
public long Uid { get; init; }
|
||||
#else
|
||||
public long Uid { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Sender address (From header).
|
||||
/// </summary>
|
||||
#if NET
|
||||
public string From { get; init; } = string.Empty;
|
||||
#else
|
||||
public string From { get; set; } = string.Empty;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Recipient addresses (To header).
|
||||
/// </summary>
|
||||
#if NET
|
||||
public IEnumerable<string> To { get; init; } = [];
|
||||
#else
|
||||
public IEnumerable<string> To { get; set; } = [];
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// CC addresses.
|
||||
/// </summary>
|
||||
#if NET
|
||||
public IEnumerable<string> Cc { get; init; } = [];
|
||||
#else
|
||||
public IEnumerable<string> Cc { get; set; } = [];
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Email subject.
|
||||
/// </summary>
|
||||
#if NET
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
#else
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Plain-text body (may be empty when only HTML is present).
|
||||
/// </summary>
|
||||
#if NET
|
||||
public string TextBody { get; init; } = string.Empty;
|
||||
#else
|
||||
public string TextBody { get; set; } = string.Empty;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// HTML body (may be empty when only plain-text is present).
|
||||
/// </summary>
|
||||
#if NET
|
||||
public string HtmlBody { get; init; } = string.Empty;
|
||||
#else
|
||||
public string HtmlBody { get; set; } = string.Empty;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Date/time the message was sent (Date header).
|
||||
/// </summary>
|
||||
#if NET
|
||||
public DateTime Date { get; init; }
|
||||
#else
|
||||
public DateTime Date { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Attachments included with this message.
|
||||
/// </summary>
|
||||
#if NET
|
||||
public IEnumerable<EmailAttachmentDto> Attachments { get; init; } = [];
|
||||
#else
|
||||
public IEnumerable<EmailAttachmentDto> Attachments { get; set; } = [];
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Whether the message has been marked as seen/read on the server.
|
||||
/// </summary>
|
||||
#if NET
|
||||
public bool IsSeen { get; init; }
|
||||
#else
|
||||
public bool IsSeen { get; set; }
|
||||
#endif
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DigitalData.MessagingService.Abstraction;
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
public record SendingEmailCreateDto
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DigitalData.MessagingService.Abstraction;
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
public record SendingEmailEvent
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for reading emails via IMAP.
|
||||
/// </summary>
|
||||
public interface IImapEmailService
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches emails from the specified mailbox folder.
|
||||
/// </summary>
|
||||
/// <param name="account">Account whose IMAP settings will be used.</param>
|
||||
/// <param name="filter">Filter to apply when fetching emails.</param>
|
||||
/// When <see langword="true"/> (default), fetched messages are marked as <c>\Seen</c> on the server.
|
||||
/// Set to <see langword="false"/> for a non-destructive read (uses <c>BODY.PEEK</c> internally).
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync(
|
||||
EmailAccount account,
|
||||
MailSearchFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a message as seen (read) on the server.
|
||||
/// </summary>
|
||||
Task MarkAsSeenAsync(
|
||||
EmailAccount account,
|
||||
long uid,
|
||||
string folder = "INBOX",
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
#endif
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Email queue interface for outgoing emails.
|
||||
@@ -8,4 +10,4 @@ public interface ISendingEmailPublisher
|
||||
Task EnqueueAsync(SendingEmailEvent sendingEmailEvent, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ public interface IRepository<TEntity> where TEntity : class
|
||||
// CREATE
|
||||
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IEnumerable<TEntity>> CreateAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default);
|
||||
|
||||
// READ
|
||||
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
@@ -20,6 +22,11 @@ public interface IRepository<TEntity> where TEntity : class
|
||||
Task<int> CountAsync(Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default);
|
||||
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
|
||||
// UPSERT
|
||||
Task<(TEntity Entity, bool Created)> UpsertAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(TEntity Entity, bool Created)> UpsertSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
// UPDATE
|
||||
Task UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#if NET
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
||||
|
||||
@@ -11,10 +14,15 @@ public class EmailMappingProfile : Profile
|
||||
{
|
||||
public EmailMappingProfile()
|
||||
{
|
||||
// SendEmailCommand -> Email
|
||||
// PublishEmailCommand -> Email
|
||||
// Sender is resolved via MediatR in the handler and set separately after mapping.
|
||||
CreateMap<SendEmailCommand, EmailContext>()
|
||||
CreateMap<PublishEmailCommand, EmailContext>()
|
||||
.ForMember(dest => dest.Sender, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
|
||||
|
||||
// EmailAccountDto -> EmailAccount
|
||||
CreateMap<EmailAccount, EmailAccountDto>();
|
||||
CreateMap<EmailAccountModificationDto, EmailAccount>();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,9 +1,13 @@
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||
using DigitalData.MessagingService.Application.EmailAccounts.Commands;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper options class that holds a list of <see cref="EmailAccountDto"/> entries
|
||||
/// Wrapper options class that holds a list of <see cref="Dto.EmailAccount"/> entries
|
||||
/// bound from the <c>EmailAccounts</c> configuration section.
|
||||
/// </summary>
|
||||
public class EmailAccountsOptions
|
||||
@@ -13,5 +17,12 @@ public class EmailAccountsOptions
|
||||
/// <summary>
|
||||
/// The list of configured email accounts.
|
||||
/// </summary>
|
||||
public required IEnumerable<EmailAccountDto> Accounts { get; init; } = [];
|
||||
public required IEnumerable<EmailAccountModificationDto> Accounts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// How often the IMAP sync worker polls for new emails, in seconds.
|
||||
/// Defaults to 300 seconds (5 minutes).
|
||||
/// </summary>
|
||||
public int SyncIntervalSeconds { get; init; } = 300;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DigitalData.MessagingService.Application.Common.ValueObjects;
|
||||
|
||||
public enum Modification
|
||||
{
|
||||
Upsert
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Options;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Reflection;
|
||||
|
||||
namespace DigitalData.MessagingService.Application;
|
||||
@@ -43,3 +43,4 @@ public static class DependencyInjection
|
||||
return services;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,17 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
||||
<ProjectReference Include="..\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MediatR" Version="14.2.0" />
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
using DigitalData.MessagingService.Application.Common.Options;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailAccount.Queries;
|
||||
|
||||
public record GetSenderQuery : IRequest<EmailAccountDto?>
|
||||
{
|
||||
public int? Id { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="Options"></param>
|
||||
/// <param name="Logger"></param>
|
||||
public class GetSenderQueryHandler(IOptions<EmailAccountsOptions> Options, ILogger<GetSenderQueryHandler> Logger) : IRequestHandler<GetSenderQuery, EmailAccountDto?>
|
||||
{
|
||||
public Task<EmailAccountDto?> Handle(GetSenderQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var accounts = request.Id is not null
|
||||
? Options.Value.Accounts.Where(a => a.Id == request.Id)
|
||||
: Options.Value.Accounts.Where(a => a.Username == request.Username);
|
||||
|
||||
if(accounts.Count() > 1)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.",
|
||||
request.Id is not null ? $"Id: {request.Id}" : $"Username: {request.Username}"
|
||||
);
|
||||
}
|
||||
|
||||
return Task.FromResult(accounts.FirstOrDefault());
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using DigitalData.MessagingService.Application.EmailAccount.Queries;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailAccount.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for <see cref="GetSenderQuery"/>.
|
||||
/// Either <see cref="GetSenderQuery.Id"/> or <see cref="GetSenderQuery.Username"/> must be provided, but not both.
|
||||
/// </summary>
|
||||
public class GetSenderQueryValidator : AbstractValidator<GetSenderQuery>
|
||||
{
|
||||
public GetSenderQueryValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.Id is not null) ^ (x.Username is not null))
|
||||
.WithMessage("Either Id or Username must be provided, but not both.");
|
||||
|
||||
When(x => x.Username is not null, () =>
|
||||
{
|
||||
RuleFor(x => x.Username)
|
||||
.NotEmpty()
|
||||
.WithMessage("Username must not be empty.")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Username must not exceed 200 characters.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Application.Common.ValueObjects;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using MediatR;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailAccounts.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a single email account configuration.
|
||||
/// </summary>
|
||||
public record EmailAccountModificationCommand : IRequest<(EmailAccount, bool)>
|
||||
{
|
||||
public required EmailAccountModificationDto ModifiedAccount { get; init; }
|
||||
|
||||
public required Modification Modification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="Repo"></param>
|
||||
public class EmailAccountModificationCommandHandler(IRepository<EmailAccount> Repo) : IRequestHandler<EmailAccountModificationCommand, (EmailAccount, bool)>
|
||||
{
|
||||
public async Task<(EmailAccount, bool)> Handle(EmailAccountModificationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return request.Modification switch
|
||||
{
|
||||
Modification.Upsert => await Repo.UpsertAsync(a => a.Username == request.ModifiedAccount.Username, request.ModifiedAccount, cancellationToken),
|
||||
_ => throw new NotSupportedException($"Modification type '{request.Modification}' is not supported in email account modification."),
|
||||
};
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
#if NET
|
||||
using MediatR;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||
|
||||
public record GetEmailAccountQuery : IRequest<IEnumerable<EmailAccountDto>>
|
||||
{
|
||||
public int? Id { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles queries for retrieving email accounts.
|
||||
/// </summary>
|
||||
/// <param name="Repo"></param>
|
||||
/// <param name="Mapper"></param>
|
||||
public class GetEmailAccountQueryHandler(IRepository<EmailAccount> Repo, IMapper Mapper) : IRequestHandler<GetEmailAccountQuery, IEnumerable<EmailAccountDto>>
|
||||
{
|
||||
public async Task<IEnumerable<EmailAccountDto>> Handle(GetEmailAccountQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var accounts = request.Id is null && request.Username is null
|
||||
? await Repo.GetAllAsync(cancellationToken)
|
||||
: await Repo.FindAsync(request.Id is int id ? x => x.Id == id : x => x.Username == request.Username, cancellationToken: cancellationToken);
|
||||
|
||||
if (accounts.Any())
|
||||
return Mapper.Map<IEnumerable<EmailAccountDto>>(accounts);
|
||||
else
|
||||
throw new NotFoundException($"No email account found for the given criteria ({(request.Id is not null ? $"Id: {request.Id}" : $"Username: {request.Username}")}).");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,25 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailAccounts.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for <see cref="GetEmailAccountQuery"/>.
|
||||
/// Either <see cref="GetEmailAccountQuery.Id"/> or <see cref="GetEmailAccountQuery.Username"/> must be provided, but not both.
|
||||
/// </summary>
|
||||
public class GetSenderQueryValidator : AbstractValidator<GetEmailAccountQuery>
|
||||
{
|
||||
public GetSenderQueryValidator()
|
||||
{
|
||||
When(x => x.Username is not null, () =>
|
||||
{
|
||||
RuleFor(x => x.Username)
|
||||
.NotEmpty()
|
||||
.WithMessage("Username must not be empty.")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Username must not exceed 200 characters.");
|
||||
});
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,48 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailReceiving.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command to mark a single IMAP message as seen (read).
|
||||
/// </summary>
|
||||
public record MarkEmailAsSeenCommand : IRequest
|
||||
{
|
||||
public required GetEmailAccountQuery Account { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UID of the message to mark as seen.
|
||||
/// </summary>
|
||||
public required long Uid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mailbox folder the message resides in (default: "INBOX").
|
||||
/// </summary>
|
||||
public string Folder { get; init; } = "INBOX";
|
||||
}
|
||||
|
||||
public class MarkEmailAsSeenCommandHandler(IImapEmailService ImapService, ILogger<MarkEmailAsSeenCommandHandler> Logger, IRepository<EmailAccount> Repo) : IRequestHandler<MarkEmailAsSeenCommand>
|
||||
{
|
||||
public async Task Handle(MarkEmailAsSeenCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var accounts = await Repo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
|
||||
|
||||
if (accounts.Count() > 1)
|
||||
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
|
||||
|
||||
var account = accounts.FirstOrDefault()
|
||||
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.ImapServer))
|
||||
throw new BadRequestException($"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
|
||||
|
||||
await ImapService.MarkAsSeenAsync(account, request.Uid, request.Folder, cancellationToken);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,52 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query to fetch emails from an IMAP mailbox.
|
||||
/// </summary>
|
||||
public record FetchEmailsQuery : IRequest<IEnumerable<ReceivedEmailDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies the email account to use.
|
||||
/// </summary>
|
||||
public required GetEmailAccountQuery Account { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mail query used to filter and limit the emails retrieved.
|
||||
/// </summary>
|
||||
public MailSearchFilter Mail { get; init; } = new();
|
||||
}
|
||||
|
||||
public class FetchEmailsQueryHandler(IImapEmailService ImapService, ILogger<FetchEmailsQueryHandler> Logger, IRepository<EmailAccount> Repo) : IRequestHandler<FetchEmailsQuery, IEnumerable<ReceivedEmailDto>>
|
||||
{
|
||||
public async Task<IEnumerable<ReceivedEmailDto>> Handle(FetchEmailsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var accounts = await Repo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
|
||||
|
||||
if (accounts.Count() > 1)
|
||||
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
|
||||
|
||||
EmailAccount account = accounts.FirstOrDefault()
|
||||
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.ImapServer))
|
||||
throw new BadRequestException(
|
||||
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
|
||||
|
||||
return await ImapService.FetchEmailsAsync(
|
||||
account,
|
||||
request.Mail,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,39 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a <see cref="DateFilter"/> value object.
|
||||
/// </summary>
|
||||
public class DateFilterValidator : AbstractValidator<DateFilter>
|
||||
{
|
||||
public DateFilterValidator()
|
||||
{
|
||||
// At least one field must be provided
|
||||
RuleFor(x => x)
|
||||
.Must(d => d.After is not null || d.Before is not null)
|
||||
.WithName("Date")
|
||||
.WithMessage("DateFilter must specify at least one of: After or Before.");
|
||||
|
||||
// Dates must not be in the future
|
||||
RuleFor(x => x.After)
|
||||
.LessThanOrEqualTo(DateTime.UtcNow)
|
||||
.WithMessage("Date.After must not be in the future.")
|
||||
.When(x => x.After is not null);
|
||||
|
||||
RuleFor(x => x.Before)
|
||||
.LessThanOrEqualTo(DateTime.UtcNow)
|
||||
.WithMessage("Date.Before must not be in the future.")
|
||||
.When(x => x.Before is not null);
|
||||
|
||||
// Range coherence: After must be earlier than Before
|
||||
RuleFor(x => x)
|
||||
.Must(d => d.After!.Value < d.Before!.Value)
|
||||
.WithName("Date.Range")
|
||||
.WithMessage("Date.After must be earlier than Date.Before.")
|
||||
.When(x => x.After is not null && x.Before is not null);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,24 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a <see cref="FetchEmailsQuery"/> before it is handled by <see cref="FetchEmailsQueryHandler"/>.
|
||||
/// </summary>
|
||||
public class FetchEmailsQueryValidator : AbstractValidator<FetchEmailsQuery>
|
||||
{
|
||||
public FetchEmailsQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Account)
|
||||
.NotNull()
|
||||
.WithMessage("Account query must not be null.");
|
||||
|
||||
RuleFor(x => x.Mail)
|
||||
.NotNull()
|
||||
.WithMessage("Mail search filter must not be null.")
|
||||
.SetValidator(new MailSearchFilterValidator());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,53 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a <see cref="MailSearchFilter"/> instance before it is dispatched to the IMAP service.
|
||||
/// </summary>
|
||||
public class MailSearchFilterValidator : AbstractValidator<MailSearchFilter>
|
||||
{
|
||||
public MailSearchFilterValidator()
|
||||
{
|
||||
RuleFor(x => x.Folder)
|
||||
.NotEmpty()
|
||||
.WithMessage("Folder must not be empty.")
|
||||
.MaximumLength(255)
|
||||
.WithMessage("Folder name must not exceed 255 characters.");
|
||||
|
||||
RuleFor(x => x.MaxCount)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("MaxCount must be 0 (unlimited) or a positive number.");
|
||||
|
||||
RuleFor(x => x.SubjectContains)
|
||||
.MaximumLength(500)
|
||||
.WithMessage("SubjectContains must not exceed 500 characters.")
|
||||
.When(x => x.SubjectContains is not null);
|
||||
|
||||
RuleFor(x => x.SenderContains)
|
||||
.MaximumLength(320)
|
||||
.WithMessage("SenderContains must not exceed 320 characters.")
|
||||
.When(x => x.SenderContains is not null);
|
||||
|
||||
RuleFor(x => x.RecipientContains)
|
||||
.MaximumLength(320)
|
||||
.WithMessage("RecipientContains must not exceed 320 characters.")
|
||||
.When(x => x.RecipientContains is not null);
|
||||
|
||||
RuleFor(x => x.BodyContains)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("BodyContains must not exceed 1000 characters.")
|
||||
.When(x => x.BodyContains is not null);
|
||||
|
||||
RuleFor(x => x.Uid)
|
||||
.SetValidator(new UidFilterValidator()!)
|
||||
.When(x => x.Uid is not null);
|
||||
|
||||
RuleFor(x => x.Date)
|
||||
.SetValidator(new DateFilterValidator()!)
|
||||
.When(x => x.Date is not null);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,50 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a <see cref="UidFilter"/> value object.
|
||||
/// </summary>
|
||||
public class UidFilterValidator : AbstractValidator<UidFilter>
|
||||
{
|
||||
public UidFilterValidator()
|
||||
{
|
||||
// Absolute and range are mutually exclusive
|
||||
RuleFor(x => x)
|
||||
.Must(u => u.Absolute is null || (u.Min is null && u.Max is null))
|
||||
.WithName("Uid.Absolute")
|
||||
.WithMessage("Uid.Absolute cannot be combined with Uid.Min or Uid.Max. Use either an exact UID or a range.");
|
||||
|
||||
// At least one field must be provided
|
||||
RuleFor(x => x)
|
||||
.Must(u => u.Absolute is not null || u.Min is not null || u.Max is not null)
|
||||
.WithName("Uid")
|
||||
.WithMessage("UidFilter must specify at least one of: Absolute, Min, or Max.");
|
||||
|
||||
// All UID values must be positive
|
||||
RuleFor(x => x.Absolute)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Uid.Absolute must be a positive number.")
|
||||
.When(x => x.Absolute is not null);
|
||||
|
||||
RuleFor(x => x.Min)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Uid.Min must be a positive number.")
|
||||
.When(x => x.Min is not null);
|
||||
|
||||
RuleFor(x => x.Max)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Uid.Max must be a positive number.")
|
||||
.When(x => x.Max is not null);
|
||||
|
||||
// Range coherence: Min must be less than or equal to Max
|
||||
RuleFor(x => x)
|
||||
.Must(u => u.Min!.Value <= u.Max!.Value)
|
||||
.WithName("Uid.Range")
|
||||
.WithMessage("Uid.Min must be less than or equal to Uid.Max.")
|
||||
.When(x => x.Min is not null && x.Max is not null);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,82 @@
|
||||
#if NET
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command to send an email (enqueue to RabbitMQ)
|
||||
/// </summary>
|
||||
public record PublishEmailCommand : IRequest<Guid>
|
||||
{
|
||||
public required GetEmailAccountQuery Sender { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recipient email addresses
|
||||
/// </summary>
|
||||
public required IEnumerable<string> Recipients { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Email subject
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Email body (HTML or plain text)
|
||||
/// </summary>
|
||||
public required string Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Is HTML email (default: true)
|
||||
/// </summary>
|
||||
public bool IsHtml { get; init; } = true;
|
||||
|
||||
[JsonIgnore]
|
||||
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new command instance with the supplied attachments.
|
||||
/// Called by the controller after resolving uploaded files.
|
||||
/// </summary>
|
||||
public PublishEmailCommand WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
|
||||
=> this with { Attachments = attachments };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for PublishEmailCommand
|
||||
/// Resolves the sender account via MediatR, maps to SendingEmailEvent and enqueues to RabbitMQ
|
||||
/// </summary>
|
||||
public class PublishEmailCommandHandler(IRepository<EmailAccount> Repo, ISendingEmailPublisher Publisher, IMapper Mapper, ILogger<PublishEmailCommandHandler> Logger) : IRequestHandler<PublishEmailCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(PublishEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var senderAccounts = await Repo.FindAsync(request.Sender.Id is int id ? x => x.Id == id : x => x.Username == request.Sender.Username, cancellationToken: cancellationToken);
|
||||
|
||||
if (senderAccounts.Count() > 1)
|
||||
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Sender.Id is not null ? $"Id: {request.Sender.Id}" : $"Username: {request.Sender.Username}");
|
||||
|
||||
var senderAccount = senderAccounts.FirstOrDefault()
|
||||
?? throw new NotFoundException($"No email account found for the given sender criteria (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
|
||||
|
||||
var email = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
|
||||
|
||||
// Enqueue to RabbitMQ
|
||||
var sendingEmailEvent = new SendingEmailEvent()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mail = email,
|
||||
QueuedAt = DateTime.Now
|
||||
};
|
||||
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
|
||||
return sendingEmailEvent.Id;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,72 +0,0 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.EmailAccount.Queries;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using MediatR;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command to send an email (enqueue to RabbitMQ)
|
||||
/// </summary>
|
||||
public record SendEmailCommand : IRequest<Guid>
|
||||
{
|
||||
public required GetSenderQuery Sender { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recipient email addresses
|
||||
/// </summary>
|
||||
public required IEnumerable<string> Recipients { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Email subject
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Email body (HTML or plain text)
|
||||
/// </summary>
|
||||
public required string Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Is HTML email (default: true)
|
||||
/// </summary>
|
||||
public bool IsHtml { get; init; } = true;
|
||||
|
||||
[JsonIgnore]
|
||||
internal IEnumerable<EmailAttachmentContext> Attachments { get; private init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new command instance with the supplied attachments.
|
||||
/// Called by the controller after resolving uploaded files.
|
||||
/// </summary>
|
||||
public SendEmailCommand WithAttachments(IEnumerable<EmailAttachmentContext> attachments)
|
||||
=> this with { Attachments = attachments };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for SendEmailCommand
|
||||
/// Resolves the sender account via MediatR, maps to SendingEmailEvent and enqueues to RabbitMQ
|
||||
/// </summary>
|
||||
public class SendEmailCommandHandler(ISender Sender, ISendingEmailPublisher Publisher, IMapper Mapper) : IRequestHandler<SendEmailCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(SendEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var senderAccount = await Sender.Send(request.Sender, cancellationToken)
|
||||
?? throw new NotFoundException(
|
||||
$"No email account found for the given sender criteria (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
|
||||
|
||||
var email = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
|
||||
|
||||
// Enqueue to RabbitMQ
|
||||
var sendingEmailEvent = new SendingEmailEvent()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mail = email,
|
||||
QueuedAt = DateTime.Now
|
||||
};
|
||||
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
|
||||
return sendingEmailEvent.Id;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailSending.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for SendEmailCommand
|
||||
/// Validator for PublishEmailCommand
|
||||
/// </summary>
|
||||
public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
|
||||
public class PublishEmailCommandValidator : AbstractValidator<PublishEmailCommand>
|
||||
{
|
||||
public SendEmailCommandValidator()
|
||||
public PublishEmailCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Recipients)
|
||||
.NotEmpty()
|
||||
@@ -32,3 +33,4 @@ public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
|
||||
.WithMessage("Body is required");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -11,4 +11,8 @@
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net462'">
|
||||
<Reference Include="System.ComponentModel.DataAnnotations" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a single email account configuration.
|
||||
/// </summary>
|
||||
[Table("EMAIL_ACCOUNT")]
|
||||
public class EmailAccount
|
||||
{
|
||||
/// <summary>
|
||||
/// Logical name to identify this account (e.g. "default", "support").
|
||||
/// </summary>
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
[Column("ID", TypeName = "int")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
[Column("USERNAME", TypeName = "nvarchar(256)")]
|
||||
#if NET
|
||||
public required string Username { get; set; }
|
||||
#else
|
||||
public string Username { get; set; } = null!;
|
||||
#endif
|
||||
|
||||
[Required]
|
||||
[MaxLength(512)]
|
||||
[Column("PASSWORD", TypeName = "nvarchar(512)")]
|
||||
#if NET
|
||||
public required string Password { get; set; }
|
||||
#else
|
||||
public string Password { get; set; } = null!;
|
||||
#endif
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
[Column("SMTP_SERVER", TypeName = "nvarchar(256)")]
|
||||
#if NET
|
||||
public required string SmtpServer { get; set; }
|
||||
#else
|
||||
public string SmtpServer { get; set; } = null!;
|
||||
#endif
|
||||
|
||||
[Column("SMTP_PORT", TypeName = "int")]
|
||||
public int SmtpPort { get; set; }
|
||||
|
||||
[Column("SMTP_USE_SSL", TypeName = "bit")]
|
||||
public bool SmtpUseSsl { get; set; }
|
||||
|
||||
[Column("USE_OAUTH2", TypeName = "bit")]
|
||||
public bool UseOAuth2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IMAP server hostname (e.g. "imap.example.com").
|
||||
/// Leave empty when this account is send-only.
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
[Column("IMAP_SERVER", TypeName = "nvarchar(256)")]
|
||||
public string? ImapServer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
|
||||
/// </summary>
|
||||
[Column("IMAP_PORT", TypeName = "int")]
|
||||
public int ImapPort { get; set; } = 993;
|
||||
|
||||
/// <summary>
|
||||
/// Use SSL/TLS when connecting to the IMAP server.
|
||||
/// </summary>
|
||||
[Column("IMAP_USE_SSL", TypeName = "bit")]
|
||||
public bool ImapUseSsl { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single email attachment.
|
||||
/// </summary>
|
||||
[Table("EMAIL_ATTACHMENT")]
|
||||
public sealed class EmailAttachment
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
[Column("ID", TypeName = "bigint")]
|
||||
#if NET
|
||||
public long Id { get; init; }
|
||||
#else
|
||||
public long Id { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the attachment (e.g. "invoice.pdf").
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(260)]
|
||||
[Column("FILE_NAME", TypeName = "nvarchar(260)")]
|
||||
#if NETFRAMEWORK
|
||||
public string FileName { get; set; } = null!;
|
||||
#else
|
||||
public required string FileName { get; init; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Raw content of the attachment.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("CONTENT", TypeName = "varbinary(max)")]
|
||||
#if NETFRAMEWORK
|
||||
public byte[] Content { get; set; } = null!;
|
||||
#else
|
||||
public required byte[] Content { get; init; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// MIME content-type (e.g. "application/pdf", "image/png").
|
||||
/// Defaults to "application/octet-stream" when not specified.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
[Column("CONTENT_TYPE", TypeName = "nvarchar(256)")]
|
||||
public string ContentType { get; set; } = "application/octet-stream";
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/> the attachment is embedded inline and displayed
|
||||
/// directly inside the email body via a CID reference (e.g. <img src="cid:logo">).
|
||||
/// When <see langword="false"/> (default) it appears as a regular downloadable attachment.
|
||||
/// </summary>
|
||||
[Column("IS_INLINE", TypeName = "bit")]
|
||||
public bool IsInline { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Content-ID used when <see cref="IsInline"/> is <see langword="true"/>.
|
||||
/// Reference it in HTML body as <c>cid:{ContentId}</c>.
|
||||
/// Auto-generated from <see cref="FileName"/> when left empty.
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
[Column("CONTENT_ID", TypeName = "nvarchar(512)")]
|
||||
public string? ContentId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(Email))]
|
||||
[Column("EMAIL_ID", TypeName = "bigint")]
|
||||
#if NET
|
||||
public long EmailId { get; init; }
|
||||
#else
|
||||
public long EmailId { get; set; }
|
||||
#endif
|
||||
|
||||
#if NET
|
||||
public ReceivedEmail? Email { get; init; }
|
||||
#else
|
||||
public ReceivedEmail? Email { get; set; }
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an email message received via IMAP.
|
||||
/// </summary>
|
||||
[Table("RECEIVED_EMAIL")]
|
||||
public sealed record ReceivedEmail
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
[Column("ID", TypeName = "bigint")]
|
||||
#if NET
|
||||
public long Id { get; init; }
|
||||
#else
|
||||
public long Id { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier of the message on the IMAP server (UID).
|
||||
/// </summary>
|
||||
[Column("UID", TypeName = "bigint")]
|
||||
#if NET
|
||||
public long Uid { get; init; }
|
||||
#else
|
||||
public long Uid { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Sender address (From header).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(512)]
|
||||
[Column("FROM", TypeName = "nvarchar(512)")]
|
||||
#if NET
|
||||
public string From { get; init; } = string.Empty;
|
||||
#else
|
||||
public string From { get; set; } = string.Empty;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Recipient addresses (To header).
|
||||
/// </summary>
|
||||
[Column("TO", TypeName = "nvarchar(max)")]
|
||||
#if NET
|
||||
public IEnumerable<string> To { get; init; } = [];
|
||||
#else
|
||||
public IEnumerable<string> To { get; set; } = [];
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// CC addresses.
|
||||
/// </summary>
|
||||
[Column("CC", TypeName = "nvarchar(max)")]
|
||||
#if NET
|
||||
public IEnumerable<string> Cc { get; init; } = [];
|
||||
#else
|
||||
public IEnumerable<string> Cc { get; set; } = [];
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Email subject.
|
||||
/// </summary>
|
||||
[MaxLength(998)]
|
||||
[Column("SUBJECT", TypeName = "nvarchar(998)")]
|
||||
#if NET
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
#else
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Plain-text body (may be empty when only HTML is present).
|
||||
/// </summary>
|
||||
[Column("TEXT_BODY", TypeName = "nvarchar(max)")]
|
||||
#if NET
|
||||
public string TextBody { get; init; } = string.Empty;
|
||||
#else
|
||||
public string TextBody { get; set; } = string.Empty;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// HTML body (may be empty when only plain-text is present).
|
||||
/// </summary>
|
||||
[Column("HTML_BODY", TypeName = "nvarchar(max)")]
|
||||
#if NET
|
||||
public string HtmlBody { get; init; } = string.Empty;
|
||||
#else
|
||||
public string HtmlBody { get; set; } = string.Empty;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Date/time the message was sent (Date header).
|
||||
/// </summary>
|
||||
[Column("DATE", TypeName = "datetime2")]
|
||||
#if NET
|
||||
public DateTime Date { get; init; }
|
||||
#else
|
||||
public DateTime Date { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Whether the message has been marked as seen/read on the server.
|
||||
/// </summary>
|
||||
[Column("IS_SEEN", TypeName = "bit")]
|
||||
#if NET
|
||||
public bool IsSeen { get; init; }
|
||||
#else
|
||||
public bool IsSeen { get; set; }
|
||||
#endif
|
||||
|
||||
[ForeignKey(nameof(Account))]
|
||||
[Column("ACCOUNT_ID", TypeName = "int")]
|
||||
#if NET
|
||||
public int AccountId { get; init; }
|
||||
#else
|
||||
public int AccountId { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Attachments included with this message.
|
||||
/// </summary>
|
||||
#if NET
|
||||
public IEnumerable<EmailAttachment>? Attachments { get; init; }
|
||||
#else
|
||||
public IEnumerable<EmailAttachment>? Attachments { get; set; }
|
||||
#endif
|
||||
|
||||
#if NET
|
||||
public EmailAccount? Account { get; init; }
|
||||
#else
|
||||
public EmailAccount? Account { get; set; }
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace DigitalData.MessagingService.Domain.Exceptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception thrown when a requested entity is not found.
|
||||
/// </summary>
|
||||
public class BadRequestException : Exception
|
||||
{
|
||||
public BadRequestException(string entityName, object key) : base($"{entityName} with key '{key}' was not found.")
|
||||
{
|
||||
}
|
||||
|
||||
public BadRequestException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public BadRequestException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Infrastructure.Mappings;
|
||||
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||
using DigitalData.MessagingService.Infrastructure.Repositories;
|
||||
using DigitalData.MessagingService.Infrastructure.Services;
|
||||
using DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
using DigitalData.MessagingService.Publisher;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -23,8 +29,12 @@ public static class DependencyInjection
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// --- External Services ---
|
||||
// Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker)
|
||||
// Email Service - SMTP outbound (Limilabs Mail.dll)
|
||||
services.AddSingleton<IEmailService, LimilabsEmailService>();
|
||||
|
||||
// Email Service - IMAP inbound (Limilabs Mail.dll)
|
||||
// Fresh connection per call — stateless and thread-safe.
|
||||
services.AddSingleton<IImapEmailService, LimilabsImapEmailService>();
|
||||
|
||||
// PDF Processing Service (using DevExpress.Pdf)
|
||||
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
||||
@@ -46,7 +56,20 @@ public static class DependencyInjection
|
||||
|
||||
// Register Background Workers
|
||||
services.AddHostedService<AsyncInitWorker>();
|
||||
services.AddHostedService<EmailSyncWorker>();
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
// --- Database (InMemory) ---
|
||||
services.AddDbContext<MessagingServiceDbContext>(options =>
|
||||
options.UseInMemoryDatabase("MessagingServiceDb"));
|
||||
|
||||
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
|
||||
// AutoMapper - Register entity self-mappings (T -> T) for generic repository
|
||||
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.65.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Mappings;
|
||||
|
||||
/// <summary>
|
||||
/// AutoMapper profile that registers self-mappings (T -> T) for all domain entities.
|
||||
/// This allows AutoMapper to be used uniformly in the generic repository
|
||||
/// regardless of whether TDto is a DTO or the entity type itself.
|
||||
/// </summary>
|
||||
public class EntitySelfMappingProfile : Profile
|
||||
{
|
||||
public EntitySelfMappingProfile()
|
||||
{
|
||||
CreateMap<EmailAccount, EmailAccount>();
|
||||
CreateMap<ReceivedEmail, ReceivedEmail>();
|
||||
CreateMap<EmailAttachment, EmailAttachment>();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,38 @@
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core DbContext for MessagingService.
|
||||
/// IMPORTANT: This context maps to a LEGACY database - NO schema modifications allowed!
|
||||
/// </summary>
|
||||
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<EmailAccount> EmailAccounts => Set<EmailAccount>();
|
||||
public DbSet<ReceivedEmail> ReceivedEmails => Set<ReceivedEmail>();
|
||||
public DbSet<EmailAttachment> EmailAttachments => Set<EmailAttachment>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<EmailAccount>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Username)
|
||||
.UseCollation("SQL_Latin1_General_CP1_CI_AS");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ReceivedEmail>(entity =>
|
||||
{
|
||||
entity.HasMany(e => e.Attachments)
|
||||
.WithOne(a => a.Email)
|
||||
.HasForeignKey(a => a.EmailId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TEntity>> CreateAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = Mapper.Map<IEnumerable<TEntity>>(dtos);
|
||||
await _dbSet.AddRangeAsync(entities, cancellationToken);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return entities;
|
||||
}
|
||||
|
||||
// --- READ ---
|
||||
|
||||
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
@@ -44,13 +52,13 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _dbSet.Where(predicate);
|
||||
|
||||
|
||||
if (skip.HasValue)
|
||||
query = query.Skip(skip.Value);
|
||||
|
||||
|
||||
if (take.HasValue)
|
||||
query = query.Take(take.Value);
|
||||
|
||||
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -84,6 +92,59 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
||||
return await _dbSet.AnyAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
// --- UPSERT ---
|
||||
|
||||
/// <summary>
|
||||
/// Upsert: if no record matches the predicate, creates a new entity;
|
||||
/// if one or more match, updates the FIRST match.
|
||||
/// Returns the entity and a flag indicating whether it was created (true) or updated (false).
|
||||
/// Auto-saves changes.
|
||||
/// </summary>
|
||||
public async Task<(TEntity Entity, bool Created)> UpsertAsync<TDto>(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
TDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = Mapper.Map<TEntity>(dto);
|
||||
await _dbSet.AddAsync(entity, cancellationToken);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return (entity, true);
|
||||
}
|
||||
|
||||
Mapper.Map(dto, entity);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return (entity, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upsert (single-safe): if no record matches the predicate, creates a new entity;
|
||||
/// if exactly one matches, updates it. Throws InvalidOperationException if 2+ match.
|
||||
/// Auto-saves changes.
|
||||
/// </summary>
|
||||
public async Task<(TEntity Entity, bool Created)> UpsertSingleAsync<TDto>(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
TDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = Mapper.Map<TEntity>(dto);
|
||||
await _dbSet.AddAsync(entity, cancellationToken);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return (entity, true);
|
||||
}
|
||||
|
||||
Mapper.Map(dto, entity);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return (entity, false);
|
||||
}
|
||||
|
||||
// --- UPDATE ---
|
||||
|
||||
/// <summary>
|
||||
@@ -96,7 +157,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
||||
TDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||
Mapper.Map(dto, entity);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
@@ -113,12 +174,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
Mapper.Map(dto, entity);
|
||||
}
|
||||
|
||||
entities.ForEach(entity => Mapper.Map(dto, entity));
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return entities.Count;
|
||||
}
|
||||
@@ -134,7 +190,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||
_dbSet.Remove(entity);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
@@ -150,7 +206,6 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||
|
||||
_dbSet.RemoveRange(entities);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return entities.Count;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Application.Common.Options;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
|
||||
public class EmailSyncWorker(IImapEmailService imapService, IOptions<EmailAccountsOptions> Options, IServiceProvider Provider) : BackgroundService
|
||||
{
|
||||
private DateFilter? _dateFilter = null;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await UpsertSeedEmailAccount(stoppingToken);
|
||||
|
||||
if (imapService is not LimilabsImapEmailService limapService)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||
return;
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
using var scope = Provider.CreateAsyncScope();
|
||||
|
||||
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||
|
||||
foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
|
||||
if (account.ImapServer is not null)
|
||||
{
|
||||
// init or update last date filter
|
||||
_dateFilter = _dateFilter is null
|
||||
? new DateFilter
|
||||
{
|
||||
After = null,
|
||||
Before = DateTime.UtcNow
|
||||
}
|
||||
: new DateFilter
|
||||
{
|
||||
After = _dateFilter.Before,
|
||||
Before = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await limapService.FetchEmailsAsync(account, new MailSearchFilter { Date = _dateFilter }, stoppingToken);
|
||||
}
|
||||
|
||||
await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpsertSeedEmailAccount(CancellationToken stoppingToken)
|
||||
{
|
||||
using var scope = Provider.CreateAsyncScope();
|
||||
|
||||
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||
|
||||
// init seed email accounts if not exist
|
||||
foreach (var account in Options.Value.Accounts)
|
||||
await emailAccountRepo.UpsertAsync(a => a.Username == account.Username, account, stoppingToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Limilabs.Client.IMAP;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||
|
||||
public static class ImapExtensions
|
||||
{
|
||||
public static async Task CloseSafelyAsync(this Imap imap)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (imap.Connected)
|
||||
await imap.CloseAsync();
|
||||
}
|
||||
catch { /* Ignore disconnect errors */ }
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ using Limilabs.Client.SMTP;
|
||||
using Limilabs.Mail;
|
||||
using Limilabs.Mail.Headers;
|
||||
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
@@ -13,11 +14,10 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
|
||||
/// Commercial-grade library with superior Exchange support.
|
||||
/// SMTP configuration is injected via IOptions<EmailAccountsOptions> from appsettings.json.
|
||||
/// Uses the first account in the list whose <see cref="EmailAccountDto.Name"/> equals <c>"default"</c>,
|
||||
/// Uses the first account in the list whose <see cref="EmailAccount.Name"/> equals <c>"default"</c>,
|
||||
/// or falls back to the first account if none is named "default".
|
||||
/// </summary>
|
||||
public class LimilabsEmailService(
|
||||
IEncryptionService encryptionService) : IEmailService
|
||||
public class LimilabsEmailService() : IEmailService
|
||||
{
|
||||
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
|
||||
static LimilabsEmailService()
|
||||
@@ -71,7 +71,7 @@ public class LimilabsEmailService(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccountDto smtpAccount)
|
||||
private static async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount)
|
||||
{
|
||||
if (smtpAccount.SmtpUseSsl)
|
||||
{
|
||||
@@ -88,9 +88,7 @@ public class LimilabsEmailService(
|
||||
}
|
||||
else
|
||||
{
|
||||
var password = smtpAccount.PasswordEncrypted ? encryptionService.Decrypt(smtpAccount.Password) : smtpAccount.Password;
|
||||
|
||||
await smtp.LoginAsync(smtpAccount.Username, password);
|
||||
await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +109,7 @@ public class LimilabsEmailService(
|
||||
return message.ToString();
|
||||
}
|
||||
|
||||
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentContext> attachments)
|
||||
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
|
||||
{
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||
using Limilabs.Client.IMAP;
|
||||
using Limilabs.Mail;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// IMAP email service using Limilabs Mail.dll.
|
||||
/// Opens a fresh connection per call — stateless and thread-safe.
|
||||
/// </summary>
|
||||
public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger, IMemoryCache Cache) : IImapEmailService
|
||||
{
|
||||
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
|
||||
|
||||
static LimilabsImapEmailService()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
// Public API
|
||||
public async Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync(
|
||||
EmailAccount account,
|
||||
MailSearchFilter filter,
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
using var imap = await OpenAsync(account, filter.Folder, cancel);
|
||||
try
|
||||
{
|
||||
#region Find UIDs
|
||||
// Server-side: only date range; all other filters are applied in-process after cache retrieval
|
||||
List<ICriterion> criterions = [];
|
||||
|
||||
if (filter.Date is DateFilter dateF)
|
||||
{
|
||||
if (dateF.After is DateTime after)
|
||||
criterions.Add(Expression.SentSince(after.Date));
|
||||
|
||||
// IMAP BEFORE is exclusive, so add one day to make the bound inclusive
|
||||
if (dateF.Before is DateTime before)
|
||||
criterions.Add(Expression.SentBefore(before.Date.AddDays(1)));
|
||||
}
|
||||
|
||||
var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All();
|
||||
List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)];
|
||||
|
||||
if (filter.SortOrder == MailSortOrder.NewestFirst)
|
||||
uids.Reverse();
|
||||
#endregion
|
||||
|
||||
if (uids.Count == 0)
|
||||
return [];
|
||||
|
||||
var results = new List<ReceivedEmailDto>(uids.Count);
|
||||
|
||||
foreach (var uid in uids)
|
||||
{
|
||||
cancel.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
#region Read email
|
||||
var email = await Cache.GetOrCreateAsync(
|
||||
CacheKeyPrefix + uid,
|
||||
async entry =>
|
||||
{
|
||||
var eml = await imap.GetMessageByUIDAsync(uid, cancel);
|
||||
var mail = new MailBuilder().CreateFromEml(eml);
|
||||
var flags = await imap.GetFlagsByUIDAsync(uid, cancel);
|
||||
|
||||
var attachments = new List<EmailAttachmentDto>();
|
||||
|
||||
foreach (var att in mail.Attachments)
|
||||
{
|
||||
attachments.Add(new EmailAttachmentDto
|
||||
{
|
||||
FileName = att.FileName ?? "attachment",
|
||||
Content = att.Data,
|
||||
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
|
||||
IsInline = false,
|
||||
ContentId = att.ContentId
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var vis in mail.Visuals)
|
||||
{
|
||||
attachments.Add(new EmailAttachmentDto
|
||||
{
|
||||
FileName = vis.FileName ?? "inline",
|
||||
Content = vis.Data,
|
||||
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
|
||||
IsInline = true,
|
||||
ContentId = vis.ContentId
|
||||
});
|
||||
}
|
||||
|
||||
return new ReceivedEmailDto
|
||||
{
|
||||
Uid = uid,
|
||||
From = mail.From.FirstOrDefault()?.Address ?? string.Empty,
|
||||
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||
Subject = mail.Subject ?? string.Empty,
|
||||
TextBody = mail.Text ?? string.Empty,
|
||||
HtmlBody = mail.Html ?? string.Empty,
|
||||
Date = mail.Date ?? DateTime.MinValue,
|
||||
IsSeen = flags.Contains(Flag.Seen),
|
||||
Attachments = attachments,
|
||||
};
|
||||
});
|
||||
#endregion Read email
|
||||
|
||||
if (email is null)
|
||||
continue;
|
||||
|
||||
if (filter.UnseenOnly && email.IsSeen)
|
||||
continue;
|
||||
|
||||
if (filter.SubjectContains is string subject &&
|
||||
!email.Subject.Contains(subject, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (filter.SenderContains is string sender &&
|
||||
!email.From.Contains(sender, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (filter.RecipientContains is string recipient &&
|
||||
!email.To.Any(t => t.Contains(recipient, StringComparison.OrdinalIgnoreCase)) &&
|
||||
!email.Cc.Any(c => c.Contains(recipient, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
if (filter.BodyContains is string body &&
|
||||
!email.TextBody.Contains(body, StringComparison.OrdinalIgnoreCase) &&
|
||||
!email.HtmlBody.Contains(body, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (filter.Uid is UidFilter uidF)
|
||||
{
|
||||
if (uidF.Absolute is long exactUid && email.Uid != exactUid)
|
||||
continue;
|
||||
|
||||
if (uidF.Min is long min && email.Uid < min)
|
||||
continue;
|
||||
|
||||
if (uidF.Max is long max && email.Uid > max)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter.WithAttachments)
|
||||
results.Add(email);
|
||||
else
|
||||
results.Add(email with { Attachments = [] });
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex,
|
||||
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
|
||||
uid, filter.Folder);
|
||||
}
|
||||
}
|
||||
|
||||
await imap.CloseAsync(cancel);
|
||||
|
||||
if (filter.MaxCount is int maxCount && maxCount > 0 && results.Count > maxCount)
|
||||
return results.Take(maxCount);
|
||||
|
||||
return results;
|
||||
}
|
||||
catch (Limilabs.Client.ServerException ex)
|
||||
{
|
||||
await imap.CloseSafelyAsync();
|
||||
throw new AuthenticationFailedException(
|
||||
$"IMAP authentication failed for account '{account.Username}'.", ex);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
await imap.CloseSafelyAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to fetch emails from IMAP server '{account.ImapServer}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkAsSeenAsync(
|
||||
EmailAccount account,
|
||||
long uid,
|
||||
string folder = "INBOX",
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
using var imap = await OpenAsync(account, folder, cancel);
|
||||
try
|
||||
{
|
||||
await imap.MarkMessageSeenByUIDAsync(uid, cancel);
|
||||
await imap.CloseAsync(cancel);
|
||||
}
|
||||
catch (Limilabs.Client.ServerException ex)
|
||||
{
|
||||
await imap.CloseSafelyAsync();
|
||||
throw new AuthenticationFailedException(
|
||||
$"IMAP authentication failed for account '{account.Username}'.", ex);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
await imap.CloseSafelyAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to mark message UID={uid} as seen on '{account.ImapServer}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Imap> OpenAsync(EmailAccount account, string folder, CancellationToken cancel)
|
||||
{
|
||||
var imap = new Imap();
|
||||
|
||||
if (account.ImapUseSsl)
|
||||
await imap.ConnectSSLAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
|
||||
else
|
||||
await imap.ConnectAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
|
||||
|
||||
await imap.LoginAsync(account.Username, account.Password, cancel);
|
||||
|
||||
if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase))
|
||||
await imap.SelectInboxAsync(cancel);
|
||||
else
|
||||
await imap.SelectAsync(folder, cancel);
|
||||
|
||||
return imap;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DigitalData.MessagingService.Publisher;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj" />
|
||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
|
||||
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
namespace DigitalData.MessagingService.Publisher;
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class EmailAccountController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetEmailAccount([FromQuery] GetEmailAccountQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await mediator.Send(query, cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Email sending API controller.
|
||||
/// Enqueues outgoing emails to RabbitMQ for async processing.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class EmailController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public enum OnlyFilter
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
HtmlBody,
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
Uid,
|
||||
}
|
||||
|
||||
#region Send
|
||||
/// <summary>
|
||||
/// Send an email, optionally with file attachments.
|
||||
/// Omit the <c>attachments</c> field for a plain send.
|
||||
/// </summary>
|
||||
/// <param name="command">Email fields as form values</param>
|
||||
/// <param name="attachments">Optional uploaded files</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
|
||||
[HttpPost]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> SendEmail(
|
||||
[FromForm] PublishEmailCommand command,
|
||||
IFormFileCollection? attachments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var commandWithAttachments = command.WithAttachments(
|
||||
await BuildAttachmentsAsync(attachments, cancellationToken));
|
||||
|
||||
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
||||
return Accepted(new { Id = eventId });
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<EmailAttachmentDto>> BuildAttachmentsAsync(
|
||||
IFormFileCollection? files,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (files is null || files.Count == 0)
|
||||
return [];
|
||||
|
||||
var result = new List<EmailAttachmentDto>(files.Count);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms, cancellationToken);
|
||||
|
||||
result.Add(new EmailAttachmentDto
|
||||
{
|
||||
FileName = file.FileName,
|
||||
Content = ms.ToArray(),
|
||||
ContentType = file.ContentType
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
#endregion Send
|
||||
|
||||
#region Receive
|
||||
/// <summary>
|
||||
/// Fetch emails from an IMAP mailbox.
|
||||
/// </summary>
|
||||
/// <param name="query">Query parameters for filtering and fetching emails from the IMAP mailbox.</param>
|
||||
/// <param name="only"></param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>HTTP 200 with list of received emails.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> FetchEmails([FromQuery] FetchEmailsQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var emails = await mediator.Send(query, cancellationToken);
|
||||
|
||||
if(!emails.Any())
|
||||
return NotFound("No emails found matching the specified criteria.");
|
||||
|
||||
if (only == OnlyFilter.HtmlBody)
|
||||
{
|
||||
if (emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||
return Content(htmlBody, "text/html");
|
||||
else
|
||||
return NotFound();
|
||||
}
|
||||
else if (only == OnlyFilter.Uid)
|
||||
return Ok(emails.Select(e => e.Uid).ToList());
|
||||
else
|
||||
return Ok(emails);
|
||||
}
|
||||
#endregion Receive
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Email sending API controller.
|
||||
/// Enqueues outgoing emails to RabbitMQ for async processing.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class EmailsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Send an email, optionally with file attachments.
|
||||
/// Omit the <c>attachments</c> field for a plain send.
|
||||
/// </summary>
|
||||
/// <param name="command">Email fields as form values</param>
|
||||
/// <param name="attachments">Optional uploaded files</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
|
||||
[HttpPost]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> SendEmail(
|
||||
[FromForm] SendEmailCommand command,
|
||||
IFormFileCollection? attachments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var commandWithAttachments = command.WithAttachments(
|
||||
await BuildAttachmentsAsync(attachments, cancellationToken));
|
||||
|
||||
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
||||
return Accepted(new { Id = eventId });
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<EmailAttachmentContext>> BuildAttachmentsAsync(
|
||||
IFormFileCollection? files,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (files is null || files.Count == 0)
|
||||
return [];
|
||||
|
||||
var result = new List<EmailAttachmentContext>(files.Count);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms, cancellationToken);
|
||||
|
||||
result.Add(new EmailAttachmentContext
|
||||
{
|
||||
FileName = file.FileName,
|
||||
Content = ms.ToArray(),
|
||||
ContentType = file.ContentType
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +1,82 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Global exception handling middleware
|
||||
/// </summary>
|
||||
public class ExceptionHandlingMiddleware
|
||||
public class ExceptionHandlingMiddleware(RequestDelegate Next, ILogger<ExceptionHandlingMiddleware> Logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
||||
|
||||
public ExceptionHandlingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<ExceptionHandlingMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="context"></param>
|
||||
/// <returns></returns>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
await Next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HandleExceptionAsync(context, ex);
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var (statusCode, message) = ex switch
|
||||
{
|
||||
NotFoundException notFoundEx =>
|
||||
(HttpStatusCode.NotFound, notFoundEx.Message),
|
||||
|
||||
BadRequestException badRequestEx =>
|
||||
(HttpStatusCode.BadRequest, badRequestEx.Message),
|
||||
|
||||
AuthenticationFailedException authEx =>
|
||||
(HttpStatusCode.Unauthorized, authEx.Message),
|
||||
|
||||
ValidationException validationEx =>
|
||||
(HttpStatusCode.BadRequest, FormatValidationErrors(validationEx)),
|
||||
|
||||
_ => (HttpStatusCode.InternalServerError, "An internal server error occurred")
|
||||
};
|
||||
|
||||
context.Response.StatusCode = (int)statusCode;
|
||||
|
||||
// Log the exception
|
||||
if (statusCode == HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning(ex, "Exception handled: {StatusCode} - {Message}",
|
||||
statusCode, message);
|
||||
}
|
||||
|
||||
var response = new
|
||||
{
|
||||
StatusCode = (int)statusCode,
|
||||
Message = message,
|
||||
DetailedMessage = statusCode == HttpStatusCode.InternalServerError
|
||||
? ex.Message
|
||||
: null,
|
||||
Timestamp = DateTime.Now
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(response, _jsonSerializerOptions);
|
||||
|
||||
await context.Response.WriteAsync(json);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var (statusCode, message) = exception switch
|
||||
{
|
||||
NotFoundException notFoundEx =>
|
||||
(HttpStatusCode.NotFound, notFoundEx.Message),
|
||||
|
||||
AuthenticationFailedException authEx =>
|
||||
(HttpStatusCode.Unauthorized, authEx.Message),
|
||||
|
||||
FluentValidation.ValidationException validationEx =>
|
||||
(HttpStatusCode.BadRequest, FormatValidationErrors(validationEx)),
|
||||
|
||||
_ => (HttpStatusCode.InternalServerError, "An internal server error occurred")
|
||||
};
|
||||
|
||||
context.Response.StatusCode = (int)statusCode;
|
||||
|
||||
// Log the exception
|
||||
if (statusCode == HttpStatusCode.InternalServerError)
|
||||
{
|
||||
_logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(exception, "Exception handled: {StatusCode} - {Message}",
|
||||
statusCode, message);
|
||||
}
|
||||
|
||||
var response = new
|
||||
{
|
||||
StatusCode = (int)statusCode,
|
||||
Message = message,
|
||||
DetailedMessage = statusCode == HttpStatusCode.InternalServerError
|
||||
? exception.Message
|
||||
: null,
|
||||
Timestamp = DateTime.Now
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(response, _jsonSerializerOptions);
|
||||
|
||||
await context.Response.WriteAsync(json);
|
||||
}
|
||||
|
||||
private static string FormatValidationErrors(FluentValidation.ValidationException exception)
|
||||
private static string FormatValidationErrors(ValidationException exception)
|
||||
{
|
||||
var errors = exception.Errors
|
||||
.Select(e => $"{e.PropertyName}: {e.ErrorMessage}")
|
||||
|
||||
@@ -35,9 +35,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</ProjectReference>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using DigitalData.MessagingService.Publisher;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
namespace DigitalData.MessagingService.Client;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Client;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Tests.Integration;
|
||||
|
||||
@@ -77,7 +78,7 @@ public sealed class EmailSenderTests
|
||||
{
|
||||
var email = new EmailContext
|
||||
{
|
||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Recipients = ["hakanttek@gmail.com"],
|
||||
Subject = "EmailSender.Send Integration Test",
|
||||
Body = "<p>Sent via EmailSender static client.</p>",
|
||||
@@ -94,7 +95,7 @@ public sealed class EmailSenderTests
|
||||
{
|
||||
var email = new EmailContext
|
||||
{
|
||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Recipients = ["hakanttek@gmail.com"],
|
||||
Subject = "Plain Text Test",
|
||||
Body = "This is a plain text email.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Client;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Tests.Integration;
|
||||
|
||||
@@ -57,7 +58,7 @@ public sealed class EmailSenderUrlOverloadTests
|
||||
{
|
||||
var email = new EmailContext
|
||||
{
|
||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Recipients = ["url-overload-test@example.com"],
|
||||
Subject = "URL Overload Integration Test",
|
||||
Body = "<p>Sent after URL-based connection.</p>",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DigitalData.MessagingService.Publisher;
|
||||
using DigitalData.MessagingService.Abstraction;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Tests.Integration;
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
|
||||
Id = Guid.NewGuid(),
|
||||
Mail = new EmailContext
|
||||
{
|
||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Recipients = ["test@example.com"],
|
||||
Subject = "Integration Test - EnqueueAsync",
|
||||
Body = "<p>Hello from integration test.</p>",
|
||||
@@ -68,8 +68,8 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
|
||||
Id = Guid.NewGuid(),
|
||||
Mail = new EmailContext
|
||||
{
|
||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Recipients = new List<string> { $"recipient{i}@example.com" },
|
||||
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Recipients = [$"recipient{i}@example.com"],
|
||||
Subject = $"Integration Test - Batch #{i}",
|
||||
Body = $"Batch message {i}",
|
||||
IsHtml = false,
|
||||
@@ -95,7 +95,7 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
|
||||
Id = Guid.NewGuid(),
|
||||
Mail = new EmailContext
|
||||
{
|
||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Recipients = ["depth-test@example.com"],
|
||||
Subject = "Integration Test - GetQueueDepth",
|
||||
Body = "Queue depth test",
|
||||
@@ -122,7 +122,7 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
|
||||
Id = id,
|
||||
Mail = new EmailContext
|
||||
{
|
||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||
Recipients = new List<string> { "serialize@example.com" },
|
||||
Subject = "Serialization Test",
|
||||
Body = "<strong>Bold</strong>",
|
||||
|
||||
Reference in New Issue
Block a user