diff --git a/AGENTS.md b/AGENTS.md
index 8c33b2ff..393ab6ca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
## Repository Overview
-Odin is a multi-package .NET library solution for ordinary line-of-business infrastructure components.
+Odin, short for 'OrDinary INfrastructure', is a multi-package mono-repo containing packages for .NET line-of-business application development concerns.
The root solution is `Odin.sln`; `TestsOnly.sln` is used for test validation.
Primary component folders include:
@@ -79,6 +79,11 @@ Run integration tests only when the change explicitly needs them or the user ask
- Production packages: `Odin.[.]`
- Tests: `Tests.Odin.`
+## Package Publish to Nuget
+
+- All packages in the Odin libraries are published to Nuget by the Github Action defined in '.github/workflows/publish.yml'
+- When changing the path to a package, ensure the corresponding publish.yml step is updated to reflect the new path.
+
## Git Hygiene
- Preserve user changes already present in the worktree.
diff --git a/Emailing/EmailingV1Plan.md b/Emailing/EmailingV1Plan.md
new file mode 100644
index 00000000..f0cc029c
--- /dev/null
+++ b/Emailing/EmailingV1Plan.md
@@ -0,0 +1,217 @@
+# Odin.Emailing V1 Development Plan
+
+## A - Project Goals
+
+The Odin.Emailing namespace will provide a common object model for creating and sending email through the world's most popular providers used by developers of .NET APIs and applications.
+
+The objective is to provide a single common object model for use with all the world's most popular transactional,
+marketing, developer-focussed, enterprise SaaS providers sending APIs and email object models.
+
+Aspects that might emerge as needing to be incorporated (over and above simple email send)
+could include tracking, tagging, email merging and templates, and more... It depends what services the email sending providers provide that are popular.
+
+Providers to support in rough order of priority (popularity in the .NET community) are suspected to be
+SendGrid, Mailgun, standard SMTP, Amazon SES, Office365, Azure Communication Services, Postmark, Resend, SparkPost, Brevo, Mailjet, SMTP2GO.
+
+This will be an evolution of the work done previously in Odin.Email.
+
+## B - Project Plan
+
+1 - Isolate the most popular providers and document their API surface areas
+
+1.1 - Tabulate and research popularity of all potential emailing providers in the .NET ecosystem.
+
+1.2 - Research the top 10 providers API and email message object models.
+
+2 - Create a new expanded provider abstraction API surface: IEmailMessage model, revised email sending models and maybe more.
+
+2.1 - Create a new IEmailMessage model with expanded properties and methods
+
+2.2 - Implement a new email sending interface, that will separate email send\dispath from a Provider sending adaptor. The overall surface area of this may need to expand to accommodate 'email merge' or other Provider functionality not known yet.
+
+2.3- Evaluate whether 'mail merge' needs a distinct API?
+
+3 - IEmailSender configuration handling
+
+3.1 - Create an object model for IConfiguration injection of a dictionary of
+provider-specific options. Keep the settings for 'defaults' used in Odin.Email, but introduce allowing > 1 providers.
+
+4 - IEmailDispatcher implementations
+
+4.1 - Research and tabulate what dispatching features are or would be popular: Eg queueing, retries, concurrency, logging to database, etc.
+
+4.2 - Choose dispatching features for this V1.
+
+4.3 - TBA...
+
+4.4 - Create extensive unit tests
+
+5 - Email sending integration testing
+
+5.1 - Research and tabulate options to actually test delivery of email through temporary concrete inboxes, eg Mailinator, Mailosaur, Mailtrap
+
+5.2 - Create email send and receive integration testing scaffolding.
+
+6 - Provider implementations
+
+
+
+
+# 1 New IEmailMessage model and revised email sending model
+
+## 1.1 Table of Potential Providers
+Below is a researched table of potential providers, ordered by directional popularity with developers and deployed applications, not by NuGet package downloads.
+
+There is no single perfect source for "developer popularity." Media and comparison roundups are useful for surfacing mindshare and newer developer-first platforms, but they can be biased by sponsorship, SEO, affiliate incentives, and recency. A better ranking should combine several imperfect signals:
+
+- Public web-technology detection, such as [BuiltWith Transactional Email usage](https://trends.builtwith.com/mx/transactional-email), which captures deployed footprint but can miss API-only usage.
+- Company install-base datasets, such as [Enlyft Transactional Email products](https://enlyft.com/tech/transactional-email), which are useful for relative market presence but have opaque collection methods.
+- Developer self-reporting and stack pages, such as [StackShare Email Services](https://stackshare.io/email-services), which better reflects developer familiarity but is sample-biased.
+- Developer media, benchmarks, and community discussion, which help identify rising providers such as Resend and Mailtrap but should not be treated as hard market share.
+- Platform gravity from AWS, Google Workspace, Microsoft 365, Azure, and OCI, especially where teams choose the email service because it is native to the cloud or productivity platform they already use.
+
+The table therefore uses a broad, qualitative popularity evidence column. It is intentionally not a numeric market-share table.
+
+| Rank | Provider | API documentation | Broad popularity evidence, excluding NuGet | Typical client types | SMTP support | Mail focus areas |
+| --- | --- | --- | --- | --- | --- | --- |
+| 1 | Standard SMTP relay / protocol adapters | [MailKit SmtpClient API](https://mimekit.net/docs/html/T_MailKit_Net_Smtp_SmtpClient.htm) | Provider-neutral baseline rather than a vendor. SMTP remains the common fallback across legacy apps, devices, business systems, and providers that expose both SMTP and HTTP APIs. | Any .NET app, legacy LOB systems, devices, internal relays, teams wanting provider portability | Yes, this is the baseline protocol | Lowest-common-denominator sending abstraction, portability, relay replacement, device/app compatibility |
+| 2 | Twilio SendGrid | [Mail Send API](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send) | Top-tier across broad sources: Enlyft lists SendGrid first among transactional email products, StackShare lists it as the top developer email API after Gmail, and BuiltWith places it near the top of detected transactional email usage. | SaaS products, startups, Azure/.NET apps, high-volume transactional and marketing senders | Yes, [SMTP API](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/integrating-with-the-smtp-api) | Developer email API, SMTP relay, dynamic templates, analytics, deliverability, marketing campaigns |
+| 3 | Amazon SES | [SES v2 SendEmail API](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html) | Very strong deployed footprint: BuiltWith lists Amazon SES as the most detected transactional email technology, Enlyft places it among the top three, and StackShare shows high developer adoption. | AWS-hosted apps, cloud-native teams, cost-sensitive high-volume senders | Yes, [SES SMTP interface](https://docs.aws.amazon.com/ses/latest/dg/send-an-email-using-smtp.html) | Low-cost high-volume sending, transactional and marketing email, AWS IAM/infrastructure integration |
+| 4 | Mailgun | [Messages API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages) | Consistently top-tier in web usage, company install-base data, StackShare adoption, and developer discussions about API-first transactional email. | Developers, SaaS teams, platforms needing API-first send, inbound routing, validation | Yes, [SMTP or API](https://help.mailgun.com/hc/en-us/articles/202464990-How-can-I-start-sending-email) | Developer email API, SMTP relay, routing, inbound parse, validation, logs and analytics |
+| 5 | Mailchimp Transactional (Mandrill) | [Messages API](https://mailchimp.com/developer/transactional/api/messages/) | Strong legacy and deployed footprint: Mandrill is high in BuiltWith and Enlyft, and StackShare still shows meaningful developer use despite its Mailchimp-addon positioning. | Mailchimp users, ecommerce, legacy Mandrill users, event-driven app mail | Yes, [SMTP integration](https://mailchimp.com/developer/transactional/docs/smtp-integration/) | Transactional email add-on, templates, event messages, Mailchimp ecosystem fit |
+| 6 | Google Workspace / Gmail | [Gmail users.messages.send API](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send) | Huge developer familiarity and StackShare presence, but it is primarily mailbox/workspace email rather than a transactional email provider. Strong for apps sending as users or through Workspace relay. | Google Workspace tenants, mailbox-centric apps, admin-managed SMTP relay scenarios | Yes via [Workspace SMTP relay](https://support.google.com/a/answer/2956491?hl=en); API sending via [Gmail API](https://developers.google.com/gmail/api/guides/sending) | User/mailbox send, Workspace relay, OAuth-controlled integrations, small business and internal tooling |
+| 7 | Microsoft 365 / Exchange Online | [Graph sendMail API](https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0) | Massive enterprise platform gravity and strong relevance for business apps, but usage is mailbox/tenant-oriented rather than specialist transactional-email market share. | Enterprise tenants, internal business apps, mailbox-based workflows, Microsoft-first organizations | Yes via [Exchange Online SMTP AUTH](https://learn.microsoft.com/en-us/Exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission); API sending via [Graph sendMail](https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0) | Tenant/mailbox send, compliance, delegated or application permissions, Office integration |
+| 8 | Mailjet | [Send API v3.1](https://dev.mailjet.com/email/guides/send-api-v31/) | Strong mid-market and European footprint: Enlyft and BuiltWith both place Mailjet high among detected transactional email providers. | SMB and mid-market teams, European/Sinch customers, combined marketing and transactional senders | Yes, [SMTP relay](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters) | Transactional and marketing email, team email editor, templates, deliverability, SMTP/API |
+| 9 | Brevo (formerly Sendinblue) | [sendTransacEmail API](https://developers.brevo.com/reference/sendtransacemail) | Strong SMB and marketing-automation presence: Enlyft lists SendinBlue/Brevo near Mailjet, and BuiltWith shows both Brevo and SendinBlue signals with meaningful detected usage. | SMBs, ecommerce, CRM/marketing automation users, transactional app senders | Yes, [SMTP relay](https://developers.brevo.com/docs/smtp-integration) | Marketing automation, transactional email, contacts/CRM, SMS and multichannel campaigns |
+| 10 | Postmark | [Email API](https://postmarkapp.com/developer/api/email-api) | Smaller deployed footprint than SendGrid/SES/Mailgun, but disproportionately strong developer reputation for transactional deliverability and simple APIs. Frequently appears in developer recommendations and comparison media. | SaaS and product teams that prioritize transactional reliability and support | Yes, [SMTP sending](https://postmarkapp.com/developer/user-guide/send-email-with-smtp) | Transactional email, message streams, templates, inbound parsing, delivery visibility |
+| 11 | SparkPost | [Transmissions API](https://developers.sparkpost.com/api/transmissions/) | Meaningful high-volume/enterprise presence. BuiltWith and Enlyft show a smaller but established footprint; developer mindshare is lower than SendGrid/Mailgun/Postmark. | High-volume senders, deliverability-focused teams, enterprise email programs | Yes, [SMTP API](https://developers.sparkpost.com/api/smtp/) | High-volume transactional and marketing email, analytics, webhooks, deliverability signals |
+| 12 | Elastic Email | [REST API](https://elasticemail.com/developers/api-documentation/rest-api) | Visible in BuiltWith and Enlyft with a meaningful long-tail footprint, especially among cost-sensitive senders and mixed marketing/API use cases. | Cost-conscious SMBs, bulk marketers, apps needing combined marketing and transactional email | Yes, [SMTP settings](https://help.elasticemail.com/en/articles/4803409-smtp-settings) | Bulk and campaign email, REST API, contact/campaign management, transactional sending |
+| 13 | Resend | [Send Email API](https://resend.com/docs/api-reference/emails/send-email) | Lower deployed-footprint signal than older providers, but strong modern developer mindshare in API-first email discussions and recent comparison media. Worth watching despite newer market position. | Modern SaaS/startup developers, product teams wanting simple API-first email | Yes, [SMTP sending](https://resend.com/docs/send-with-smtp) | Developer-first transactional email, API ergonomics, templates, domains, webhooks |
+| 14 | MailerSend | [Sending an Email API](https://developers.mailersend.com/api/v1/email.html) | Detected in BuiltWith and Enlyft below the largest providers. Relevant as a modern transactional-focused service with API, SMTP, templates, and webhooks. | SMB/SaaS apps, product teams needing transactional templates and webhooks | Yes, [SMTP relay](https://developers.mailersend.com/smtp-relay) | Transactional email API/SMTP, templates, inbound routing, webhooks, activity logs |
+| 15 | Mailtrap Email API/SMTP | [Transactional API](https://docs.mailtrap.io/developers/email-sending) | Strong developer awareness for email sandbox/testing; production sending is newer and has a smaller deployed-footprint signal than the long-standing providers. | Developers and QA teams, apps needing sandbox testing plus production sending | Yes, [Email API/SMTP](https://docs.mailtrap.io/getting-started) | Email sandbox/testing, production transactional sending, logs, templates, deliverability diagnostics |
+| 16 | SMTP2GO | [Email send API](https://developers.smtp2go.com/reference/send-standard-email) | Visible in BuiltWith as an SMTP-first relay provider. Less developer-media mindshare than API-first providers but common in operational and migration scenarios. | SMBs, MSPs, operational apps, devices, WordPress/CMS, systems needing reliable relay | Yes, [SMTP relay](https://developers.smtp2go.com/docs/smtp-relay) | SMTP-first relay, reporting, deliverability, regional relay infrastructure, simple migration |
+| 17 | Zoho ZeptoMail | [API index](https://www.zoho.com/zeptomail/help/api-index.html) | Visible in BuiltWith and backed by Zoho's broader SMB/productivity ecosystem. More common as a Zoho-adjacent transactional choice than a general developer default. | Zoho customers, SMB apps, transactional-only application senders | Yes, [SMTP configuration](https://help.zoho.com/portal/en/kb/zeptomail/faqs/sending-emails/articles/how-to-configure-smtp) | Transactional email only, OTPs, password resets, invoices, templates, separation from bulk marketing |
+| 18 | Azure Communication Services Email | [Email Send REST API](https://learn.microsoft.com/en-us/rest/api/communication/email/email/send?tabs=HTTP&view=rest-communication-email-2023-03-31) | Important for Azure-native and Microsoft-aligned applications, but public broad-market popularity signals are weaker than SendGrid, SES, Mailgun, and Postmark. | Azure-first apps, enterprise LOB apps, teams replacing old authenticated SMTP | Yes, [ACS SMTP support](https://learn.microsoft.com/en-us/azure/communication-services/concepts/email/email-smtp-overview) | Azure-hosted outbound email, modern SMTP auth through Entra, SDK/API sending, centralized Azure control |
+| 19 | Infobip Email | [Email over HTTP API](https://www.infobip.com/docs/email/email-over-api/send-email-over-http-api) | Strong CPaaS/enterprise provider, but general developer email-provider mindshare is lower than its SMS/omnichannel footprint. Relevant for teams already using Infobip. | Enterprise and global brands using omnichannel CPaaS | Yes, [SMTP API](https://www.infobip.com/docs/email/smtp-specification) | Omnichannel messaging, transactional and marketing email, global delivery, SMS/WhatsApp adjacency |
+| 20 | Oracle Cloud Infrastructure Email Delivery | [Email Delivery HTTP API guide](https://docs.oracle.com/en/learn/send-email-with-ociemaildelivery-http/index.html) | Platform-native OCI option. Important inside Oracle Cloud environments, but smaller broad developer mindshare and public detected footprint than AWS SES or the specialist providers. | OCI-hosted workloads, enterprise cloud apps, Oracle customers | Yes, [SMTP or HTTPS submission](https://docs.oracle.com/en-us/iaas/Content/Email/Reference/gettingstarted_topic-Begin_sending_email.htm) | Managed outbound relay for transactional and high-volume email in OCI |
+
+## 1.2 Provider API and Message Object Model Comparison
+
+This table compares the top 10 provider/protocol entries from the 1.1 popularity research and maps them to a suggested `IEmailMessage` property model. The top 10 are interpreted as:
+
+1. Standard SMTP / MimeKit
+2. Twilio SendGrid
+3. Amazon SES
+4. Mailgun
+5. Mailchimp Transactional
+6. Google Workspace / Gmail
+7. Microsoft 365 / Exchange Online
+8. Mailjet
+9. Brevo
+10. Postmark
+
+The table focuses on message construction and send-time API fields, not account configuration or post-send event/webhook models. Blank or `n/a` cells mean the provider does not expose a close first-class equivalent in the send API. Some providers can still support the concept through raw MIME, custom headers, or provider-specific options.
+
+Primary references:
+
+- [MimeKit `MimeMessage` properties](https://mimekit.net/docs/html/Properties_T_MimeKit_MimeMessage.htm)
+- [Twilio SendGrid Mail Send API](https://www.twilio.com/docs/sendgrid/api-reference/mail-send) and [personalizations](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/personalizations)
+- [Amazon SES v2 `SendEmail`](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html)
+- [Mailgun Messages API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages)
+- [Mailchimp Transactional Messages API](https://mailchimp.com/developer/transactional/api/messages/)
+- [Gmail `users.messages` resource](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages)
+- [Microsoft Graph `message` resource](https://learn.microsoft.com/en-us/graph/api/resources/message?view=graph-rest-1.0) and [`sendMail` action](https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0)
+- [Mailjet Send API v3.1](https://dev.mailjet.com/email/guides/send-api-v31/)
+- [Brevo `sendTransacEmail`](https://developers.brevo.com/reference/send-transac-email)
+- [Postmark Email API](https://postmarkapp.com/developer/api/email-api)
+
+| Suggested `IEmailMessage` property | Suggested Odin type | SMTP / MimeKit | SendGrid | Amazon SES v2 | Mailgun | Mailchimp Transactional | Gmail API | Microsoft Graph | Mailjet | Brevo | Postmark |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `From` | `EmailAddress?` | `MimeMessage.From: InternetAddressList` | `from: EmailAddress` | `FromEmailAddress: string` | `from: string` | `message.from_email: string`, `message.from_name: string` | `raw` MIME `From` header | `message.from: recipient` | `Messages[].From: object` | `sender: object` | `From: string` |
+| `Sender` | `EmailAddress?` | `MimeMessage.Sender: MailboxAddress` | n/a | n/a | `h:Sender: string` header | n/a | `raw` MIME `Sender` header | `message.sender: recipient` | `Messages[].Sender: object` | n/a | custom `Headers[]` |
+| `ReplyTo` | `IReadOnlyList` | `MimeMessage.ReplyTo: InternetAddressList` | `reply_to: EmailAddress`, `reply_to_list: EmailAddress[]` | `ReplyToAddresses: string[]` | `h:Reply-To: string` header | `message.headers["Reply-To"]: string` | `raw` MIME `Reply-To` header | `message.replyTo: recipient[]` | `Messages[].ReplyTo: object` | `replyTo: object` | `ReplyTo: string` |
+| `To` | `IReadOnlyList` | `MimeMessage.To: InternetAddressList` | `personalizations[].to: EmailAddress[]` | `Destination.ToAddresses: string[]` | `to: string[]` | `message.to[].type = "to"` | `raw` MIME `To` header | `message.toRecipients: recipient[]` | `Messages[].To: object[]` | `to: object[]` | `To: string` |
+| `Cc` | `IReadOnlyList` | `MimeMessage.Cc: InternetAddressList` | `personalizations[].cc: EmailAddress[]` | `Destination.CcAddresses: string[]` | `cc: string[]` | `message.to[].type = "cc"` | `raw` MIME `Cc` header | `message.ccRecipients: recipient[]` | `Messages[].Cc: object[]` | `cc: object[]` | `Cc: string` |
+| `Bcc` | `IReadOnlyList` | `MimeMessage.Bcc: InternetAddressList` | `personalizations[].bcc: EmailAddress[]` | `Destination.BccAddresses: string[]` | `bcc: string[]` | `message.to[].type = "bcc"`, or `message.bcc_address: string` | `raw` MIME `Bcc` header | `message.bccRecipients: recipient[]` | `Messages[].Bcc: object[]` | `bcc: object[]` | `Bcc: string` |
+| `Subject` | `string?` | `MimeMessage.Subject: string` | `subject: string`, or `personalizations[].subject` | `Content.Simple.Subject.Data: string` | `subject: string` | `message.subject: string` | `raw` MIME `Subject` header | `message.subject: string` | `Messages[].Subject: string` | `subject: string` | `Subject: string` |
+| `TextBody` | `string?` | `BodyBuilder.TextBody: string` | `content[].type = "text/plain"`, `content[].value: string` | `Content.Simple.Body.Text.Data: string` | `text: string` | `message.text: string` | `raw` MIME `text/plain` part | `message.body.content` when `contentType = "text"` | `Messages[].TextPart: string` | `textContent: string` | `TextBody: string` |
+| `HtmlBody` | `string?` | `BodyBuilder.HtmlBody: string` | `content[].type = "text/html"`, `content[].value: string` | `Content.Simple.Body.Html.Data: string` | `html: string` | `message.html: string` | `raw` MIME `text/html` part | `message.body.content` when `contentType = "html"` | `Messages[].HTMLPart: string` | `htmlContent: string` | `HtmlBody: string` |
+| `AmpHtmlBody` | `string?` | MIME `text/x-amp-html` part | n/a | raw MIME only | `amp-html: string` | n/a | `raw` MIME `text/x-amp-html` part | raw MIME only | n/a | n/a | n/a |
+| `RawMime` | `ReadOnlyMemory?` | `MimeMessage` serialized with `WriteTo` | n/a | `Content.Raw.Data: blob` | `/messages.mime` MIME upload | `/messages/send-raw raw_message: string` | `raw: base64url string` | MIME send supported separately from `message` JSON | n/a | n/a | n/a |
+| `Attachments` | `IReadOnlyList` | `BodyBuilder.Attachments: AttachmentCollection` | `attachments[]: object` | `Content.Simple.Attachments[]` or `Content.Template.Attachments[]` | `attachment: binary[]` | `message.attachments[]: object[]` | `raw` MIME parts, or `payload.parts[]` when reading | `message.attachments: attachment[]` | `Messages[].Attachments: object[]` | `attachment: object[]` | `Attachments: object[]` |
+| `InlineAttachments` | `IReadOnlyList` | `BodyBuilder.LinkedResources: AttachmentCollection` | `attachments[].disposition = "inline"`, `content_id: string` | attachment `ContentDisposition = "INLINE"`, `ContentId: string` | `inline: binary[]` | `message.images[]: object[]` | `raw` MIME parts with `Content-ID` | `fileAttachment.isInline: bool`, `contentId: string` | `Messages[].InlinedAttachments: object[]` | inline via attachment plus content IDs in HTML, no strong first-class split | `Attachments[].ContentID: string` |
+| `Headers` | `IReadOnlyDictionary` | `MimeMessage.Headers: HeaderList` | `headers: object`, or `personalizations[].headers` | `Content.Simple.Headers[]` or `Content.Template.Headers[]` | `h:*` form fields | `message.headers: object` | `raw` MIME headers, read via `payload.headers[]` | `message.internetMessageHeaders: internetMessageHeader[]` on create | `Messages[].Headers: object` | `headers: object` | `Headers: object[]` |
+| `Tags` | `IReadOnlyList` | custom headers, for example `X-Tag` | `categories: string[]` | `EmailTags: MessageTag[]` | `o:tag: string[]` | `message.tags: string[]` | custom headers or labels after send, not send API | `categories: string[]` | `Messages[].CustomCampaign: string` | `tags: string[]` | `Tag: string` |
+| `Metadata` | `IReadOnlyDictionary` | custom headers | `custom_args: object`, or `personalizations[].custom_args` | `EmailTags: MessageTag[]` for event tags | `v:*` custom variables | `message.metadata: object`, `recipient_metadata[]` | custom headers in raw MIME | `extensions`, `singleValueExtendedProperties`, or custom headers | `Messages[].Variables: object` | `params: object` | `Metadata: object` |
+| `TemplateId` | `string?` | n/a | `template_id: string` | `Content.Template.TemplateName: string` or `TemplateArn: string` | `template: string` | `/messages/send-template template_name: string` | n/a | n/a | `Messages[].TemplateID: integer` | `templateId: integer` | template endpoint uses `TemplateId` or `TemplateAlias` |
+| `TemplateModel` | `object?` | n/a | `personalizations[].dynamic_template_data: object` | `Content.Template.TemplateData: string` JSON | `t:variables: string` JSON, or `recipient-variables: string` JSON | `global_merge_vars[]`, `merge_vars[]` | n/a | n/a | `Messages[].Variables: object`, `TemplateLanguage: bool` | `params: object` | template endpoint `TemplateModel: object` |
+| `Personalizations` | `IReadOnlyList` | n/a | `personalizations[]: object[]` | bulk send uses `BulkEmailEntry[]` with replacement content | `recipient-variables: string` JSON | `merge_vars[]`, `recipient_metadata[]` | n/a | n/a | multiple `Messages[]` entries or `Variables` per message | `messageVersions[]: object[]` | batch send uses one object per recipient/message |
+| `SendAt` | `DateTimeOffset?` | no provider scheduling; MIME `Date` is only the message date | `send_at: integer` Unix time, or `personalizations[].send_at` | n/a | `o:deliverytime: string` | top-level `send_at: string` | n/a | n/a | n/a | `scheduledAt: string` | n/a |
+| `Tracking` | `EmailTrackingOptions?` | n/a | `tracking_settings: object` | via configuration set events, not message body tracking flags | `o:tracking`, `o:tracking-clicks`, `o:tracking-opens` | `track_opens: bool`, `track_clicks: bool` | n/a | `isReadReceiptRequested`, `isDeliveryReceiptRequested` | account/campaign settings; limited message-level equivalents | provider/account settings, no simple message-level equivalent in basic send body | `TrackOpens: bool`, `TrackLinks: string` |
+| `MessageStream` | `string?` | n/a | `asm.group_id` for unsubscribe grouping, `ip_pool_name` for IP pool | `ConfigurationSetName: string`, `ListManagementOptions: object` | `o:campaign: string`, route/domain determines stream | `subaccount: string`, `signing_domain`, `return_path_domain` | Gmail label/thread concepts are not send streams | mailbox/user route rather than message stream | `CustomCampaign: string` | `batchId: string`, tags for grouping | `MessageStream: string` |
+| `Priority` | `EmailPriority?` | `MimeMessage.Priority`, `Importance`, `XPriority` | custom headers only | custom headers only | `h:Importance`, `h:X-Priority` headers | `message.important: bool`, or custom headers | raw MIME priority headers | `message.importance: importance` | custom headers only | `headers` only | custom `Headers[]` |
+| `ProviderOptions` | `IReadOnlyDictionary` | SMTP client delivery options outside message | `mail_settings`, `asm`, `batch_id`, `ip_pool_name` | `TenantName`, identity ARNs, feedback forwarding, endpoint ID | `o:*`, `t:*`, domain route options | `async`, `ip_pool`, domains, analytics options | `threadId`, `labelIds` for draft/import workflows | `saveToSentItems`, mailbox user route, extensions | `DeduplicateCampaign`, template error settings | `batchId`, `messageVersions`, provider constraints | server token/stream/template endpoint details |
+
+### Suggested Shape
+
+The common model should make the basic RFC email envelope and body first-class, because every provider supports that path either directly or through raw MIME. Provider-specific delivery controls should stay out of the core `IEmailMessage` until they are common across several providers.
+
+Recommended V2 core surface:
+
+```csharp
+public interface IEmailMessage
+{
+ EmailAddress? From { get; }
+ EmailAddress? Sender { get; }
+ IReadOnlyList ReplyTo { get; }
+ IReadOnlyList To { get; }
+ IReadOnlyList Cc { get; }
+ IReadOnlyList Bcc { get; }
+ string? Subject { get; }
+ string? TextBody { get; }
+ string? HtmlBody { get; }
+ string? AmpHtmlBody { get; }
+ IReadOnlyList Attachments { get; }
+ IReadOnlyList InlineAttachments { get; }
+ IReadOnlyDictionary Headers { get; }
+ IReadOnlyList Tags { get; }
+ IReadOnlyDictionary Metadata { get; }
+ string? TemplateId { get; }
+ object? TemplateModel { get; }
+ IReadOnlyList Personalizations { get; }
+ DateTimeOffset? SendAt { get; }
+ EmailTrackingOptions? Tracking { get; }
+ string? MessageStream { get; }
+ EmailPriority? Priority { get; }
+ ReadOnlyMemory? RawMime { get; }
+ IReadOnlyDictionary ProviderOptions { get; }
+}
+```
+
+Likely supporting value objects:
+
+```csharp
+public sealed record EmailAddress(string Address, string? DisplayName = null);
+
+public sealed record EmailAttachment(
+ string FileName,
+ string ContentType,
+ ReadOnlyMemory Content,
+ string? ContentId = null,
+ bool IsInline = false);
+
+public sealed record EmailTrackingOptions(
+ bool? TrackOpens = null,
+ bool? TrackClicks = null,
+ bool? RequestReadReceipt = null,
+ bool? RequestDeliveryReceipt = null);
+
+public sealed record EmailPersonalization(
+ IReadOnlyList To,
+ IReadOnlyList? Cc = null,
+ IReadOnlyList? Bcc = null,
+ string? Subject = null,
+ object? TemplateModel = null,
+ IReadOnlyDictionary? Metadata = null,
+ DateTimeOffset? SendAt = null);
+```
diff --git a/Emailing/Tests/DependencyInjectionExtensionsTests.cs b/Emailing/Tests/DependencyInjectionExtensionsTests.cs
deleted file mode 100644
index f934e63c..00000000
--- a/Emailing/Tests/DependencyInjectionExtensionsTests.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using Odin.Emailing;
-using System.Text;
-
-namespace Tests.Odin.Email
-{
- public sealed class DependencyInjectionExtensionsTests
- {
- [Fact]
- public void Add_Null_provider()
- {
- WebApplicationBuilder Builder = WebApplication.CreateBuilder();
- Builder.Configuration.AddJsonStream(Stream(GetNullSenderConfigJson()));
- Builder.Services.AddOdinEmailSending(Builder.Configuration);
- WebApplication sut = Builder.Build();
-
- IEmailSender? mailSender = sut.Services.GetService();
- EmailSendingOptions? config = sut.Services.GetService();
-
- Assert.NotNull(mailSender);
- Assert.IsType(mailSender);
- Assert.NotNull(config);
- }
-
- [Fact]
- public void Add_Mailgun_provider()
- {
- WebApplicationBuilder builder = WebApplication.CreateBuilder();
- builder.Configuration.AddJsonStream(Stream(MailgunConfigJson));
- builder.Services.AddOdinEmailSending(builder.Configuration);
- WebApplication sut = builder.Build();
-
- IEmailSender? provider = sut.Services.GetService();
- EmailSendingOptions? config = sut.Services.GetService();
- MailgunOptions? mailgunConfig = sut.Services.GetService();
-
- Assert.NotNull(provider);
- Assert.IsType(provider);
- Assert.NotNull(config);
- Assert.NotNull(mailgunConfig);
- }
-
- [Fact]
- public void Add_Office365_provider()
- {
- WebApplicationBuilder builder = WebApplication.CreateBuilder();
- builder.Configuration.AddJsonStream(Stream(Office365ConfigJson));
- builder.Services.AddOdinEmailSending(builder.Configuration);
- WebApplication sut = builder.Build();
-
- IEmailSender? provider = sut.Services.GetService();
- EmailSendingOptions? config = sut.Services.GetService();
- Office365Options? providerConfig = sut.Services.GetService();
-
- Assert.NotNull(provider);
- Assert.IsType(provider);
- Assert.NotNull(config);
- Assert.NotNull(providerConfig);
- }
-
-
- public static string GetNullSenderConfigJson()
- {
- return @"{
- ""EmailSending"": {
- ""DefaultFromAddress"": ""rubbish@domain.co.za"",
- ""DefaultFromName"": ""LocalDevelopment"",
- ""Provider"": ""Null"",
- }
-}";
- }
-
- public const string MailgunConfigJson = @"{
- ""EmailSending"": {
- ""DefaultFromAddress"": ""noreply@splendid.bom"",
- ""DefaultFromName"": ""LocalDevelopment"",
- ""Provider"": ""Mailgun"",
- ""Mailgun"": {
- ""ApiKey"": ""AAAAAAAAAABBBBBBBBBBAAAAAAAAAABBBBBBBBBB"",
- ""Domain"": ""mailgun.domain.com""
- }
- }
-}";
-
- public const string Office365ConfigJson = @"{
- ""EmailSending"": {
- ""DefaultFromAddress"": ""noreply@splendid.bom"",
- ""DefaultFromName"": ""LocalDevelopment"",
- ""Provider"": ""Office365"",
- ""Office365"": {
- ""SenderUserId"": ""Set-in-user-secrets"",
- ""MicrosoftGraphClientSecretCredentials"": {
- ""ClientId"": ""Set-in-user-secrets"",
- ""TenantId"": ""Set-in-user-secrets"",
- ""ClientSecret"": ""Set-in-user-secrets""
- }
- }
- }
-}";
-
-
- public static Stream Stream(string input)
- {
- MemoryStream memoryStream = new MemoryStream();
- StreamWriter writer = new StreamWriter(memoryStream, Encoding.UTF8, leaveOpen: true);
-
- writer.Write(input);
- writer.Flush();
-
- memoryStream.Position = 0;
- return memoryStream;
- }
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/EmailAddressCollectionTests.cs b/Emailing/Tests/EmailAddressCollectionTests.cs
deleted file mode 100644
index 7de74d39..00000000
--- a/Emailing/Tests/EmailAddressCollectionTests.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using Odin.Emailing;
-
-namespace Tests.Odin.Email
-{
- public sealed class EmailAddressCollectionTests
- {
- public static TheoryData ConstructionFromAddressesCases() =>
- new()
- {
- { "Bob , Geoff ;,,", ["B@a.com", "G@y.com"] }
- };
-
- [Theory]
- [MemberData(nameof(ConstructionFromAddressesCases))]
- public void Construction_from_addresses(string testEmailAddresses,
- string[] expectedAddresses)
- {
- EmailAddressCollection sut = new EmailAddressCollection(testEmailAddresses);
-
- for (int i = 0; i < expectedAddresses.GetLength(0); i++)
- {
- Assert.Equal(expectedAddresses[i], sut[i].Address);
- }
- }
-
- [Theory]
- [InlineData("")]
- [InlineData(null)]
- [InlineData(" ")]
- public void Add_an_address_ignores_blank_address(string? testAddress)
- {
- EmailAddressCollection sut = new EmailAddressCollection();
-
- sut.AddAddress(testAddress!);
-
- Assert.Empty(sut);
- }
-
- [Theory]
- [InlineData("bob@a.com", "Bob", "bob@a.com", "Bob")]
- public void Add_an_address(string testAddress, string testDisplayName, string expectedAddress, string expectedName)
- {
- EmailAddressCollection sut = new EmailAddressCollection();
-
- sut.AddAddress(testAddress, testDisplayName);
-
- EmailAddress emailAddress = Assert.Single(sut);
- Assert.Equal(expectedName, emailAddress.DisplayName);
- Assert.Equal(expectedAddress, emailAddress.Address);
- }
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/EmailAddressTests.cs b/Emailing/Tests/EmailAddressTests.cs
deleted file mode 100644
index af8f1f29..00000000
--- a/Emailing/Tests/EmailAddressTests.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-namespace Tests.Odin.Email
-{
- public sealed class EmailAddressTests
- {
- [Theory]
- [InlineData("a@t.com", "a@t.com", null)]
- [InlineData(" a@t.com", "a@t.com", null)]
- [InlineData("a@t.com ", "a@t.com", null)]
- [InlineData("", "a@t.com", null)]
- [InlineData("B", "a@t.com", "B")]
- [InlineData(" B", "a@t.com", "B")]
- [InlineData("B ", "a@t.com", "B")]
- [InlineData("B< a@t.com>", "a@t.com", "B")]
- [InlineData("B", "a@t.com", "B")]
- [InlineData("BIGNORED", "a@t.com", "B")]
- public void EmailAddress_construction_from_address_only_string(string testEmailAddress, string expectedAddress,
- string? expectedDisplayName)
- {
- EmailAddress sut = new EmailAddress(testEmailAddress);
-
- Assert.Equal(expectedAddress, sut.Address);
- Assert.Equal(expectedDisplayName, sut.DisplayName);
- }
-
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/EmailMessageTests.cs b/Emailing/Tests/EmailMessageTests.cs
deleted file mode 100644
index 9d7e8e30..00000000
--- a/Emailing/Tests/EmailMessageTests.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using Odin.Emailing;
-
-namespace Tests.Odin.Email
-{
- public sealed class EmailMessageTests
- {
- [Theory]
- [InlineData(false)]
- [InlineData(true)]
- public void Construction_sets_properties(bool isHtml)
- {
- EmailMessage sut = new EmailMessage("to@d.com", "from@d.com", "subj", "bod", isHtml);
-
- Assert.Equal("to@d.com", sut.To[0].Address);
- Assert.Equal("from@d.com", sut.From!.Address);
- Assert.Equal("subj", sut.Subject);
- Assert.Equal("bod", sut.Body);
- Assert.Equal(isHtml, sut.IsHtml);
- Assert.Equal(Priority.Normal, sut.Priority);
- }
-
- [Theory]
- [InlineData(null)]
- [InlineData("")]
- [InlineData(" ")]
- public void Construction_defaults_for_empty_body(string? body)
- {
- EmailMessage sut = new EmailMessage("to@d.com", "from@d.com", "subj", body, false);
- EmailMessage sut2 = new EmailMessage();
-
- Assert.Equal("", sut.Body);
- Assert.Equal("", sut2.Body);
- }
-
- [Theory]
- [InlineData(null)]
- [InlineData("")]
- [InlineData(" ")]
- public void Construction_defaults_for_empty_subject(string? subject)
- {
- EmailMessage sut = new EmailMessage("to@d.com", "from@d.com", subject, "body", false);
- EmailMessage sut2 = new EmailMessage();
-
- Assert.Equal("", sut.Subject);
- Assert.Equal("", sut2.Subject);
- }
-
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/EmailSendingOptionsTests.cs b/Emailing/Tests/EmailSendingOptionsTests.cs
deleted file mode 100644
index d502fb49..00000000
--- a/Emailing/Tests/EmailSendingOptionsTests.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using Odin.Emailing;
-using Odin.System;
-
-
-namespace Tests.Odin.Email
-{
- public sealed class EmailSendingOptionsTests
- {
- [Theory]
- [InlineData("Mailgun", true)]
- [InlineData("Null", true)]
- public void IsConfigurationValid_requires_valid_provider(string provider, bool isValidConfig)
- {
- EmailSendingOptions sut = new EmailSendingOptions()
- {
- DefaultFromAddress = "123",
- DefaultFromName = "Tiger",
- Provider = provider
- };
-
- Result result = sut.Validate();
-
- Assert.Equal(isValidConfig, result.IsSuccess);
- }
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/EmailSendingProvidersTests.cs b/Emailing/Tests/EmailSendingProvidersTests.cs
deleted file mode 100644
index 20ea46c1..00000000
--- a/Emailing/Tests/EmailSendingProvidersTests.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using Odin.Emailing;
-
-namespace Tests.Odin.Email
-{
- public sealed class EmailSendingProvidersTests
- {
- [Theory]
- [InlineData("Mailgun", true)]
- [InlineData("Fake", false)]
- [InlineData("Null", true)]
- [InlineData(null, false)]
- [InlineData("", false)]
- [InlineData("Nonsense", false)]
- public void IsProviderSupported(string? provider, bool isSupported)
- {
- Assert.Equal(isSupported, EmailSendingProviders.HasValue(provider));
- }
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/EmailTestConfiguration.cs b/Emailing/Tests/EmailTestConfiguration.cs
deleted file mode 100644
index 10da0a44..00000000
--- a/Emailing/Tests/EmailTestConfiguration.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using Microsoft.Extensions.Configuration;
-
-namespace Tests.Odin.Email;
-
-public static class EmailTestConfiguration
-{
- public static string GetTestEmailAddressFromConfig(IConfiguration config)
- {
- ArgumentNullException.ThrowIfNull(config);
- return config["Email-TestToAddress"]!;
- }
-
- public static string GetTestFromNameFromConfig(IConfiguration config)
- {
- ArgumentNullException.ThrowIfNull(config);
- return config["Email-TestFromName"]!;
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/IntegrationTest.cs b/Emailing/Tests/IntegrationTest.cs
deleted file mode 100644
index ed6992ed..00000000
--- a/Emailing/Tests/IntegrationTest.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Tests.Odin.Email;
-
-///
-/// Base class for integration tests or any test needing application
-/// configuration, or access to DI services.
-///
-public abstract class IntegrationTest
-{
- protected readonly TestApplicationFactory AppFactory = new();
-}
\ No newline at end of file
diff --git a/Emailing/Tests/Mailgun/MailgunEmailSenderTestBuilder.cs b/Emailing/Tests/Mailgun/MailgunEmailSenderTestBuilder.cs
deleted file mode 100644
index 3b6a640c..00000000
--- a/Emailing/Tests/Mailgun/MailgunEmailSenderTestBuilder.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-using Microsoft.Extensions.Configuration;
-using Moq;
-using Odin.Emailing;
-using Odin.Logging;
-using Odin.System;
-
-namespace Tests.Odin.Email.Mailgun
-{
- public sealed class MailgunEmailSenderTestBuilder
- {
- public ILoggerWrapper Logger = null!;
- public Mock>? LoggerMock;
- public EmailSendingOptions EmailSendingOptions = null!;
- public Mock? EmailSendingOptionsMock;
- public MailgunOptions MailgunOptions = null!;
- public Mock? MailgunOptionsMock;
-
- public MailgunEmailSender Build()
- {
- EnsureNullDependenciesAreMocked();
- return new MailgunEmailSender(MailgunOptions, EmailSendingOptions, Logger);
- }
-
- public MailgunEmailSenderTestBuilder EnsureNullDependenciesAreMocked()
- {
- if (MailgunOptions is null)
- {
- MailgunOptionsMock = new Mock();
- MailgunOptions = MailgunOptionsMock.Object;
- }
- if (Logger is null)
- {
- LoggerMock = new Mock>();
- Logger = LoggerMock.Object;
- }
- if (EmailSendingOptions is null)
- {
- EmailSendingOptionsMock = new Mock();
- EmailSendingOptions = EmailSendingOptionsMock.Object;
- }
- return this;
- }
-
- public MailgunEmailSenderTestBuilder WithEmailSendingOptionsFromTestConfiguration(IConfiguration configuration)
- {
- ArgumentNullException.ThrowIfNull(configuration);
- string testerEmail = EmailTestConfiguration.GetTestEmailAddressFromConfig(configuration);
- string testerName = EmailTestConfiguration.GetTestFromNameFromConfig(configuration);
- EmailSendingOptions = new EmailSendingOptions()
- {
- DefaultFromAddress = testerEmail,
- DefaultFromName = testerName,
- Provider = EmailSendingProviders.Mailgun
- };
- EmailSendingOptionsMock = null;
- return this;
- }
-
- public MailgunEmailSenderTestBuilder WithMailgunOptionsFromTestConfiguration(IConfiguration configuration)
- {
- MailgunOptions options = GetMailgunOptionsFromConfig(configuration);
- MailgunOptions = options;
- MailgunOptionsMock = null;
- return this;
- }
-
- public static MailgunOptions GetMailgunOptionsFromConfig(IConfiguration config)
- {
- ArgumentNullException.ThrowIfNull(config);
- IConfigurationSection section = config.GetSection("Email-MailgunOptions");
- MailgunOptions options = new MailgunOptions();
- section.Bind(options);
- Result optionsAreValid = options.IsConfigurationValid();
- if (!optionsAreValid.IsSuccess)
- throw new Exception(
- $"Invalid Email-MailgunOptions configuration. {optionsAreValid.MessagesToString()}");
- return options;
- }
-
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/Mailgun/MailgunEmailSenderTests.cs b/Emailing/Tests/Mailgun/MailgunEmailSenderTests.cs
deleted file mode 100644
index 680dc66b..00000000
--- a/Emailing/Tests/Mailgun/MailgunEmailSenderTests.cs
+++ /dev/null
@@ -1,164 +0,0 @@
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Logging;
-using Moq;
-using Odin.Emailing;
-using Odin.System;
-using System.Text;
-
-namespace Tests.Odin.Email.Mailgun
-{
- [Trait("Category", "IntegrationTest")]
- public sealed class MailgunEmailSenderTests : IntegrationTest
- {
- private string _toTestEmail = null!;
- private string _fromTestEmail = null!;
-
- public MailgunEmailSenderTests()
- {
- IConfiguration config = AppFactory.GetConfiguration();
- _toTestEmail = config["Email-TestToAddress"] ?? throw new Exception("Email-TestToAddress required in configuration");
- _fromTestEmail = config["Email-TestFromAddress"] ?? throw new Exception("Email-TestFromAddress required in configuration");
- }
-
-
- [Theory]
- [InlineData("Subject-prefix")]
- [InlineData("Subject-postfix")]
- [InlineData("Default-from-is-used")]
- [InlineData("Default-from-and-name-are-used")]
- [InlineData("No-default-from-does-not-throw")]
- [InlineData("1-tag")]
- [InlineData("2-tags")]
- [Trait("Category", "IntegrationTest")]
- public async Task Sending_using_various_email_options(string testCase)
- {
- EmailSendingOptions emailSendingOptions = new EmailSendingOptions();
- IConfiguration config = AppFactory.GetConfiguration();
- MailgunEmailSenderTestBuilder scenario = new MailgunEmailSenderTestBuilder()
- .WithMailgunOptionsFromTestConfiguration(config);
- scenario.EmailSendingOptions = emailSendingOptions;
-
- EmailMessage email = new EmailMessage()
- {
- Subject = "MailgunSender EmailOptions test case: ",
- Body = $"{testCase}
",
- To = [new EmailAddress(_toTestEmail)],
- From = new EmailAddress(_fromTestEmail),
- IsHtml = true
- };
- email.Subject += testCase;
-
- switch (testCase)
- {
- case "Subject-prefix":
- emailSendingOptions.SubjectPrefix = "WithPrefix: ";
- break;
- case "Subject-postfix":
- emailSendingOptions.SubjectPostfix = " :WithPostfix";
- break;
- case "Default-from-is-used":
- email.From = null;
- emailSendingOptions.DefaultFromAddress = _fromTestEmail;
- break;
- case "Default-from-and-name-are-used":
- email.From = null;
- emailSendingOptions.DefaultFromAddress = _fromTestEmail;
- emailSendingOptions.DefaultFromName = "Test From Name";
- break;
- case "No-default-from-does-not-throw":
- emailSendingOptions.DefaultFromAddress = null;
- emailSendingOptions.DefaultFromName = null;
- break;
- case "1-tag":
- emailSendingOptions.DefaultTags = new List { "Dev" };
- break;
- case "2-tags":
- emailSendingOptions.DefaultTags = new List { "Dev", "QA" };
- break;
- default:
- throw new InvalidOperationException($"Case {testCase} not implemented");
- }
- MailgunEmailSender sut = scenario.Build();
-
- ResultValue result = await sut.SendEmail(email);
-
- VerifySuccessfulSendAndLogging(scenario, email, result);
- }
-
- [Fact]
- [Trait("Category", "IntegrationTest")]
- public async Task Send_with_attachment()
- {
- IConfiguration config = AppFactory.GetConfiguration();
- MailgunEmailSenderTestBuilder scenario = new MailgunEmailSenderTestBuilder()
- .WithMailgunOptionsFromTestConfiguration(config)
- .WithEmailSendingOptionsFromTestConfiguration(config);
- EmailMessage message = new EmailMessage();
- message.From = new EmailAddress(_fromTestEmail);
- message.ReplyTo = new EmailAddress(_fromTestEmail);
- message.To.Add(new EmailAddress(_toTestEmail));
- message.Subject = "MailgunEmailSenderTests.Send_email_with_attachment";
- message.IsHtml = true;
- message.Body = "Body text
";
- MemoryStream stream = new MemoryStream(Encoding.ASCII.GetBytes("Log file contents..."));
- stream.Position = 0;
- Attachment txtAttachment = new Attachment("MyFile.log", stream, "text/plain");
- message.Attachments.Add(txtAttachment);
- MailgunEmailSender mailgunSender = scenario.Build();
-
- ResultValue result = await mailgunSender.SendEmail(message);
-
- VerifySuccessfulSendAndLogging(scenario, message, result);
- }
-
- private void VerifySuccessfulSendAndLogging(MailgunEmailSenderTestBuilder scenario, EmailMessage message, ResultValue result)
- {
- // Result
- Assert.True(result.IsSuccess, result.MessagesToString());
- Assert.NotNull(result.Value);
- Assert.False(string.IsNullOrWhiteSpace(result.Value), "Message Id expected from Mailgun");
-
- // Should be no warnings, errors
- scenario.LoggerMock!.Verify(c => c.Log(It.IsNotIn(LogLevel.Information), It.IsAny(), It.IsAny()), Times.Never);
- scenario.LoggerMock!.Verify(c => c.Log(It.IsNotIn(LogLevel.Information), It.IsAny()), Times.Never);
- // Should log a message to Information
- string expectedLogMessage =
- $"SendEmail to {_toTestEmail} succeeded. Subject - '{message.Subject}'. Sent with Mailgun reference {result.Value}.";
-
- scenario.LoggerMock!.Verify(c => c.Log(LogLevel.Information, expectedLogMessage, null as Exception), Times.Once);
-
- }
-
- ///
- /// Ensure send does not succeed, and that appropriate logging is called.
- ///
- [Fact]
- [Trait("Category", "IntegrationTest")]
- public async Task Send_handles_and_logs_for_bad_Mailgun_api_key()
- {
- IConfiguration config = AppFactory.GetConfiguration();
- MailgunEmailSenderTestBuilder scenario = new MailgunEmailSenderTestBuilder()
- .WithEmailSendingOptionsFromTestConfiguration(config)
- .WithMailgunOptionsFromTestConfiguration(config);
- scenario.MailgunOptions.ApiKey = "testing_incorrect_api_key";
- MailgunEmailSender mailgunSender = scenario.Build();
- EmailMessage message = new EmailMessage();
- message.From = new EmailAddress(_fromTestEmail);
- message.To.Add(new EmailAddress(_toTestEmail));
- message.Subject = "Bad api key test";
- message.IsHtml = false;
- message.Body = "Bad api key test";
- string expectedLogMessage =
- $"SendEmail to {_toTestEmail} failed. Subject - '{message.Subject}'. Error - Failed to send email with Mailgun. Status code: 401 Unauthorized. Response content: Forbidden";
-
- ResultValue result = await mailgunSender.SendEmail(message);
-
- // 3 retries
- scenario.LoggerMock!.Verify(c => c.Log(LogLevel.Error, expectedLogMessage, It.IsAny()), Times.Exactly(4));
- Assert.False(result.IsSuccess);
- Assert.NotEmpty(result.Messages);
- Assert.Contains("401", result.Messages[0]);
- Assert.Contains("Unauthorized", result.Messages[0]);
- }
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/Office365/Office365EmailSenderTestBuilder.cs b/Emailing/Tests/Office365/Office365EmailSenderTestBuilder.cs
deleted file mode 100644
index bd6d37ca..00000000
--- a/Emailing/Tests/Office365/Office365EmailSenderTestBuilder.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using Microsoft.Extensions.Configuration;
-using Moq;
-using Odin.Emailing;
-using Odin.Logging;
-
-namespace Tests.Odin.Email.Office365
-{
- public sealed class Office365EmailSenderTestBuilder
- {
- public ILoggerWrapper Logger = null!;
- public Mock>? LoggerMock;
- public EmailSendingOptions EmailSendingOptions = null!;
- public Mock? EmailSendingOptionsMock;
- public Office365Options Office365Options = null!;
- public Mock? Office365OptionsMock;
-
- public Office365EmailSender Build()
- {
- EnsureNullDependenciesAreMocked();
- return new Office365EmailSender(Office365Options, EmailSendingOptions, Logger);
- }
-
- public Office365EmailSenderTestBuilder EnsureNullDependenciesAreMocked()
- {
- if (Office365Options == null!)
- {
- Office365OptionsMock = new Mock();
- Office365Options = Office365OptionsMock.Object;
- }
- if (Logger == null!)
- {
- LoggerMock = new Mock>();
- Logger = LoggerMock.Object;
- }
- if (EmailSendingOptions == null!)
- {
- EmailSendingOptionsMock = new Mock();
- EmailSendingOptions = EmailSendingOptionsMock.Object;
- }
- return this;
- }
-
- public Office365EmailSenderTestBuilder WithEmailSendingOptionsFromTestConfiguration(IConfiguration configuration)
- {
- ArgumentNullException.ThrowIfNull(configuration);
- string testerEmail = EmailTestConfiguration.GetTestEmailAddressFromConfig(configuration);
- string testerName = EmailTestConfiguration.GetTestFromNameFromConfig(configuration);
- EmailSendingOptions = new EmailSendingOptions()
- {
- DefaultFromAddress = testerEmail,
- DefaultFromName = testerName,
- Provider = EmailSendingProviders.Office365
- };
- EmailSendingOptionsMock = null;
- return this;
- }
-
- public Office365EmailSenderTestBuilder WithOffice365OptionsFromTestConfiguration(IConfiguration configuration)
- {
- Office365Options options = GetOffice365OptionsFromConfig(configuration);
- Office365Options = options;
- Office365OptionsMock = null;
- return this;
- }
-
- public static Office365Options GetOffice365OptionsFromConfig(IConfiguration config)
- {
- ArgumentNullException.ThrowIfNull(config);
- IConfigurationSection section = config.GetSection("Email-Office365");
- Office365Options options = new Office365Options();
- section.Bind(options);
- options.Validate();
- return options;
- }
-
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/Office365/Office365EmailSenderTests.cs b/Emailing/Tests/Office365/Office365EmailSenderTests.cs
deleted file mode 100644
index b4d7012a..00000000
--- a/Emailing/Tests/Office365/Office365EmailSenderTests.cs
+++ /dev/null
@@ -1,183 +0,0 @@
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Logging;
-using Moq;
-using Odin.Emailing;
-using Odin.System;
-
-
-namespace Tests.Odin.Email.Office365;
-
-[Trait("Category", "IntegrationTest")]
-public class Office365EmailSenderTests : IntegrationTest
-{
- private string _toTestEmail = null!;
- private string _fromTestEmail = null!;
-
- public Office365EmailSenderTests()
- {
- IConfiguration config = AppFactory.GetConfiguration();
- IConfigurationSection office365Options = config.GetRequiredSection("Email-Office365");
-
- _toTestEmail = config["Email-TestToAddress"] ?? throw new Exception("Email-TestToAddress required in configuration");
- Office365Options options = new Office365Options();
- office365Options.Bind(options);
- _fromTestEmail = options.SenderUserId!;
-
- }
-
- private EmailSendingOptions GetEmailOptionsForOffice365()
- {
- return new EmailSendingOptions()
- {
- Provider = EmailSendingProviders.Office365
- };
- }
-
- [Theory]
- [InlineData("1-Tag")]
- [InlineData("Null-Tags")]
- [InlineData("Empty-Tags")]
- [InlineData("Many-Tags")]
- [InlineData("1-Attachment")]
- [InlineData("2-Attachments")]
- [InlineData("Plain-Text-Body")]
- [Trait("Category", "IntegrationTest")]
- public async Task Send_various_emails(string testCase)
- {
- EmailMessage email = new EmailMessage()
- {
- Subject = "Office365 test case: ",
- Body = $"{testCase}
",
- To = [new EmailAddress(_toTestEmail, "Test To Name")],
- From = new EmailAddress(_fromTestEmail, "Test From Name"),
- IsHtml = true
- };
- email.Subject += testCase;
- switch (testCase)
- {
- case "1-Tag":
- email.Tags = ["Dev"];
- break;
- case "Many-Tags":
- email.Tags = ["QA", "Dev", "Outgoing Payments"];
- break;
- case "Null-Tags":
- email.Tags = null!;
- break;
- case "Empty-Tags":
- email.Tags = new List();
- break;
- case "1-Attachment":
- email.Attachments =
- [new Attachment("file.txt", new MemoryStream("This is a text file"u8.ToArray()), "text/plain")];
- break;
- case "2-Attachments":
- email.Attachments =
- [
- new Attachment("file1.txt", new MemoryStream("This is a text file 1"u8.ToArray()), "text/plain"),
- new Attachment("file2.txt", new MemoryStream("This is a text file 2"u8.ToArray()), "text/plain")
- ];
- break;
- case "Plain-Text-Body":
- email.IsHtml = false;
- email.Body = testCase;
- break;
- default:
- throw new InvalidOperationException($"Case {testCase} not implemented");
- }
- IConfiguration config = AppFactory.GetConfiguration();
-
- Office365EmailSenderTestBuilder scenario = new Office365EmailSenderTestBuilder()
- .WithOffice365OptionsFromTestConfiguration(config)
- .WithEmailSendingOptionsFromTestConfiguration(config);
- Office365EmailSender sut = scenario.Build();
-
- ResultValue result = await sut.SendEmail(email);
-
- VerifySuccessfulSendAndLogging(scenario, email, result);
- }
-
- [Theory]
- [InlineData("Subject-prefix")]
- [InlineData("Subject-postfix")]
- [InlineData("Default-from-is-used")]
- [InlineData("Default-from-and-name-are-used")]
- [InlineData("No-default-from-does-not-throw")]
- [InlineData("1-tag")]
- [InlineData("2-tags")]
- [Trait("Category", "IntegrationTest")]
- public async Task Send_correctly_implements_email_options(string testCase)
- {
- EmailSendingOptions emailSendingOptions = new EmailSendingOptions();
- IConfiguration config = AppFactory.GetConfiguration();
- Office365EmailSenderTestBuilder scenario = new Office365EmailSenderTestBuilder()
- .WithOffice365OptionsFromTestConfiguration(config);
- scenario.EmailSendingOptions = emailSendingOptions;
-
- EmailMessage email = new EmailMessage()
- {
- Subject = "Office365 EmailOptions test case: ",
- Body = $"{testCase}
",
- To = [new EmailAddress(_toTestEmail)],
- From = new EmailAddress(_fromTestEmail),
- IsHtml = true
- };
- email.Subject += testCase;
-
- switch (testCase)
- {
- case "Subject-prefix":
- emailSendingOptions.SubjectPrefix = "WithPrefix: ";
- break;
- case "Subject-postfix":
- emailSendingOptions.SubjectPostfix = " :WithPostfix";
- break;
- case "Default-from-is-used":
- email.From = null;
- emailSendingOptions.DefaultFromAddress = _fromTestEmail;
- break;
- case "Default-from-and-name-are-used":
- email.From = null;
- emailSendingOptions.DefaultFromAddress = _fromTestEmail;
- emailSendingOptions.DefaultFromName = "Test From Name";
- break;
- case "No-default-from-does-not-throw":
- emailSendingOptions.DefaultFromAddress = null;
- emailSendingOptions.DefaultFromName = null;
- break;
- case "1-tag":
- emailSendingOptions.DefaultTags = new List { "Dev" };
- break;
- case "2-tags":
- emailSendingOptions.DefaultTags = new List { "Dev", "QA" };
- break;
- default:
- throw new InvalidOperationException($"Case {testCase} not implemented");
- }
-
- Office365EmailSender sut = scenario.Build();
-
- ResultValue result = await sut.SendEmail(email);
-
- VerifySuccessfulSendAndLogging(scenario, email, result);
- }
-
- private void VerifySuccessfulSendAndLogging(Office365EmailSenderTestBuilder scenario, EmailMessage message,
- ResultValue result)
- {
- // Result
- Assert.True(result.IsSuccess, result.MessagesToString());
- Assert.NotNull(result.Value);
- Assert.False(string.IsNullOrWhiteSpace(result.Value), "Message Id expected from Office365");
-
- // Should be no warnings, errors
- scenario.LoggerMock!.Verify(
- c => c.Log(It.IsNotIn(LogLevel.Information), It.IsAny(), It.IsAny()), Times.Never);
- scenario.LoggerMock!.Verify(c => c.Log(It.IsNotIn(LogLevel.Information), It.IsAny()), Times.Never);
- // Should log a message to Information
- string expectedLogMessage =
- $"SendEmail to {_toTestEmail} succeeded. Subject - '{message.Subject}'. Sent with Office365 via user {scenario.Office365Options.SenderUserId}";
-
- scenario.LoggerMock!.Verify(c => c.Log(LogLevel.Information, expectedLogMessage), Times.Once);
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/Program.cs b/Emailing/Tests/Program.cs
deleted file mode 100644
index 950fb813..00000000
--- a/Emailing/Tests/Program.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.Configuration;
-
-namespace Tests.Odin.Email
-{
- public sealed class Program
- {
- public static WebApplication BuildApplication(string[]? args = null)
- {
- string baseDir = AppContext.BaseDirectory;
- WebApplicationOptions appOptions = new WebApplicationOptions()
- {
- Args = args ?? [],
- ContentRootPath = baseDir
- };
- WebApplicationBuilder builder = WebApplication.CreateBuilder(appOptions);
- builder.Configuration.AddJsonFile("appSettings.json", false);
- builder.Configuration.AddUserSecrets();
- return builder.Build();
- }
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/TestApplicationFactory.cs b/Emailing/Tests/TestApplicationFactory.cs
deleted file mode 100644
index ba999cc9..00000000
--- a/Emailing/Tests/TestApplicationFactory.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Tests.Odin.Email;
-
-public sealed class TestApplicationFactory : IDisposable, IAsyncDisposable
-{
- private readonly Lazy _application = new(() => Program.BuildApplication());
-
- public IServiceProvider Services => _application.Value.Services;
-
- public IConfiguration GetConfiguration()
- {
- return Services.GetRequiredService();
- }
-
- public void Dispose()
- {
- if (_application.IsValueCreated)
- {
- _application.Value.DisposeAsync().AsTask().GetAwaiter().GetResult();
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- if (_application.IsValueCreated)
- {
- await _application.Value.DisposeAsync();
- }
- }
-}
\ No newline at end of file
diff --git a/Emailing/Tests/Tests.Odin.Emailing.csproj b/Emailing/Tests/Tests.Odin.Emailing.csproj
index 72cb0796..f8848d02 100644
--- a/Emailing/Tests/Tests.Odin.Emailing.csproj
+++ b/Emailing/Tests/Tests.Odin.Emailing.csproj
@@ -1,37 +1,33 @@
-
-
- Tests.Odin
- false
- 1591;
-
+
+ Tests.Odin
+ false
+ 1591;
+
-
-
-
-
- all
- runtime; build; native; contentfiles; analyzers
-
-
-
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Always
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
diff --git a/Emailing/Tests/appSettings.json b/Emailing/Tests/appSettings.json
deleted file mode 100644
index 6990858e..00000000
--- a/Emailing/Tests/appSettings.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "Email-TestToAddress": "Set-in-user-secrets",
- "Email-TestFromAddress": "Set-in-user-secrets",
- "Email-MailgunOptions": {
- "ApiKey": "Set-in-user-secrets",
- "Domain": "Set-in-user-secrets",
- "Region": "EU"
- },
- "Email-Office365": {
- "SenderUserId": "Set-in-user-secrets",
- "MicrosoftGraphClientSecretCredentials": {
- "ClientId": "Set-in-user-secrets",
- "TenantId": "Set-in-user-secrets",
- "ClientSecret": "Set-in-user-secrets"
- }
- }
-}
diff --git a/Emailing/Tests/testSettings.json b/Emailing/Tests/testSettings.json
new file mode 100644
index 00000000..0f60b8dc
--- /dev/null
+++ b/Emailing/Tests/testSettings.json
@@ -0,0 +1,19 @@
+{
+ "Emailing": {
+ "ToAddress": "UserSecrets",
+ "FromAddress": "UserSecrets",
+ "MailgunOptions": {
+ "ApiKey": "UserSecrets",
+ "Domain": "UserSecrets",
+ "Region": "EU"
+ },
+ "Office365Options": {
+ "SenderUserId": "UserSecrets",
+ "MicrosoftGraphClientSecretCredentials": {
+ "ClientId": "UserSecrets",
+ "TenantId": "UserSecrets",
+ "ClientSecret": "UserSecrets"
+ }
+ }
+ }
+}
diff --git a/Odin.sln b/Odin.sln
index bebb8de1..d2da2b0e 100644
--- a/Odin.sln
+++ b/Odin.sln
@@ -133,6 +133,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{4F52FD
TestsOnly.sln = TestsOnly.sln
Directory.Packages.props = Directory.Packages.props
.editorconfig = .editorconfig
+ AGENTS.md = AGENTS.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Testing.XUnitV2Utility", "Testing\XUnitV2Utility\Odin.Testing.XUnitV2Utility.csproj", "{A1A296A9-4229-4603-9D7E-AC004A59C28C}"
@@ -147,6 +148,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Emailing.Mailgun", "Em
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Emailing.Office365", "Emailing\Office365\Odin.Emailing.Office365.csproj", "{76F7D843-D359-40F7-9355-854DA929B4AD}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Odin.Emailing", "Emailing\Tests\Tests.Odin.Emailing.csproj", "{CA7BA620-74F0-4843-B10C-0B7CABE342BC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -745,6 +748,18 @@ Global
{76F7D843-D359-40F7-9355-854DA929B4AD}.Release|x64.Build.0 = Release|Any CPU
{76F7D843-D359-40F7-9355-854DA929B4AD}.Release|x86.ActiveCfg = Release|Any CPU
{76F7D843-D359-40F7-9355-854DA929B4AD}.Release|x86.Build.0 = Release|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Debug|x64.Build.0 = Debug|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Debug|x86.Build.0 = Debug|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Release|x64.ActiveCfg = Release|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Release|x64.Build.0 = Release|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Release|x86.ActiveCfg = Release|Any CPU
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -799,5 +814,6 @@ Global
{BE379012-8593-474D-9418-F565AE1B7172} = {BC84F29A-777F-49ED-A17F-004D0F016FD4}
{EDB3BBF1-FB3B-47F3-9A24-B5CCF5091D9D} = {BC84F29A-777F-49ED-A17F-004D0F016FD4}
{76F7D843-D359-40F7-9355-854DA929B4AD} = {BC84F29A-777F-49ED-A17F-004D0F016FD4}
+ {CA7BA620-74F0-4843-B10C-0B7CABE342BC} = {BC84F29A-777F-49ED-A17F-004D0F016FD4}
EndGlobalSection
EndGlobal
diff --git a/TestsOnly.sln b/TestsOnly.sln
index 4c3c95ca..f1a0e31d 100644
--- a/TestsOnly.sln
+++ b/TestsOnly.sln
@@ -22,7 +22,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Odin.Logging", "Loggi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Odin.DDD", "DomainDrivenDesign\Tests\Tests.Odin.DDD.csproj", "{1365B0DE-C19E-4604-86D9-E9BBDC606DC0}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Odin.Commands", "Commands\Tests\Tests.Odin.Commands.csproj", "{56673414-CC5F-4F03-9DEE-2D7F75A14047}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Odin.CQRS", "CQRS\Tests\Tests.Odin.CQRS.csproj", "{56673414-CC5F-4F03-9DEE-2D7F75A14047}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution