mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-02-13 00:22:49 +00:00
1022 lines
29 KiB
Go
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
|
|
}
|