Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions service/emailService.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package service

import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"time"
Expand Down Expand Up @@ -49,12 +50,19 @@ type EmailSendResponse struct {
}

type EmailMessage struct {
From string `json:"From"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody"`
HtmlBody string `json:"HtmlBody"`
MessageStream string `json:"MessageStream"`
From string `json:"From"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody"`
HtmlBody string `json:"HtmlBody"`
MessageStream string `json:"MessageStream"`
Attachments []EmailAttachment `json:"Attachments,omitempty"`
}

type EmailAttachment struct {
Name string `json:"Name"`
Content string `json:"Content"`
ContentType string `json:"ContentType"`
}

func SendNewsEmail(email []string, subject, htmlBody string) error {
Expand Down Expand Up @@ -162,7 +170,7 @@ func SendAccountResettedEmail(email string) error {
return callSendEmail(email, subjectEmailAccountResetted, body.String())
}

func SendNodeOwnerDraftEmail(email string) error {
func SendNodeOwnerDraftEmail(email string, attachments ...EmailAttachment) error {
template, err := templates.GetOperatorDraftTemplate()
if err != nil {
return errors.New("error while retrieving email template: " + err.Error())
Expand All @@ -173,7 +181,7 @@ func SendNodeOwnerDraftEmail(email string) error {
if err != nil {
return errors.New("error while executing email template: " + err.Error())
}
return callSendEmail(email, subjectNewInvoiceDraft, body.String())
return callSendEmailWithAttachments(email, subjectNewInvoiceDraft, body.String(), attachments)
}

func SendCspDraftEmail(email string) error {
Expand Down Expand Up @@ -350,13 +358,18 @@ func callSendBatchEmail(emails []string, subject, htmlBody string) error {
}

func callSendEmail(email, subject, htmlBody string) error {
return callSendEmailWithAttachments(email, subject, htmlBody, nil)
}

func callSendEmailWithAttachments(email, subject, htmlBody string, attachments []EmailAttachment) error {
msg := EmailMessage{
From: config.Config.Mail.FromEmail,
To: email,
Subject: subject,
TextBody: "",
HtmlBody: htmlBody,
MessageStream: messageStreamSend,
Attachments: attachments,
}

var resp EmailSendResponse
Expand All @@ -372,6 +385,14 @@ func callSendEmail(email, subject, htmlBody string) error {
return nil
}

func newEmailAttachment(name, contentType string, content []byte) EmailAttachment {
return EmailAttachment{
Name: name,
Content: base64.StdEncoding.EncodeToString(content),
ContentType: contentType,
}
}

func confirmUrl(t string) string {
return fmt.Sprintf(config.Config.Mail.ConfirmUrl, t)
}
118 changes: 110 additions & 8 deletions service/monthlyPoaiInvoiceService.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"math/big"
"strings"
"time"
"unicode"

"github.com/NaeuralEdgeProtocol/ratio1-backend/model"
"github.com/NaeuralEdgeProtocol/ratio1-backend/storage"
Expand Down Expand Up @@ -122,23 +123,124 @@ func MonthlyPoaiInvoiceReport() {
drafts = append(drafts, invoice)
}

allCSP := make(map[string]bool) //map[email]true to have unique emails
allNodeOwner := make(map[string]bool)
cspEmails := make(map[string][]string)
nodeOwnerDrafts := make(map[string][]model.InvoiceDraft)
for _, invoice := range drafts {
if invoice.UserAddress != invoice.CspOwner { // I should not receive emails if i worked on my nodes
allNodeOwner[invoice.UserProfile.Email] = true
allCSP[invoice.CspProfile.Email] = true
nodeOwnerDrafts[invoice.UserAddress] = append(nodeOwnerDrafts[invoice.UserAddress], invoice)
if _, found := cspEmails[invoice.CspOwner]; !found {
cspEmails[invoice.CspOwner] = draftNotificationEmails(invoice.CspOwner, invoice.CspProfile.Email)
}
}
Comment thread
aledefra marked this conversation as resolved.
}

//send unique email for csp and node owner ( even if they have more than 1 invoice)
for k := range allNodeOwner {
_ = SendNodeOwnerDraftEmail(k) //! doesn't check error
for address, invoices := range nodeOwnerDrafts {
attachments, err := draftInvoiceAttachments(invoices)
if err != nil {
fmt.Println("error while generating draft invoice attachments: " + err.Error())
continue
}
for _, email := range draftNotificationEmails(address, invoices[0].UserProfile.Email) {
_ = SendNodeOwnerDraftEmail(email, attachments...) //! doesn't check error
}
Comment thread
aledefra marked this conversation as resolved.
}

for _, emails := range cspEmails {
for _, email := range emails {
_ = SendCspDraftEmail(email) //! doesn't check error
}
}
Comment thread
aledefra marked this conversation as resolved.
}

func draftNotificationEmails(address, fallbackEmail string) []string {
emails, err := getConfirmedAccountEmails(address)
if err != nil {
fmt.Println("error while retrieving draft notification emails: " + err.Error())
return nil
}
if len(emails) > 0 {
return emails
}
Comment thread
aledefra marked this conversation as resolved.
Comment on lines +157 to +164

email := TrimWhitespacesAndToLower(fallbackEmail)
if email == "" {
return nil
}
return []string{email}
}

func draftInvoiceAttachments(drafts []model.InvoiceDraft) ([]EmailAttachment, error) {
attachments := make([]EmailAttachment, 0, len(drafts))
for _, draft := range drafts {
allocations, err := storage.GetAllocationsByDraftId(draft.DraftId.String())
if err != nil {
return nil, err
}
content, err := FillInvoiceDraftTemplate(draft, allocations)
if err != nil {
return nil, err
}
attachments = append(attachments, newEmailAttachment(draftInvoiceAttachmentName(draft, ".doc"), "application/msword", content))
}
return attachments, nil
}

func draftInvoiceAttachmentName(draft model.InvoiceDraft, ext string) string {
if ext == "" {
ext = ".doc"
}
if ext[0] != '.' {
ext = "." + ext
}

supplier, ok := draft.UserProfile.GetNameAsString()
if !ok {
supplier = draft.UserAddress
}
beneficiary, ok := draft.CspProfile.GetNameAsString()
if !ok {
beneficiary = draft.CspOwner
}
invoiceNumber := fmt.Sprintf("%d", draft.InvoiceNumber)
if strings.TrimSpace(draft.InvoiceSeries) != "" {
invoiceNumber += "-" + draft.InvoiceSeries
}

return fmt.Sprintf("%s_%s_%s_%s%s",
draft.CreationTimestamp.Format("200601"),
safeFileNamePart(supplier),
safeFileNamePart(beneficiary),
safeFileNamePart(invoiceNumber),
ext,
)
}

func safeFileNamePart(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown"
}

var out strings.Builder
lastDash := false
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
out.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
out.WriteByte('-')
lastDash = true
}
}

for k := range allCSP {
_ = SendCspDraftEmail(k) //! doesn't check error
name := strings.Trim(out.String(), "-")
if name == "" {
return "unknown"
}
return name
}

func formKey(address1, address2 string) string {
Expand Down
67 changes: 67 additions & 0 deletions service/monthlyPoaiInvoiceService_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,79 @@
package service

import (
"errors"
"testing"
"time"

"github.com/NaeuralEdgeProtocol/ratio1-backend/config"
"github.com/NaeuralEdgeProtocol/ratio1-backend/model"
"github.com/NaeuralEdgeProtocol/ratio1-backend/storage"
"github.com/stretchr/testify/require"
)

func TestDraftNotificationEmailsUsesConfirmedPrimaryAndSecondary(t *testing.T) {
previousGetAccount := getAccountByAddressFn
previousGetNotificationEmail := getNotificationEmailFn
defer func() {
getAccountByAddressFn = previousGetAccount
getNotificationEmailFn = previousGetNotificationEmail
}()

primaryEmail := "Owner@Example.com"
secondaryEmail := " Ops@Example.com "
getAccountByAddressFn = func(address string) (*model.Account, bool, error) {
require.Equal(t, "0xowner", address)
return &model.Account{
Address: address,
Email: &primaryEmail,
EmailConfirmed: true,
}, true, nil
}
getNotificationEmailFn = func(address string) (*model.AccountNotificationEmail, bool, error) {
require.Equal(t, "0xowner", address)
return &model.AccountNotificationEmail{
AccountAddress: address,
Email: &secondaryEmail,
EmailConfirmed: true,
}, true, nil
}

require.Equal(t, []string{"owner@example.com", "ops@example.com"}, draftNotificationEmails("0xowner", "fallback@example.com"))
}

func TestDraftNotificationEmailsSkipsFallbackWhenAccountLookupFails(t *testing.T) {
previousGetAccount := getAccountByAddressFn
defer func() {
getAccountByAddressFn = previousGetAccount
}()

getAccountByAddressFn = func(address string) (*model.Account, bool, error) {
return nil, false, errors.New("storage unavailable")
}

require.Nil(t, draftNotificationEmails("0xowner", "fallback@example.com"))
}

func TestDraftInvoiceAttachmentName(t *testing.T) {
supplier := "Acme Nodes SRL"
beneficiary := "Ratio/Cloud:EU"
draft := model.InvoiceDraft{
CreationTimestamp: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC),
InvoiceNumber: 42,
InvoiceSeries: "NODE",
UserProfile: model.UserInfo{
CompanyName: &supplier,
IsCompany: true,
},
CspProfile: model.UserInfo{
CompanyName: &beneficiary,
IsCompany: true,
},
}

require.Equal(t, "202607_Acme-Nodes-SRL_Ratio-Cloud-EU_42-NODE.doc", draftInvoiceAttachmentName(draft, "doc"))
}

func Test_monthlyPoaiInvoiceService(t *testing.T) {
config.Config.Mail = config.MailConfig{
ApiUrl: "",
Expand Down
17 changes: 7 additions & 10 deletions templates/html/invoice.draft.html
Original file line number Diff line number Diff line change
Expand Up @@ -307,18 +307,16 @@ <h3>Allocations Details</h3>
<table class="alloc" cellspacing="0" cellpadding="0">
<colgroup>
<col class="w-25mm-creation">
<col class="w-20mm">
<col class="w-25mm">
<col class="w-35mm">
<col class="w-60mm">
<col class="w-20mm">
<col class="w-30mm">
<col class="w-30mm">
<col class="w-70mm">
<col class="w-30mm">
Comment on lines 309 to +313
</colgroup>
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Project</th>
<th>Job ID - Name</th>
<th>Job ID</th>
<th>Node</th>
<th>USDC Paid</th>
</tr>
Expand All @@ -328,8 +326,7 @@ <h3>Allocations Details</h3>
<tr>
<td>{{ .AllocationCreation }}</td>
<td>{{ .JobType }}</td>
<td>{{ .ProjectName }}</td>
<td>{{ .JobID }} - {{ .JobName }}</td>
<td>{{ .JobID }}</td>
<td>{{ .NodeAddress }}</td>
<td class="w-20mm-r">{{ .UsdcPaid }}</td>
</tr>
Expand All @@ -339,4 +336,4 @@ <h3>Allocations Details</h3>
</div>
</body>

</html>
</html>