From dc66f6a759c8cee74f86a4a71c919fcbcbc92295 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Tue, 10 Nov 2026 09:59:12 +0200 Subject: [PATCH 1/4] Emails: plain-text alternative and current year in the footer - Every email template has a .txt version, filled with the same values and sent beside the HTML (multipart/alternative over SMTP, Body.Text over SES), so clients that show no HTML (Mail.ru) read it. - The footer's year is ~$year$~, filled with the current year (was a fixed 2024). - EnqueueSendEmail carries the text body; ISendEmailJob keeps its 3-argument SendAsync for jobs already queued. --- .../Feature.Auth/ForgotPassword/Endpoint.cs | 2 +- .../SendCopy/Endpoint.cs | 2 +- .../Assign/Endpoint.cs | 2 +- .../ResendInvites/Endpoint.cs | 2 +- .../Services/ObserverImportService.cs | 6 +- api/src/Job.Contracts/IJobService.cs | 2 +- api/src/Job.Contracts/Jobs/ISendEmailJob.cs | 4 + .../Hangfire/HangfireJobService.cs | 4 +- .../Hangfire/NoopJobService.cs | 2 +- .../Fragments/answer-attachments-fragment.txt | 3 + .../Fragments/answer-notes-fragment.txt | 3 + .../Fragments/attachment-fragment.txt | 1 + .../Fragments/input-answer-fragment.txt | 3 + .../Fragments/note-fragment.txt | 1 + .../Fragments/rating-answer-fragment.txt | 3 + .../rating-answer-option-fragment.txt | 1 + ...rating-answer-option-selected-fragment.txt | 1 + .../Fragments/select-answer-fragment.txt | 2 + .../select-answer-option-fragment.txt | 1 + ...select-answer-option-selected-fragment.txt | 1 + .../EmailTemplates/citizen-report.html | 2 +- .../EmailTemplates/citizen-report.txt | 3 + .../EmailTemplates/confirm-email.html | 2 +- .../EmailTemplates/confirm-email.txt | 10 +++ .../invitation-existing-user.html | Bin 5315 -> 5303 bytes .../invitation-existing-user.txt | 17 ++++ .../EmailTemplates/invitation-new-user.html | Bin 5382 -> 5370 bytes .../EmailTemplates/invitation-new-user.txt | 18 ++++ .../EmailTemplates/reset-password.html | 2 +- .../EmailTemplates/reset-password.txt | 10 +++ .../Services/EmailTemplating/EmailModel.cs | 3 +- .../Generators/CitizenReportEmailGenerator.cs | 55 ++++++------ .../Generators/ConfirmEmailGenerator.cs | 8 +- .../InvitationExistingUserEmailGenerator.cs | 9 +- .../InvitationNewUserEmailGenerator.cs | 9 +- .../Generators/ResetPasswordEmailGenerator.cs | 8 +- .../Helpers/EmailTemplateLoader.cs | 14 ++- .../Services/Mailing/Contracts/MailRequest.cs | 5 +- .../Services/Mailing/Ses/SesMailService.cs | 2 + .../Services/Mailing/Smtp/SmtpMailService.cs | 2 + .../Vote.Monitor.Core.csproj | 4 + .../Jobs/SendEmailJob.cs | 9 +- .../RecurringJobs/PendingInviteResendJob.cs | 2 +- .../ApiTesting.cs | 10 +-- .../ImportObserversTests.cs | 22 ++--- .../Vote.Monitor.Core.UnitTests/EmailTests.cs | 81 ++++++++++++++++++ 46 files changed, 274 insertions(+), 79 deletions(-) create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/answer-attachments-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/answer-notes-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/attachment-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/input-answer-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/note-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-option-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-option-selected-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-option-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-option-selected-fragment.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/citizen-report.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/confirm-email.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/invitation-existing-user.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/invitation-new-user.txt create mode 100644 api/src/Vote.Monitor.Core/EmailTemplates/reset-password.txt create mode 100644 api/tests/Vote.Monitor.Core.UnitTests/EmailTests.cs diff --git a/api/src/Feature.Auth/ForgotPassword/Endpoint.cs b/api/src/Feature.Auth/ForgotPassword/Endpoint.cs index b4310642f..05af19aac 100644 --- a/api/src/Feature.Auth/ForgotPassword/Endpoint.cs +++ b/api/src/Feature.Auth/ForgotPassword/Endpoint.cs @@ -38,7 +38,7 @@ public override async Task> ExecuteAsync(Request ? await GetResetPasswordEmail(user) : GetAcceptInviteEmail(user); - jobService.EnqueueSendEmail(request.Email, mail.Subject, mail.Body); + jobService.EnqueueSendEmail(request.Email, mail.Subject, mail.Body, mail.TextBody); return TypedResults.Ok(); } diff --git a/api/src/Feature.CitizenReports/SendCopy/Endpoint.cs b/api/src/Feature.CitizenReports/SendCopy/Endpoint.cs index dec7b1aca..08610302f 100644 --- a/api/src/Feature.CitizenReports/SendCopy/Endpoint.cs +++ b/api/src/Feature.CitizenReports/SendCopy/Endpoint.cs @@ -62,7 +62,7 @@ public override async Task ExecuteAsync(Request req, $"Citizen report #{req.CitizenReportId}", MapAnswersToEmailFragmentProps(form, citizenReport), _apiConfiguration.WebAppUrl)); - jobService.EnqueueSendEmail(req.Email, email.Subject, email.Body); + jobService.EnqueueSendEmail(req.Email, email.Subject, email.Body, email.TextBody); return TypedResults.NoContent(); diff --git a/api/src/Feature.MonitoringObservers/Assign/Endpoint.cs b/api/src/Feature.MonitoringObservers/Assign/Endpoint.cs index 79b592bd9..fb5ecbdcc 100644 --- a/api/src/Feature.MonitoringObservers/Assign/Endpoint.cs +++ b/api/src/Feature.MonitoringObservers/Assign/Endpoint.cs @@ -95,7 +95,7 @@ public override async Task, NotFound, Conflict> ExecuteAsync(Request re ElectionRoundDetails: electionRoundName); var email = emailFactory.GenerateNewUserInvitationEmail(invitationNewUserEmailProps); - jobService.EnqueueSendEmail(monitoringObserver.Email!, email.Subject, email.Body); + jobService.EnqueueSendEmail(monitoringObserver.Email!, email.Subject, email.Body, email.TextBody); } return TypedResults.NoContent(); diff --git a/api/src/Feature.MonitoringObservers/Services/ObserverImportService.cs b/api/src/Feature.MonitoringObservers/Services/ObserverImportService.cs index 293790d58..beb486441 100644 --- a/api/src/Feature.MonitoringObservers/Services/ObserverImportService.cs +++ b/api/src/Feature.MonitoringObservers/Services/ObserverImportService.cs @@ -107,7 +107,7 @@ public async Task ImportAsync(Guid electionRoundId, Guid ngoId, var email = GenerateCreateAccountEmail(existingAccount.InvitationToken!, fullName, ngoName, electionRoundName); - jobService.EnqueueSendEmail(observer.Email, email.Subject, email.Body); + jobService.EnqueueSendEmail(observer.Email, email.Subject, email.Body, email.TextBody); } else { @@ -124,7 +124,7 @@ public async Task ImportAsync(Guid electionRoundId, Guid ngoId, existingObserver.ApplicationUser.DisplayName, ngoName, electionRoundName) : GenerateNotificationEmail(fullName, ngoName, electionRoundName); - jobService.EnqueueSendEmail(observer.Email, email.Subject, email.Body); + jobService.EnqueueSendEmail(observer.Email, email.Subject, email.Body, email.TextBody); } } else @@ -147,7 +147,7 @@ public async Task ImportAsync(Guid electionRoundId, Guid ngoId, await context.MonitoringObservers.AddAsync(newMonitoringObserver, ct); var email = GenerateCreateAccountEmail(user.InvitationToken!, fullName, ngoName, electionRoundName); - jobService.EnqueueSendEmail(observer.Email, email.Subject, email.Body); + jobService.EnqueueSendEmail(observer.Email, email.Subject, email.Body, email.TextBody); } } diff --git a/api/src/Job.Contracts/IJobService.cs b/api/src/Job.Contracts/IJobService.cs index 83c7984dd..8cb81c974 100644 --- a/api/src/Job.Contracts/IJobService.cs +++ b/api/src/Job.Contracts/IJobService.cs @@ -2,7 +2,7 @@ public interface IJobService { - void EnqueueSendEmail(string to, string subject, string body); + void EnqueueSendEmail(string to, string subject, string body, string textBody); void EnqueueExportFormSubmissions(Guid electionRoundId, Guid ngoId, Guid exportedDataId); void EnqueueExportQuickReports(Guid electionRoundId, Guid ngoId, Guid exportedDataId); void EnqueueExportPollingStations(Guid electionRoundId, Guid exportedDataId); diff --git a/api/src/Job.Contracts/Jobs/ISendEmailJob.cs b/api/src/Job.Contracts/Jobs/ISendEmailJob.cs index f9b19070e..b83287e33 100644 --- a/api/src/Job.Contracts/Jobs/ISendEmailJob.cs +++ b/api/src/Job.Contracts/Jobs/ISendEmailJob.cs @@ -2,5 +2,9 @@ public interface ISendEmailJob { + /// HTML only. Kept for jobs enqueued before the plain-text alternative. Task SendAsync(string to, string subject, string body); + + /// HTML with its plain-text alternative. + Task SendAsync(string to, string subject, string body, string textBody); } diff --git a/api/src/Vote.Monitor.Api/Hangfire/HangfireJobService.cs b/api/src/Vote.Monitor.Api/Hangfire/HangfireJobService.cs index 2bd55c819..92cb723d9 100644 --- a/api/src/Vote.Monitor.Api/Hangfire/HangfireJobService.cs +++ b/api/src/Vote.Monitor.Api/Hangfire/HangfireJobService.cs @@ -6,9 +6,9 @@ namespace Vote.Monitor.Api.Hangfire; public class HangfireJobService(IBackgroundJobClient backgroundJobClient) : IJobService { - public void EnqueueSendEmail(string to, string subject, string body) + public void EnqueueSendEmail(string to, string subject, string body, string textBody) { - backgroundJobClient.Enqueue(job => job.SendAsync(to, subject, body)); + backgroundJobClient.Enqueue(job => job.SendAsync(to, subject, body, textBody)); } public void EnqueueExportFormSubmissions(Guid electionRoundId, Guid ngoId, Guid exportedDataId) diff --git a/api/src/Vote.Monitor.Api/Hangfire/NoopJobService.cs b/api/src/Vote.Monitor.Api/Hangfire/NoopJobService.cs index 73c68f57c..f9458d0ea 100644 --- a/api/src/Vote.Monitor.Api/Hangfire/NoopJobService.cs +++ b/api/src/Vote.Monitor.Api/Hangfire/NoopJobService.cs @@ -4,7 +4,7 @@ namespace Vote.Monitor.Api.Hangfire; internal class NoopJobService : IJobService { - public void EnqueueSendEmail(string to, string subject, string body) + public void EnqueueSendEmail(string to, string subject, string body, string textBody) { } diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/answer-attachments-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/answer-attachments-fragment.txt new file mode 100644 index 000000000..f18f7f18f --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/answer-attachments-fragment.txt @@ -0,0 +1,3 @@ + +~$listTitle$~ +~$attachments$~ \ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/answer-notes-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/answer-notes-fragment.txt new file mode 100644 index 000000000..60be80bac --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/answer-notes-fragment.txt @@ -0,0 +1,3 @@ + +~$listTitle$~ +~$notes$~ \ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/attachment-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/attachment-fragment.txt new file mode 100644 index 000000000..c89534281 --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/attachment-fragment.txt @@ -0,0 +1 @@ +~$linkText$~: ~$link$~ diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/input-answer-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/input-answer-fragment.txt new file mode 100644 index 000000000..3acbb6bbc --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/input-answer-fragment.txt @@ -0,0 +1,3 @@ +~$text$~ +~$answer$~ +~$notes$~~$attachments$~ diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/note-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/note-fragment.txt new file mode 100644 index 000000000..6ec45c5e3 --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/note-fragment.txt @@ -0,0 +1 @@ +~$number$~ ~$text$~ diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-fragment.txt new file mode 100644 index 000000000..1872b7a5f --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-fragment.txt @@ -0,0 +1,3 @@ +~$text$~ +~$options$~ +~$notes$~~$attachments$~ diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-option-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-option-fragment.txt new file mode 100644 index 000000000..2f4b6d100 --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-option-fragment.txt @@ -0,0 +1 @@ +( ~$value$~ ) \ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-option-selected-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-option-selected-fragment.txt new file mode 100644 index 000000000..faf922dcd --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/rating-answer-option-selected-fragment.txt @@ -0,0 +1 @@ +[ ~$value$~ ] \ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-fragment.txt new file mode 100644 index 000000000..f22c49e38 --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-fragment.txt @@ -0,0 +1,2 @@ +~$text$~ +~$options$~~$notes$~~$attachments$~ diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-option-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-option-fragment.txt new file mode 100644 index 000000000..d2442284d --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-option-fragment.txt @@ -0,0 +1 @@ +[ ] ~$text$~ diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-option-selected-fragment.txt b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-option-selected-fragment.txt new file mode 100644 index 000000000..bf4cf0a28 --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/Fragments/select-answer-option-selected-fragment.txt @@ -0,0 +1 @@ +[x] ~$text$~ diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/citizen-report.html b/api/src/Vote.Monitor.Core/EmailTemplates/citizen-report.html index 5f7a83c9c..9c3754349 100644 --- a/api/src/Vote.Monitor.Core/EmailTemplates/citizen-report.html +++ b/api/src/Vote.Monitor.Core/EmailTemplates/citizen-report.html @@ -1 +1 @@ -
~$preview$~
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
\ No newline at end of file +
~$preview$~
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
\ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/citizen-report.txt b/api/src/Vote.Monitor.Core/EmailTemplates/citizen-report.txt new file mode 100644 index 000000000..49f627eed --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/citizen-report.txt @@ -0,0 +1,3 @@ +~$heading$~ + +~$answers$~© ~$year$~ Commit Global. All rights reserved. diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/confirm-email.html b/api/src/Vote.Monitor.Core/EmailTemplates/confirm-email.html index 449efc088..0decca515 100644 --- a/api/src/Vote.Monitor.Core/EmailTemplates/confirm-email.html +++ b/api/src/Vote.Monitor.Core/EmailTemplates/confirm-email.html @@ -1 +1 @@ -
Confirm your email on VoteMonitor platform
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
\ No newline at end of file +
Confirm your email on VoteMonitor platform
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
\ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/confirm-email.txt b/api/src/Vote.Monitor.Core/EmailTemplates/confirm-email.txt new file mode 100644 index 000000000..26bfadbe8 --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/confirm-email.txt @@ -0,0 +1,10 @@ +Hello, ~$name$~ + +This is an automatic email that was sent to confirm your account. Please follow this link to complete your registration and to set your password in order to use the application: +~$confirmUrl$~ + +Thank you. + +If you have any questions, reach out to us at info@commitglobal.org. + +© ~$year$~ Commit Global. All rights reserved. diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/invitation-existing-user.html b/api/src/Vote.Monitor.Core/EmailTemplates/invitation-existing-user.html index 11a5ed5b3b8cc31ae353c488d566c573840f2908..fa5cdabebcb1de1c5cac5a639f82871b10e97484 100644 GIT binary patch delta 20 bcmX@Cxm|NZya-2~N@Z$dkxJd
Reset your password on VoteMonitor Platform.
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
\ No newline at end of file +
Reset your password on VoteMonitor Platform.
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
\ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/EmailTemplates/reset-password.txt b/api/src/Vote.Monitor.Core/EmailTemplates/reset-password.txt new file mode 100644 index 000000000..d8391d325 --- /dev/null +++ b/api/src/Vote.Monitor.Core/EmailTemplates/reset-password.txt @@ -0,0 +1,10 @@ +Hello, ~$name$~ + +You are receiving this email because you requested help with your account credentials. In order to reset your password please follow this link and the instructions provided in the application: +~$resetPasswordUrl$~ + +Thank you. + +If you have any questions, reach out to us at info@commitglobal.org. + +© ~$year$~ Commit Global. All rights reserved. diff --git a/api/src/Vote.Monitor.Core/Services/EmailTemplating/EmailModel.cs b/api/src/Vote.Monitor.Core/Services/EmailTemplating/EmailModel.cs index 5227308c6..ab4613b42 100644 --- a/api/src/Vote.Monitor.Core/Services/EmailTemplating/EmailModel.cs +++ b/api/src/Vote.Monitor.Core/Services/EmailTemplating/EmailModel.cs @@ -1,3 +1,4 @@ namespace Vote.Monitor.Core.Services.EmailTemplating; -public record EmailModel(string Subject, string Body); +/// The plain-text alternative of , sent beside it. +public record EmailModel(string Subject, string Body, string TextBody); diff --git a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/CitizenReportEmailGenerator.cs b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/CitizenReportEmailGenerator.cs index b09b95e44..75bc2a308 100644 --- a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/CitizenReportEmailGenerator.cs +++ b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/CitizenReportEmailGenerator.cs @@ -6,20 +6,19 @@ namespace Vote.Monitor.Core.Services.EmailTemplating.Generators; internal static class CitizenReportEmailGenerator { - private static readonly string Template = EmailTemplateLoader.GetTemplate(EmailTemplateType.CitizenReport); + public static EmailModel Generate(CitizenReportEmailProps props) => + new(props.Title, Build(props, text: false), Build(props, text: true)); - public static EmailModel Generate(CitizenReportEmailProps props) - { - var body = Template + /// The email in HTML, or () its plain-text version from the .txt templates. + private static string Build(CitizenReportEmailProps props, bool text) => + EmailTemplateLoader.Get(EmailTemplateType.CitizenReport, text) .Replace("~$heading$~", props.Heading) .Replace("~$preview$~", props.Preview) .Replace("~$cdnUrl$~", props.CdnUrl) - .Replace("~$answers$~", BuildAnswersFragment(props.Answers)); - - return new EmailModel(props.Title, body); - } + .Replace("~$year$~", DateTime.UtcNow.Year.ToString()) + .Replace("~$answers$~", BuildAnswersFragment(props.Answers, text)); - private static string BuildAnswersFragment(IEnumerable answers) + private static string BuildAnswersFragment(IEnumerable answers, bool text) { var userAnswers = answers as BaseAnswerFragmentProps[] ?? answers.ToArray(); @@ -28,14 +27,14 @@ private static string BuildAnswersFragment(IEnumerable var result = new StringBuilder(); foreach (var answer in userAnswers) { - var notesFragment = BuildNotesFragment(answer.NotesListTitle, answer.Notes); - var attachmentsFragment = BuildAttachmentsFragment(answer.AttachmentsListTitle, answer.Attachments); + var notesFragment = BuildNotesFragment(answer.NotesListTitle, answer.Notes, text); + var attachmentsFragment = BuildAttachmentsFragment(answer.AttachmentsListTitle, answer.Attachments, text); switch (answer) { case InputAnswerFragmentProps inputAnswer: result.Append(EmailTemplateLoader - .GetTemplate(EmailTemplateType.InputAnswerFragment) + .Get(EmailTemplateType.InputAnswerFragment, text) .Replace("~$text$~", inputAnswer.Text) .Replace("~$answer$~", inputAnswer.Answer) .Replace("~$notes$~", notesFragment) @@ -44,18 +43,18 @@ private static string BuildAnswersFragment(IEnumerable case RatingAnswerFragmentProps ratingAnswer: result.Append(EmailTemplateLoader - .GetTemplate(EmailTemplateType.RatingAnswerFragment) + .Get(EmailTemplateType.RatingAnswerFragment, text) .Replace("~$text$~", ratingAnswer.Text) - .Replace("~$options$~", BuildRatingOptionsFragment(ratingAnswer.Scale, ratingAnswer.Value)) + .Replace("~$options$~", BuildRatingOptionsFragment(ratingAnswer.Scale, ratingAnswer.Value, text).TrimEnd()) .Replace("~$notes$~", notesFragment) .Replace("~$attachments$~", attachmentsFragment)); break; case SelectAnswerFragmentProps selectAnswer: result.Append(EmailTemplateLoader - .GetTemplate(EmailTemplateType.SelectAnswerFragment) + .Get(EmailTemplateType.SelectAnswerFragment, text) .Replace("~$text$~", selectAnswer.Text) - .Replace("~$options$~", BuildSelectOptionsFragment(selectAnswer.Options)) + .Replace("~$options$~", BuildSelectOptionsFragment(selectAnswer.Options, text)) .Replace("~$notes$~", notesFragment) .Replace("~$attachments$~", attachmentsFragment)); break; @@ -66,7 +65,7 @@ private static string BuildAnswersFragment(IEnumerable } private static string? BuildAttachmentsFragment(string attachmentsListTitle, - IEnumerable attachments) + IEnumerable attachments, bool text) { var attachmentsArray = attachments as AttachmentFragmentProps[] ?? attachments.ToArray(); if (!attachmentsArray.Any()) @@ -75,7 +74,7 @@ private static string BuildAnswersFragment(IEnumerable } var attachmentsFragments = new StringBuilder(); - var noteFragment = EmailTemplateLoader.GetTemplate(EmailTemplateType.AttachmentFragment); + var noteFragment = EmailTemplateLoader.Get(EmailTemplateType.AttachmentFragment, text); foreach (var note in attachmentsArray) { attachmentsFragments.Append(noteFragment @@ -83,12 +82,12 @@ private static string BuildAnswersFragment(IEnumerable .Replace("~$linkText$~", note.Title)); } - return EmailTemplateLoader.GetTemplate(EmailTemplateType.AnswerAttachmentsFragment) + return EmailTemplateLoader.Get(EmailTemplateType.AnswerAttachmentsFragment, text) .Replace("~$listTitle$~", attachmentsListTitle) .Replace("~$attachments$~", attachmentsFragments.ToString()); } - private static string? BuildNotesFragment(string notesListTitle, IEnumerable notes) + private static string? BuildNotesFragment(string notesListTitle, IEnumerable notes, bool text) { var notesArray = notes as NoteFragmentProps[] ?? notes.ToArray(); if (!notesArray.Any()) @@ -97,7 +96,7 @@ private static string BuildAnswersFragment(IEnumerable } var notesFragments = new StringBuilder(); - var noteFragment = EmailTemplateLoader.GetTemplate(EmailTemplateType.NoteFragment); + var noteFragment = EmailTemplateLoader.Get(EmailTemplateType.NoteFragment, text); foreach (var note in notesArray) { notesFragments.Append(noteFragment @@ -105,20 +104,20 @@ private static string BuildAnswersFragment(IEnumerable .Replace("~$text$~", note.Text)); } - return EmailTemplateLoader.GetTemplate(EmailTemplateType.AnswerNotesFragment) + return EmailTemplateLoader.Get(EmailTemplateType.AnswerNotesFragment, text) .Replace("~$listTitle$~", notesListTitle) .Replace("~$notes$~", notesFragments.ToString()); } - private static string BuildSelectOptionsFragment(IEnumerable options) + private static string BuildSelectOptionsFragment(IEnumerable options, bool text) { var result = new StringBuilder(); foreach (var option in options) { - var optionTemplate = EmailTemplateLoader.GetTemplate(option.IsSelected + var optionTemplate = EmailTemplateLoader.Get(option.IsSelected ? EmailTemplateType.SelectAnswerCheckedOptionFragment - : EmailTemplateType.SelectAnswerOptionFragment); + : EmailTemplateType.SelectAnswerOptionFragment, text); result.Append(optionTemplate.Replace("~$text$~", option.Text)); } @@ -126,15 +125,15 @@ private static string BuildSelectOptionsFragment(IEnumerable template .Replace("~$cdnUrl$~", props.CdnUrl) .Replace("~$name$~", props.FullName) - .Replace("~$confirmUrl$~", props.ConfirmUrl); + .Replace("~$confirmUrl$~", props.ConfirmUrl) + .Replace("~$year$~", DateTime.UtcNow.Year.ToString()); - return new EmailModel("Confirm your email on VoteMonitor platform", body); + return new EmailModel("Confirm your email on VoteMonitor platform", Fill(Template), Fill(TextTemplate)); } } \ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/InvitationExistingUserEmailGenerator.cs b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/InvitationExistingUserEmailGenerator.cs index d0d8cec66..32d07b32e 100644 --- a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/InvitationExistingUserEmailGenerator.cs +++ b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/InvitationExistingUserEmailGenerator.cs @@ -6,16 +6,17 @@ namespace Vote.Monitor.Core.Services.EmailTemplating.Generators; internal class InvitationExistingUserEmailGenerator { private static readonly string Template = EmailTemplateLoader.GetTemplate(EmailTemplateType.InvitationExistingUser); + private static readonly string TextTemplate = EmailTemplateLoader.GetTextTemplate(EmailTemplateType.InvitationExistingUser); public static EmailModel Generate(InvitationExistingUserEmailProps props) { - var body = Template + string Fill(string template) => template .Replace("~$name$~", props.FullName) .Replace("~$cdnUrl$~", props.CdnUrl) .Replace("~$ngoName$~", props.NgoName) - .Replace("~$electionRoundDetails$~", props.ElectionRoundDetails); + .Replace("~$electionRoundDetails$~", props.ElectionRoundDetails) + .Replace("~$year$~", DateTime.UtcNow.Year.ToString()); - return new EmailModel($"{props.NgoName} has invited you to be an observer for {props.ElectionRoundDetails}.", - body); + return new EmailModel($"{props.NgoName} has invited you to be an observer for {props.ElectionRoundDetails}.", Fill(Template), Fill(TextTemplate)); } } \ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/InvitationNewUserEmailGenerator.cs b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/InvitationNewUserEmailGenerator.cs index a379cd89b..ed0081093 100644 --- a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/InvitationNewUserEmailGenerator.cs +++ b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/InvitationNewUserEmailGenerator.cs @@ -6,17 +6,18 @@ namespace Vote.Monitor.Core.Services.EmailTemplating.Generators; internal class InvitationNewUserEmailGenerator { private static readonly string Template = EmailTemplateLoader.GetTemplate(EmailTemplateType.NewUserInvitation); + private static readonly string TextTemplate = EmailTemplateLoader.GetTextTemplate(EmailTemplateType.NewUserInvitation); public static EmailModel Generate(InvitationNewUserEmailProps props) { - var body = Template + string Fill(string template) => template .Replace("~$name$~", props.FullName) .Replace("~$confirmUrl$~", props.AcceptUrl) .Replace("~$cdnUrl$~", props.CdnUrl) .Replace("~$ngoName$~", props.NgoName) - .Replace("~$electionRoundDetails$~", props.ElectionRoundDetails); + .Replace("~$electionRoundDetails$~", props.ElectionRoundDetails) + .Replace("~$year$~", DateTime.UtcNow.Year.ToString()); - return new EmailModel($"{props.NgoName} has invited you to be an observer for {props.ElectionRoundDetails}.", - body); + return new EmailModel($"{props.NgoName} has invited you to be an observer for {props.ElectionRoundDetails}.", Fill(Template), Fill(TextTemplate)); } } \ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/ResetPasswordEmailGenerator.cs b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/ResetPasswordEmailGenerator.cs index c18d526d0..1b5e4ee63 100644 --- a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/ResetPasswordEmailGenerator.cs +++ b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Generators/ResetPasswordEmailGenerator.cs @@ -6,14 +6,16 @@ namespace Vote.Monitor.Core.Services.EmailTemplating.Generators; internal class ResetPasswordEmailGenerator { private static readonly string Template = EmailTemplateLoader.GetTemplate(EmailTemplateType.ResetPassword); + private static readonly string TextTemplate = EmailTemplateLoader.GetTextTemplate(EmailTemplateType.ResetPassword); public static EmailModel Generate(ResetPasswordEmailProps props) { - var body = Template + string Fill(string template) => template .Replace("~$name$~", props.FullName) .Replace("~$resetPasswordUrl$~", props.ResetPasswordUrl) - .Replace("~$cdnUrl$~", props.CdnUrl); + .Replace("~$cdnUrl$~", props.CdnUrl) + .Replace("~$year$~", DateTime.UtcNow.Year.ToString()); - return new EmailModel("Reset your password on VoteMonitor Platform", body); + return new EmailModel("Reset your password on VoteMonitor Platform", Fill(Template), Fill(TextTemplate)); } } \ No newline at end of file diff --git a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Helpers/EmailTemplateLoader.cs b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Helpers/EmailTemplateLoader.cs index 31a508af9..49b48fb8b 100644 --- a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Helpers/EmailTemplateLoader.cs +++ b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Helpers/EmailTemplateLoader.cs @@ -30,11 +30,21 @@ internal static class EmailTemplateLoader { EmailTemplateType.NoteFragment, "Fragments/note-fragment.html" } }; - public static string GetTemplate(EmailTemplateType templateType) + public static string GetTemplate(EmailTemplateType templateType) => Read(_templateMap[templateType]); + + /// The template's plain-text version: the same name with .txt. + public static string GetTextTemplate(EmailTemplateType templateType) => + Read(Path.ChangeExtension(_templateMap[templateType], ".txt")); + + /// The HTML or the plain-text template. + public static string Get(EmailTemplateType templateType, bool text) => + text ? GetTextTemplate(templateType) : GetTemplate(templateType); + + private static string Read(string relativePath) { var baseDirectory = AppDomain.CurrentDomain.BaseDirectory; var tmplFolder = Path.Combine(baseDirectory, "EmailTemplates"); - var filePath = Path.Combine(tmplFolder, _templateMap[templateType]); + var filePath = Path.Combine(tmplFolder, relativePath); using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var sr = new StreamReader(fs, Encoding.Default); diff --git a/api/src/Vote.Monitor.Core/Services/Mailing/Contracts/MailRequest.cs b/api/src/Vote.Monitor.Core/Services/Mailing/Contracts/MailRequest.cs index 9d7e35a72..f06f547ac 100644 --- a/api/src/Vote.Monitor.Core/Services/Mailing/Contracts/MailRequest.cs +++ b/api/src/Vote.Monitor.Core/Services/Mailing/Contracts/MailRequest.cs @@ -1,10 +1,13 @@ namespace Vote.Monitor.Core.Services.Mailing.Contracts; -public class MailRequest(string to, string subject, string body) +/// The plain-text alternative of the HTML ; null sends HTML only. +public class MailRequest(string to, string subject, string body, string? textBody = null) { public string To { get; } = to; public string Subject { get; } = subject; public string Body { get; } = body; + + public string? TextBody { get; } = textBody; } diff --git a/api/src/Vote.Monitor.Core/Services/Mailing/Ses/SesMailService.cs b/api/src/Vote.Monitor.Core/Services/Mailing/Ses/SesMailService.cs index f28543f38..6b38e3e50 100644 --- a/api/src/Vote.Monitor.Core/Services/Mailing/Ses/SesMailService.cs +++ b/api/src/Vote.Monitor.Core/Services/Mailing/Ses/SesMailService.cs @@ -21,6 +21,8 @@ public async Task SendAsync(MailRequest mailRequest, CancellationToken cancellat { Html = new Content(mailRequest.Body) }; + // With a plain-text alternative too: some clients (Mail.ru) show no HTML. + if (!string.IsNullOrEmpty(mailRequest.TextBody)) mailBody.Text = new Content(mailRequest.TextBody); var message = new Message(new Content(mailRequest.Subject), mailBody); var destination = new Destination([mailRequest.To]); var request = new SendEmailRequest(_options.SenderEmail, destination, message); diff --git a/api/src/Vote.Monitor.Core/Services/Mailing/Smtp/SmtpMailService.cs b/api/src/Vote.Monitor.Core/Services/Mailing/Smtp/SmtpMailService.cs index 50f62f3b4..b81f32c2d 100644 --- a/api/src/Vote.Monitor.Core/Services/Mailing/Smtp/SmtpMailService.cs +++ b/api/src/Vote.Monitor.Core/Services/Mailing/Smtp/SmtpMailService.cs @@ -28,6 +28,8 @@ public async Task SendAsync(MailRequest request, CancellationToken cancellationT email.Sender = new MailboxAddress(_options.SenderName, _options.SenderEmail); email.Subject = request.Subject; builder.HtmlBody = request.Body; + // With a plain-text alternative too (multipart/alternative): some clients (Mail.ru) show no HTML. + if (!string.IsNullOrEmpty(request.TextBody)) builder.TextBody = request.TextBody; email.Body = builder.ToMessageBody(); diff --git a/api/src/Vote.Monitor.Core/Vote.Monitor.Core.csproj b/api/src/Vote.Monitor.Core/Vote.Monitor.Core.csproj index afa29810c..f7567479f 100644 --- a/api/src/Vote.Monitor.Core/Vote.Monitor.Core.csproj +++ b/api/src/Vote.Monitor.Core/Vote.Monitor.Core.csproj @@ -44,6 +44,10 @@ + + + Always + Always diff --git a/api/src/Vote.Monitor.Hangfire/Jobs/SendEmailJob.cs b/api/src/Vote.Monitor.Hangfire/Jobs/SendEmailJob.cs index 00db7ed01..a9e619b2a 100644 --- a/api/src/Vote.Monitor.Hangfire/Jobs/SendEmailJob.cs +++ b/api/src/Vote.Monitor.Hangfire/Jobs/SendEmailJob.cs @@ -5,11 +5,16 @@ namespace Vote.Monitor.Hangfire.Jobs; public class SendEmailJob(IMailService mailService, ILogger logger) : ISendEmailJob { - public async Task SendAsync(string to, string subject, string body) + public Task SendAsync(string to, string subject, string body) => Send(new MailRequest(to, subject, body)); + + public Task SendAsync(string to, string subject, string body, string textBody) => + Send(new MailRequest(to, subject, body, textBody)); + + private async Task Send(MailRequest request) { try { - await mailService.SendAsync(new MailRequest(to, subject, body), CancellationToken.None); + await mailService.SendAsync(request, CancellationToken.None); } catch (Exception e) { diff --git a/api/src/Vote.Monitor.Hangfire/RecurringJobs/PendingInviteResendJob.cs b/api/src/Vote.Monitor.Hangfire/RecurringJobs/PendingInviteResendJob.cs index 026463ed6..80d9d5de9 100644 --- a/api/src/Vote.Monitor.Hangfire/RecurringJobs/PendingInviteResendJob.cs +++ b/api/src/Vote.Monitor.Hangfire/RecurringJobs/PendingInviteResendJob.cs @@ -62,7 +62,7 @@ public async Task Run() ElectionRoundDetails: observer.ElectionRoundDetails); var email = emailFactory.GenerateNewUserInvitationEmail(emailProps); - await sendEmailJob.SendAsync(observer.Email, email.Subject, email.Body); + await sendEmailJob.SendAsync(observer.Email, email.Subject, email.Body, email.TextBody); } } } diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs index 5f6add619..3120f44b5 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs @@ -30,19 +30,19 @@ public async Task RunBeforeAnyTests() _emailFactory = Substitute.For(); _emailFactory.GenerateConfirmAccountEmail(Arg.Any()) - .Returns(new EmailModel("fake", "fake")); + .Returns(new EmailModel("fake", "fake", "fake")); _emailFactory.GenerateResetPasswordEmail(Arg.Any()) - .Returns(new EmailModel("fake", "fake")); + .Returns(new EmailModel("fake", "fake", "fake")); _emailFactory.GenerateInvitationExistingUserEmail(Arg.Any()) - .Returns(new EmailModel("fake", "fake")); + .Returns(new EmailModel("fake", "fake", "fake")); _emailFactory.GenerateNewUserInvitationEmail(Arg.Any()) - .Returns(new EmailModel("fake", "fake")); + .Returns(new EmailModel("fake", "fake", "fake")); _emailFactory.GenerateCitizenReportEmail(Arg.Any()) - .Returns(new EmailModel("fake", "fake")); + .Returns(new EmailModel("fake", "fake", "fake")); _jobService = Substitute.For(); diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/Features/MonitoringObservers/ImportObserversTests.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/MonitoringObservers/ImportObserversTests.cs index a1fce0b4f..25c152ede 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/Features/MonitoringObservers/ImportObserversTests.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/MonitoringObservers/ImportObserversTests.cs @@ -22,9 +22,9 @@ public void ReImportedObserversShouldBeInPendingUntilTheyAcceptInvite() { // Arrange EmailFactory.GenerateNewUserInvitationEmail(Arg.Any()) - .Returns(new EmailModel(string.Empty, string.Empty)); + .Returns(new EmailModel(string.Empty, string.Empty, string.Empty)); EmailFactory.GenerateInvitationExistingUserEmail(Arg.Any()) - .Returns(new EmailModel(string.Empty, string.Empty)); + .Returns(new EmailModel(string.Empty, string.Empty, string.Empty)); var scenarioData = ScenarioBuilder.New(CreateClient) .WithNgo(ScenarioNgos.Alfa) @@ -72,9 +72,9 @@ public void ReImportedObserversShouldBeActiveIfTheyAcceptInvite() { // Arrange EmailFactory.GenerateNewUserInvitationEmail(Arg.Any()) - .Returns(new EmailModel(string.Empty, string.Empty)); + .Returns(new EmailModel(string.Empty, string.Empty, string.Empty)); EmailFactory.GenerateInvitationExistingUserEmail(Arg.Any()) - .Returns(new EmailModel(string.Empty, string.Empty)); + .Returns(new EmailModel(string.Empty, string.Empty, string.Empty)); var scenarioData = ScenarioBuilder.New(CreateClient) .WithNgo(ScenarioNgos.Alfa) @@ -99,7 +99,7 @@ public void ReImportedObserversShouldBeActiveIfTheyAcceptInvite() { invitationToken = GetInvitationTokenFromInviteUrl(x.AcceptUrl); })) - .Returns(new EmailModel(string.Empty, string.Empty)); + .Returns(new EmailModel(string.Empty, string.Empty, string.Empty)); admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundAId}/monitoring-observers:import", importFile); @@ -154,7 +154,7 @@ public void ReImportedObserversThatHavePendingAccountShouldReceiveAcceptInviteEm EmailFactory .GenerateNewUserInvitationEmail(Arg.Any()) - .Returns(new EmailModel("This is a confirm account email", acceptUrl)); + .Returns(new EmailModel("This is a confirm account email", acceptUrl, "Confirm text")); // Act admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundAId}/monitoring-observers:import", @@ -166,7 +166,7 @@ public void ReImportedObserversThatHavePendingAccountShouldReceiveAcceptInviteEm // Assert JobService .Received(2) - .EnqueueSendEmail(aliceEmail, "This is a confirm account email", acceptUrl); + .EnqueueSendEmail(aliceEmail, "This is a confirm account email", acceptUrl, "Confirm text"); } [Test] @@ -198,11 +198,11 @@ public void ReImportedObserversThatHaveActiveAccountShouldReceiveNotificationEma invitationToken = GetInvitationTokenFromInviteUrl(x.AcceptUrl); })) .Returns(x => new EmailModel("This is a confirm account email", - GetInvitationTokenFromInviteUrl(x.Arg().AcceptUrl))); + GetInvitationTokenFromInviteUrl(x.Arg().AcceptUrl), "Confirm text")); EmailFactory .GenerateInvitationExistingUserEmail(Arg.Any()) - .Returns(new EmailModel("This is a notification email", "Notification email body")); + .Returns(new EmailModel("This is a notification email", "Notification email body", "Notification email text")); admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundAId}/monitoring-observers:import", importFile); @@ -218,11 +218,11 @@ public void ReImportedObserversThatHaveActiveAccountShouldReceiveNotificationEma JobService .Received(1) .EnqueueSendEmail(aliceEmail, - "This is a confirm account email", Arg.Is(invitationToken)); + "This is a confirm account email", Arg.Is(invitationToken), "Confirm text"); JobService .Received(1) - .EnqueueSendEmail(aliceEmail, "This is a notification email", "Notification email body"); + .EnqueueSendEmail(aliceEmail, "This is a notification email", "Notification email body", "Notification email text"); } private string GetInvitationTokenFromInviteUrl(string inviteUrl) diff --git a/api/tests/Vote.Monitor.Core.UnitTests/EmailTests.cs b/api/tests/Vote.Monitor.Core.UnitTests/EmailTests.cs new file mode 100644 index 000000000..2e30fb7d9 --- /dev/null +++ b/api/tests/Vote.Monitor.Core.UnitTests/EmailTests.cs @@ -0,0 +1,81 @@ +using Vote.Monitor.Core.Services.EmailTemplating; +using Vote.Monitor.Core.Services.EmailTemplating.Props; + +namespace Vote.Monitor.Core.UnitTests; + +public class EmailTests +{ + // The factory is internal; its interface is what the app uses. + private static readonly IEmailTemplateFactory Factory = (IEmailTemplateFactory)Activator.CreateInstance( + typeof(IEmailTemplateFactory).Assembly.GetType("Vote.Monitor.Core.Services.EmailTemplating.EmailTemplateFactory")!, + nonPublic: true)!; + + private static readonly string Year = DateTime.UtcNow.Year.ToString(); + + private static IEnumerable AllEmails() => + [ + Factory.GenerateResetPasswordEmail(new ResetPasswordEmailProps("Ana", "https://cdn", "https://app/reset")), + Factory.GenerateConfirmAccountEmail(new ConfirmEmailProps("https://cdn", "Ana", "https://app/confirm")), + Factory.GenerateNewUserInvitationEmail(new InvitationNewUserEmailProps("Ana", "https://cdn", "https://app/accept", "Alfa", "Round A")), + Factory.GenerateInvitationExistingUserEmail(new InvitationExistingUserEmailProps("Ana", "https://cdn", "Alfa", "Round A")), + Factory.GenerateCitizenReportEmail(new CitizenReportEmailProps("Title", "Heading", "Preview", [], "https://cdn")), + ]; + + [Fact] + public void Every_email_carries_the_current_year_in_both_bodies() + { + foreach (var email in AllEmails()) + { + email.Body.Should().Contain($"© {Year} Commit Global").And.NotContain("~$").And.NotContain("2024"); + email.TextBody.Should().Contain($"© {Year} Commit Global").And.NotContain("~$").And.NotContain("<"); + } + } + + [Fact] + public void Text_body_of_the_new_user_invitation() + { + var email = Factory.GenerateNewUserInvitationEmail( + new InvitationNewUserEmailProps("Ana Popescu", "https://cdn", "https://app/accept?token=a&b=1", "Alfa", "Round A")); + + email.TextBody.Should().StartWith("Hello, Ana Popescu\n"); + email.TextBody.Should().Contain("Alfa has invited you to be an observer for Round A."); + email.TextBody.Should().Contain("in order to use the application:\nhttps://app/accept?token=a&b=1\n"); + } + + [Fact] + public void Text_body_of_a_citizen_report_lists_the_answers() + { + var email = Factory.GenerateCitizenReportEmail(new CitizenReportEmailProps("Title", "Your report", "Preview", + [ + new InputAnswerFragmentProps("What happened?", "Notes", [new NoteFragmentProps("1.", "A note")], + "Attachments", [new AttachmentFragmentProps("photo.jpg", "https://cdn/photo.jpg")], "Queue outside"), + new SelectAnswerFragmentProps("Which?", "Notes", [], "Attachments", [], + [new SelectAnswerOptionFragmentProps("Yes", true), new SelectAnswerOptionFragmentProps("No", false)]), + new RatingAnswerFragmentProps("How bad?", "Notes", [], "Attachments", [], 3, 2), + ], "https://cdn")); + + email.TextBody.Should().Be( + $""" + Your report + + What happened? + Queue outside + + Notes + 1. A note + + Attachments + photo.jpg: https://cdn/photo.jpg + + Which? + [x] Yes + [ ] No + + How bad? + ( 1 ) [ 2 ] ( 3 ) + + © {Year} Commit Global. All rights reserved. + + """); + } +} From fdb7cc08a18217f85304595b95bebac79b767d95 Mon Sep 17 00:00:00 2001 From: Ion Dormenco Date: Tue, 10 Nov 2026 10:20:41 +0200 Subject: [PATCH 2/4] Potential fix for pull request finding 'CodeQL / Missing a summary in documentation comment' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../Vote.Monitor.Core/Services/EmailTemplating/EmailModel.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/src/Vote.Monitor.Core/Services/EmailTemplating/EmailModel.cs b/api/src/Vote.Monitor.Core/Services/EmailTemplating/EmailModel.cs index ab4613b42..4ff194c3e 100644 --- a/api/src/Vote.Monitor.Core/Services/EmailTemplating/EmailModel.cs +++ b/api/src/Vote.Monitor.Core/Services/EmailTemplating/EmailModel.cs @@ -1,4 +1,9 @@ namespace Vote.Monitor.Core.Services.EmailTemplating; +/// +/// Represents the email content used by the templating service. +/// +/// The email subject line. +/// The HTML body content of the email. /// The plain-text alternative of , sent beside it. public record EmailModel(string Subject, string Body, string TextBody); From d090c7e02c06392097be9a1ff976731ddb0c6144 Mon Sep 17 00:00:00 2001 From: Ion Dormenco Date: Tue, 10 Nov 2026 10:20:53 +0200 Subject: [PATCH 3/4] Potential fix for pull request finding 'CodeQL / Missing a summary in documentation comment' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../Services/Mailing/Contracts/MailRequest.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/src/Vote.Monitor.Core/Services/Mailing/Contracts/MailRequest.cs b/api/src/Vote.Monitor.Core/Services/Mailing/Contracts/MailRequest.cs index f06f547ac..54194799a 100644 --- a/api/src/Vote.Monitor.Core/Services/Mailing/Contracts/MailRequest.cs +++ b/api/src/Vote.Monitor.Core/Services/Mailing/Contracts/MailRequest.cs @@ -1,5 +1,8 @@ namespace Vote.Monitor.Core.Services.Mailing.Contracts; +/// +/// Represents an email message request payload. +/// /// The plain-text alternative of the HTML ; null sends HTML only. public class MailRequest(string to, string subject, string body, string? textBody = null) { From f64c7cc0b9c84faa278f6713c55a42dc7131aef5 Mon Sep 17 00:00:00 2001 From: Ion Dormenco Date: Tue, 10 Nov 2026 14:13:10 +0200 Subject: [PATCH 4/4] Potential fix for pull request finding 'CodeQL / Call to 'System.IO.Path.Combine' may silently drop its earlier arguments' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../Services/EmailTemplating/Helpers/EmailTemplateLoader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Helpers/EmailTemplateLoader.cs b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Helpers/EmailTemplateLoader.cs index 49b48fb8b..e3c2bf3e7 100644 --- a/api/src/Vote.Monitor.Core/Services/EmailTemplating/Helpers/EmailTemplateLoader.cs +++ b/api/src/Vote.Monitor.Core/Services/EmailTemplating/Helpers/EmailTemplateLoader.cs @@ -44,7 +44,7 @@ private static string Read(string relativePath) { var baseDirectory = AppDomain.CurrentDomain.BaseDirectory; var tmplFolder = Path.Combine(baseDirectory, "EmailTemplates"); - var filePath = Path.Combine(tmplFolder, relativePath); + var filePath = Path.Join(tmplFolder, relativePath); using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var sr = new StreamReader(fs, Encoding.Default);