Files
phishingclub/backend/service/email.go
Ronni Skansing 4b091a4d3c add inline images for emails
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-01-19 20:57:14 +01:00

1022 lines
29 KiB
Go

package service
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/build"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
"github.com/wneessen/go-mail"
"gorm.io/gorm"
)
// Email is a Email service
type Email struct {
Common
EmailRepository *repository.Email
SMTPService *SMTPConfiguration
DomainService *Domain
RecipientService *Recipient
TemplateService *Template
AttachmentService *Attachment
AttachmentPath string
}
// AttachmentWithInline represents an attachment with inline flag
type AttachmentWithInline struct {
ID *uuid.UUID
IsInline bool
}
// AddAttachments adds an attachments to a message
func (m *Email) AddAttachments(
ctx context.Context,
session *model.Session,
messageID *uuid.UUID,
attachments []AttachmentWithInline,
) error {
ae := NewAuditEvent("Email.AddAttachments", session)
ae.Details["messageId"] = messageID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.Logger.Errorw("failed to get email id", "error", err)
return errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// TODO check if the user is privliged for the message
_, err = m.EmailRepository.GetByID(
ctx,
messageID,
&repository.EmailOption{},
)
if err != nil {
m.Logger.Errorw("failed to add attachment to email", "error", err)
return errs.Wrap(err)
}
// add attachment to message
attachmentIdsStr := []string{}
for _, attachment := range attachments {
attachmentIdsStr = append(attachmentIdsStr, attachment.ID.String())
// get the message to ensure it exists and the user is privliged
_, err = m.AttachmentService.GetByID(
ctx,
session,
attachment.ID,
)
if err != nil {
m.Logger.Errorw("failed to add attachment to email", "error", err)
return errs.Wrap(err)
}
err = m.EmailRepository.AddAttachment(
ctx,
messageID,
attachment.ID,
attachment.IsInline,
)
if err != nil {
m.Logger.Errorw("failed to add attachment to email", "error", err)
return errs.Wrap(err)
}
}
ae.Details["attachmentIds"] = attachmentIdsStr
m.AuditLogAuthorized(ae)
return nil
}
// RemoveAttachment removes an attachment from a email
func (m *Email) RemoveAttachment(
ctx context.Context,
session *model.Session,
emailID *uuid.UUID,
attachmentID *uuid.UUID,
) error {
ae := NewAuditEvent("Email.RemoveAttachment", session)
ae.Details["emailId"] = emailID.String()
ae.Details["attachmentId"] = attachmentID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// TODO check if the user is privliged for the email
_, err = m.EmailRepository.GetByID(
ctx,
emailID,
&repository.EmailOption{},
)
if err != nil {
m.Logger.Errorw("failed to remove attachment from email", "error", err)
return errs.Wrap(err)
}
// get the email to ensure it exists and the user is privliged
_, err = m.EmailRepository.GetByID(
ctx,
emailID,
&repository.EmailOption{},
)
if err != nil {
m.Logger.Errorw("failed to remove attachment from email", "error", err)
return errs.Wrap(err)
}
// remove attachment from email
err = m.EmailRepository.RemoveAttachment(
ctx,
emailID,
attachmentID,
)
if err != nil {
m.Logger.Errorw("failed to remove attachment from email", "error", err)
return errs.Wrap(err)
}
m.AuditLogAuthorized(ae)
return nil
}
// Create creates a new email
func (m *Email) Create(
ctx context.Context,
session *model.Session,
email *model.Email,
) (*uuid.UUID, error) {
ae := NewAuditEvent("Email.Create", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// validate
if err := email.Validate(); err != nil {
return nil, errs.Wrap(err)
}
// validate template content if present
if content, err := email.Content.Get(); err == nil {
if err := m.TemplateService.ValidateEmailTemplate(content.String()); err != nil {
m.Logger.Errorw("failed to validate email template", "error", err)
return nil, validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
}
}
// check uniqueness
var companyID *uuid.UUID
if cid, err := email.CompanyID.Get(); err == nil {
companyID = &cid
}
name := email.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
m.EmailRepository.DB,
"emails",
name.String(),
companyID,
nil,
)
if err != nil {
m.Logger.Errorw("failed to create email", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
m.Logger.Debugw("email name is already taken", "error", name.String())
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// handle tracking pixel
email, err = m.toggleTrackingPixel(email)
if err != nil {
return nil, errs.Wrap(err)
}
// create email
emailID, err := m.EmailRepository.Insert(
ctx,
email,
)
if err != nil {
m.Logger.Errorw("failed to create email", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = emailID.String()
m.AuditLogAuthorized(ae)
return emailID, nil
}
func (m *Email) toggleTrackingPixel(
email *model.Email,
) (*model.Email, error) {
// add tracking pixel
addTrackingPixel, err := email.AddTrackingPixel.Get()
if err != nil {
return email, nil
}
c, err := email.Content.Get()
if err != nil {
return nil, errs.Wrap(err)
}
var tmp string
if !addTrackingPixel {
tmp = m.TemplateService.RemoveTrackingPixelFromContent(c.String())
} else {
tmp = m.TemplateService.AddTrackingPixel(c.String())
}
b, err := vo.NewOptionalString1MB(tmp)
if err != nil {
return nil, errs.Wrap(err)
}
email.Content.Set(*b)
return email, nil
}
// GetAll gets all emails by pagination with optional company id
func (m *Email) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
queryArgs *vo.QueryArgs,
) (*model.Result[model.Email], error) {
result := model.NewEmptyResult[model.Email]()
ae := NewAuditEvent("Email.GetAll", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get all emails
emails, err := m.EmailRepository.GetAll(
ctx,
companyID,
&repository.EmailOption{
QueryArgs: queryArgs,
},
)
if err != nil {
m.Logger.Errorw("failed to get emails", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return emails, nil
}
// GetOverviews gets all email overviews
func (m *Email) GetOverviews(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
queryArgs *vo.QueryArgs,
) (*model.Result[model.Email], error) {
result := model.NewEmptyResult[model.Email]()
ae := NewAuditEvent("Email.GetOverviews", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get all emails
result, err = m.EmailRepository.GetOverviews(
ctx,
companyID,
&repository.EmailOption{
QueryArgs: queryArgs,
},
)
if err != nil {
m.Logger.Errorw("failed to get emails", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetByID gets a email by id
func (m *Email) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
companyID *uuid.UUID,
) (*model.Email, error) {
ae := NewAuditEvent("Email.GetByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get email by id
email, err := m.EmailRepository.GetByID(
ctx,
id,
&repository.EmailOption{
WithAttachments: false, // we'll load attachments manually with context filtering
},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
// return early this is not an error
return nil, errs.Wrap(err)
}
if err != nil {
m.Logger.Errorw("failed to get email by id", "error", err)
return nil, errs.Wrap(err)
}
// load attachments with proper context filtering
err = m.loadEmailAttachmentsWithContext(ctx, email, companyID)
if err != nil {
m.Logger.Errorw("failed to load email attachments with context", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return email, nil
}
// GetByCompanyID gets a emails by company id
func (m *Email) GetByCompanyID(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) (*model.Result[model.Email], error) {
result := model.NewEmptyResult[model.Email]()
ae := NewAuditEvent("Email.GetByCompanyID", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get emails by id
result, err = m.EmailRepository.GetAllByCompanyID(
ctx,
companyID,
&repository.EmailOption{},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
// return early this is not an error
return result, errs.Wrap(err)
}
if err != nil {
m.Logger.Errorw("failed to get email by id", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// SendTestEmail sends a test email
func (m *Email) SendTestEmail(
ctx context.Context,
session *model.Session,
emailID *uuid.UUID,
smtpID *uuid.UUID,
domainID *uuid.UUID,
recpID *uuid.UUID,
companyID *uuid.UUID,
) error {
ae := NewAuditEvent("Email.SendTestEmail", session)
ae.Details["emailId"] = emailID.String()
ae.Details["smtpId"] = smtpID.String()
ae.Details["recipientId"] = recpID.String()
ae.Details["domainID"] = domainID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get recipient by id
recipient, err := m.RecipientService.GetByID(
ctx,
session,
recpID,
&repository.RecipientOption{},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Infow("failed to send test email - recipient not found",
"recipientID", recpID.String(),
)
return errs.Wrap(err)
}
// get smtp by id
smtp, err := m.SMTPService.GetByID(ctx, session, smtpID, &repository.SMTPConfigurationOption{})
if errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Infow("failed to send test email - stmp not found",
"SMTPID", smtpID.String(),
)
return errs.Wrap(err)
}
if err != nil {
m.Logger.Errorw("failed to send test email, smtp not found", "error", err)
return err
}
// get domain by id
testDomain, err := m.DomainService.GetByID(ctx, session, domainID, &repository.DomainOption{})
if errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Infow("failed to send test email - domain not found",
"DomainID", domainID.String(),
)
return errs.Wrap(err)
}
if err != nil {
m.Logger.Errorw("failed to send test email, domain not found", "error", err)
return err
}
// get email by id
email, err := m.EmailRepository.GetByID(
ctx,
emailID,
&repository.EmailOption{
WithAttachments: false, // we'll load attachments manually with context filtering
},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Infow("failed to send test email - email not found",
"emailID", emailID.String(),
)
return errs.Wrap(err)
}
if err != nil {
m.Logger.Errorw("failed to send test email - email not found", "error", err)
return errs.Wrap(err)
}
// load attachments with proper context filtering
err = m.loadEmailAttachmentsWithContext(ctx, email, companyID)
if err != nil {
m.Logger.Errorw("failed to load email attachments with context for test email", "error", err)
return errs.Wrap(err)
}
campaignRecipient := &model.CampaignRecipient{
ID: nullable.NewNullableWithValue(uuid.New()),
Recipient: recipient,
}
smtpPort, err := smtp.Port.Get()
if err != nil {
m.Logger.Errorw("failed to get smtp port", "error", err)
return errs.Wrap(err)
}
smtpHost, err := smtp.Host.Get()
if err != nil {
m.Logger.Errorw("failed to get smtp host", "error", err)
return errs.Wrap(err)
}
smtpIgnoreCertErrors, err := smtp.IgnoreCertErrors.Get()
if err != nil {
m.Logger.Errorw("failed to get smtp ignore cert errors", "error", err)
return errs.Wrap(err)
}
emailOptions := []mail.Option{
mail.WithPort(smtpPort.Int()),
mail.WithTLSConfig(
&tls.Config{
ServerName: smtpHost.String(),
// #nosec
InsecureSkipVerify: smtpIgnoreCertErrors,
// MinVersion: tls.VersionTLS12,
},
),
}
// setup authentication if provided
username, err := smtp.Username.Get()
if err != nil {
m.Logger.Errorw("failed to get smtp username", "error", err)
return errs.Wrap(err)
}
password, err := smtp.Password.Get()
if err != nil {
m.Logger.Errorw("failed to get smtp password", "error", err)
return errs.Wrap(err)
}
if un := username.String(); len(un) > 0 {
emailOptions = append(
emailOptions,
mail.WithUsername(
un,
),
)
if pw := password.String(); len(pw) > 0 {
emailOptions = append(
emailOptions,
mail.WithPassword(
pw,
),
)
}
}
// prepare message
messageOptions := []mail.MsgOption{
mail.WithNoDefaultUserAgent(),
}
msg := mail.NewMsg(messageOptions...)
err = msg.EnvelopeFrom(email.MailEnvelopeFrom.MustGet().String())
if err != nil {
m.Logger.Errorw("failed to set envelope from", "error", err)
return errs.Wrap(err)
}
// headers
err = msg.From(email.MailHeaderFrom.MustGet().String())
if err != nil {
m.Logger.Errorw("failed to set mail header 'From'", "error", err)
return errs.Wrap(err)
}
recpEmail := campaignRecipient.Recipient.Email.MustGet().String()
err = msg.To(recpEmail)
if err != nil {
m.Logger.Errorw("failed to set mail header 'To'", "error", err)
return errs.Wrap(err)
}
// custom headers
if headers := smtp.Headers; headers != nil {
for _, header := range headers {
key := header.Key.MustGet()
value := header.Value.MustGet()
msg.SetGenHeader(
mail.Header(key.String()),
value.String(),
)
}
}
domainName, err := testDomain.Name.Get()
if err != nil {
m.Logger.Errorw("failed to get domain name", "error", err)
return errs.Wrap(err)
}
// create template
content, err := email.Content.Get()
if err != nil {
m.Logger.Errorw("failed to get message content", "error", err)
return errs.Wrap(err)
}
mailTmpl, err := template.
New("email").
Funcs(TemplateFuncs()).
Parse(content.String())
if err != nil {
m.Logger.Errorw("failed to parse email template", "error", err)
return errs.Wrap(err)
}
t := m.TemplateService.CreateMail(
ctx,
domainName.String(),
"id",
"/",
campaignRecipient,
email,
nil,
companyID,
)
// process subject through template
subjectTemplate, err := template.New("subject").Funcs(m.TemplateService.TemplateFuncsWithCompany(ctx, companyID)).Parse(email.MailHeaderSubject.MustGet().String())
if err != nil {
m.Logger.Errorw("failed to parse subject template", "error", err)
return errs.Wrap(err)
}
var subjectBuffer bytes.Buffer
err = subjectTemplate.Execute(&subjectBuffer, t)
if err != nil {
m.Logger.Errorw("failed to execute subject template", "error", err)
return errs.Wrap(err)
}
msg.Subject(subjectBuffer.String())
var bodyBuffer bytes.Buffer
err = mailTmpl.Execute(&bodyBuffer, t)
if err != nil {
m.Logger.Errorw("failed to execute mail template", "error", err)
return err
}
msg.SetBodyString("text/html", bodyBuffer.String())
// attachments
attachments := email.Attachments
for _, emailAttachment := range attachments {
attachment := emailAttachment.Attachment
p, err := m.AttachmentService.GetPath(attachment)
if err != nil {
return fmt.Errorf("failed to get attachment path: %s", err)
}
// check if attachment should be inline (for cid: references)
if emailAttachment.IsInline {
// inline attachment - embedded in email body, can be referenced via cid:filename
if !attachment.EmbeddedContent.MustGet() {
// simple inline image - no template processing needed
msg.EmbedFile(p.String())
} else {
// inline image with template processing
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
}
// setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
UpdatedAt: email.UpdatedAt,
Name: email.Name,
MailEnvelopeFrom: email.MailEnvelopeFrom,
MailHeaderFrom: email.MailHeaderFrom,
MailHeaderSubject: email.MailHeaderSubject,
Content: email.Content,
AddTrackingPixel: email.AddTrackingPixel,
CompanyID: email.CompanyID,
Attachments: email.Attachments,
Company: email.Company,
}
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
attachmentStr, err := m.TemplateService.CreateMailBody(
ctx,
"id",
"/",
testDomain,
campaignRecipient,
&attachmentAsEmail,
nil,
companyID,
)
if err != nil {
return fmt.Errorf("failed to setup attachment with embedded content: %s", err)
}
// use EmbedReader for inline images - sets Content-Disposition: inline and Content-ID header
// the filename becomes the Content-ID, so use <img src="cid:filename.jpg"> in HTML
msg.EmbedReader(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
}
} else if !attachment.EmbeddedContent.MustGet() {
// regular attachment - shows in attachment list
msg.AttachFile(p.String())
} else {
// inline attachment - embedded in email body, can be referenced via cid:filename
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
}
// setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
UpdatedAt: email.UpdatedAt,
Name: email.Name,
MailEnvelopeFrom: email.MailEnvelopeFrom,
MailHeaderFrom: email.MailHeaderFrom,
MailHeaderSubject: email.MailHeaderSubject,
Content: email.Content,
AddTrackingPixel: email.AddTrackingPixel,
CompanyID: email.CompanyID,
Attachments: email.Attachments,
Company: email.Company,
}
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
attachmentStr, err := m.TemplateService.CreateMailBody(
ctx,
"id",
"/",
testDomain,
campaignRecipient,
&attachmentAsEmail,
nil,
companyID,
)
if err != nil {
return fmt.Errorf("failed to setup attachment with embedded content: %s", err)
}
// use EmbedReader for inline images - sets Content-Disposition: inline and Content-ID header
// the filename becomes the Content-ID, so use <img src="cid:filename.jpg"> in HTML
msg.EmbedReader(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
}
}
// the client sends all the messages and ensure that all messages are sent
// in the same connection
var mc *mail.Client
// Try different authentication methods based on configuration
// If username is provided, use authentication; otherwise try without auth first
if un := username.String(); len(un) > 0 {
// Try CRAM-MD5 first when credentials are provided
emailOptionsCRAM5 := append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthCramMD5))
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsCRAM5...)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(ctx, msg)
// Check if it's an authentication error and try PLAIN auth
if err != nil && (strings.Contains(err.Error(), "535 ") ||
strings.Contains(err.Error(), "534 ") ||
strings.Contains(err.Error(), "538 ") ||
strings.Contains(err.Error(), "CRAM-MD5") ||
strings.Contains(err.Error(), "authentication failed")) {
m.Logger.Warnw("CRAM-MD5 authentication failed, trying PLAIN auth", "error", err)
emailOptionsBasic := emailOptions
if build.Flags.Production {
emailOptionsBasic = append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthPlain))
}
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsBasic...)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(ctx, msg)
}
} else {
// No credentials provided, try without authentication (e.g., local postfix)
mc, _ = mail.NewClient(smtpHost.String(), emailOptions...)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(ctx, msg)
// If no-auth fails and we get an auth-related error, log it appropriately
if err != nil && (strings.Contains(err.Error(), "530 ") ||
strings.Contains(err.Error(), "535 ") ||
strings.Contains(err.Error(), "authentication required") ||
strings.Contains(err.Error(), "AUTH")) {
m.Logger.Warnw("Server requires authentication but no credentials provided", "error", err)
}
}
if err != nil {
m.Logger.Errorw("failed to send test email", "error", err)
if msg.HasSendError() {
m.Logger.Errorw("failed to send test email", "error", msg.SendError())
return msg.SendError()
}
return err
}
m.AuditLogAuthorized(ae)
return nil
}
// UpdateByID updates a email by id
func (m *Email) UpdateByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
email *model.Email,
) error {
ae := NewAuditEvent("Email.UpdateByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return errs.Wrap(errs.ErrAuthorizationFailed)
}
// get current by id
current, err := m.EmailRepository.GetByID(
ctx,
id,
&repository.EmailOption{},
)
if errors.Is(err, gorm.ErrRecordNotFound) {
m.Logger.Debugw("failed to update email by ID", "error", err)
return errs.Wrap(err)
}
if err != nil {
m.Logger.Errorw("failed to update email by ID", "error", err)
return errs.Wrap(err)
}
var companyID *uuid.UUID
if cid, err := email.CompanyID.Get(); err == nil {
companyID = &cid
}
// check uniqueness
name := email.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
m.EmailRepository.DB,
"emails",
name.String(),
companyID,
id,
)
if err != nil {
m.Logger.Errorw("failed to create email", "error", err)
return errs.Wrap(err)
}
if !isOK {
m.Logger.Debugw("email name is already taken", "name", name.String())
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// update email - if a field is present and not null, update it
if v, err := email.Name.Get(); err == nil {
current.Name.Set(v)
}
if v, err := email.MailEnvelopeFrom.Get(); err == nil {
current.MailEnvelopeFrom.Set(v)
}
if v, err := email.MailHeaderFrom.Get(); err == nil {
current.MailHeaderFrom.Set(v)
}
if v, err := email.MailHeaderSubject.Get(); err == nil {
current.MailHeaderSubject.Set(v)
}
if v, err := email.Content.Get(); err == nil {
// validate template content before updating
if err := m.TemplateService.ValidateEmailTemplate(v.String()); err != nil {
m.Logger.Errorw("failed to validate email template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
}
if _, err := email.AddTrackingPixel.Get(); err == nil {
// handle tracking pixel
email, err = m.toggleTrackingPixel(email)
if err != nil {
return errs.Wrap(err)
}
current.Content.Set(email.Content.MustGet())
} else {
current.Content.Set(v)
}
}
if v, err := email.AddTrackingPixel.Get(); err == nil {
current.AddTrackingPixel.Set(v)
}
if v, err := email.CompanyID.Get(); err == nil {
current.CompanyID.Set(v)
}
// validate change
if err := current.Validate(); err != nil {
m.Logger.Errorw("failed to update email by ID", "error", err)
return errs.Wrap(err)
}
// update email
err = m.EmailRepository.UpdateByID(
ctx,
id,
current,
)
if err != nil {
m.Logger.Errorw("failed to update email by ID", "error", err)
return errs.Wrap(err)
}
m.AuditLogAuthorized(ae)
return nil
}
// DeleteByID deletes a email by id
func (m *Email) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("Email.DeleteByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
m.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
m.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// delete email by id
err = m.EmailRepository.DeleteByID(
ctx,
id,
)
if err != nil {
m.Logger.Errorw("failed to delete message by id", "error", err)
return errs.Wrap(err)
}
m.AuditLogAuthorized(ae)
return nil
}
// loadEmailAttachmentsWithContext loads attachments for an email with proper context filtering
// this ensures that in global context, only global attachments are loaded
// and in company context, both global and company-specific attachments are loaded
func (m *Email) loadEmailAttachmentsWithContext(
ctx context.Context,
email *model.Email,
companyID *uuid.UUID,
) error {
// get all email-attachment relationships with isInline status
emailAttachments, err := m.EmailRepository.GetEmailAttachments(ctx, email.ID.MustGet())
if err != nil {
return errs.Wrap(err)
}
// if no attachments, nothing to do
if len(emailAttachments) == 0 {
email.Attachments = []*model.EmailAttachment{}
return nil
}
// get attachments with proper context filtering
contextFilteredAttachments := []*model.EmailAttachment{}
for _, ea := range emailAttachments {
attachment, err := m.AttachmentService.AttachmentRepository.GetByID(ctx, ea.AttachmentID)
if err != nil {
// if attachment doesn't exist, log and continue
m.Logger.Debugw("attachment not found", "attachmentID", ea.AttachmentID, "error", err)
continue
}
// apply context filtering logic
isAttachmentAccessible := false
if companyID == nil {
// global context - only allow global attachments (company_id IS NULL)
isAttachmentAccessible = !attachment.CompanyID.IsSpecified() || attachment.CompanyID.IsNull()
} else {
// company context - allow both global and company-specific attachments
if !attachment.CompanyID.IsSpecified() || attachment.CompanyID.IsNull() {
// global attachment
isAttachmentAccessible = true
} else {
// company-specific attachment - check if it matches the context
attachmentCompanyID := attachment.CompanyID.MustGet()
isAttachmentAccessible = attachmentCompanyID == *companyID
}
}
if isAttachmentAccessible {
contextFilteredAttachments = append(contextFilteredAttachments, &model.EmailAttachment{
Attachment: attachment,
IsInline: ea.IsInline,
})
}
}
email.Attachments = contextFilteredAttachments
return nil
}