Merge branch 'develop' into feat-arm-support

This commit is contained in:
Ronni Skansing
2026-01-23 15:35:15 +01:00
15 changed files with 460 additions and 122 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## [1.27.0] - 2026-01-22
- Added support for attachments as inline images in emails and the email editor
- Fix delete orphans recipients when recipient is in active campaign
- Fix error in update options
- Fix faulty migration
- Fix added AllowDenyIDs on get campaign by id endpoint for consistency
## [1.26.0] - 2025-12-19
- Added ContentRaw variable to API Senders
- Fix proxy capturing on 302 responses
+28 -6
View File
@@ -27,8 +27,16 @@ var EmailOrderByMap = map[string]string{
}
// AddAttachmentsToEmailRequest is a request to add attachments to a message
// supports both old format (ids array) and new format (attachments array with isInline)
type AddAttachmentsToEmailRequest struct {
Attachments []string `json:"ids"` // attachment IDs
IDs []string `json:"ids"` // deprecated: old format for backward compatibility
Attachments []AttachmentWithInline `json:"attachments"` // new format with isInline support
}
// AttachmentWithInline represents an attachment ID with inline flag
type AttachmentWithInline struct {
ID string `json:"id"`
IsInline bool `json:"isInline"`
}
// RemoveAttachmentFromEmailRequest is a request to remove an attachment from a message
@@ -66,13 +74,24 @@ func (m *Email) AddAttachments(g *gin.Context) {
if !ok {
return
}
// handle backward compatibility: if old format (ids) is used, convert to new format
if len(request.IDs) > 0 && len(request.Attachments) == 0 {
for _, idStr := range request.IDs {
request.Attachments = append(request.Attachments, AttachmentWithInline{
ID: idStr,
IsInline: false, // default to false for backward compatibility
})
}
}
if len(request.Attachments) == 0 {
m.Response.BadRequestMessage(g, "No attachments provided")
return
}
attachmentIDs := []*uuid.UUID{}
for _, idParam := range request.Attachments {
id, err := uuid.Parse(idParam)
attachments := []service.AttachmentWithInline{}
for _, att := range request.Attachments {
id, err := uuid.Parse(att.ID)
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID,
"error", err,
@@ -80,14 +99,17 @@ func (m *Email) AddAttachments(g *gin.Context) {
m.Response.BadRequestMessage(g, "Invalid attachment ID")
return
}
attachmentIDs = append(attachmentIDs, &id)
attachments = append(attachments, service.AttachmentWithInline{
ID: &id,
IsInline: att.IsInline,
})
}
// add attachments to email
err := m.EmailService.AddAttachments(
g.Request.Context(),
session,
id,
attachmentIDs,
attachments,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
+1
View File
@@ -9,6 +9,7 @@ import (
type EmailAttachment struct {
EmailID *uuid.UUID `gorm:"primary_key;not null;index;type:uuid;unique_index:idx_message_attachment;"`
AttachmentID *uuid.UUID `gorm:"primary_key;not null;index;type:uuid;unique_index:idx_message_attachment;"`
IsInline bool `gorm:"not null;default:false;"`
}
func (EmailAttachment) TableName() string {
+9 -2
View File
@@ -22,8 +22,8 @@ type Email struct {
AddTrackingPixel nullable.Nullable[bool] `json:"addTrackingPixel"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Attachments []*Attachment `json:"attachments"`
Company *Company `json:"company"`
Attachments []*EmailAttachment `json:"attachments"`
Company *Company `json:"company"`
}
// Validate checks if the mail has a valid state
@@ -132,3 +132,10 @@ type EmailOverview struct {
Company *Company `json:"company"`
}
// EmailAttachment represents an attachment associated with an email
// with additional metadata about how it should be displayed
type EmailAttachment struct {
*Attachment
IsInline bool `json:"isInline"` // if true, use Content-Disposition: inline and set Content-ID for cid: references
}
+18 -5
View File
@@ -54,11 +54,13 @@ func (m *Email) AddAttachment(
ctx context.Context,
emailID *uuid.UUID,
attachmentID *uuid.UUID,
isInline bool,
) error {
result := m.DB.Create(
&database.EmailAttachment{
EmailID: emailID,
AttachmentID: attachmentID,
IsInline: isInline,
},
)
if result.Error != nil {
@@ -103,6 +105,19 @@ func (m *Email) GetAttachmentIDsByEmailID(
return attachmentIDs, nil
}
// GetEmailAttachments gets all email-attachment relationships for an email including isInline status
func (m *Email) GetEmailAttachments(
ctx context.Context,
emailID uuid.UUID,
) ([]database.EmailAttachment, error) {
var emailAttachments []database.EmailAttachment
result := m.DB.Where("email_id = ?", emailID).Find(&emailAttachments)
if result.Error != nil {
return nil, result.Error
}
return emailAttachments, nil
}
// RemoveAttachment removes an attachments from a email by attachment ID
func (m *Email) RemoveAttachmentsByAttachmentID(
ctx context.Context,
@@ -342,10 +357,8 @@ func ToEmail(row *database.Email) *model.Email {
content := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.Content))
addTrackingPixel := nullable.NewNullableWithValue(row.AddTrackingPixel)
attachments := []*model.Attachment{}
for _, attachment := range row.Attachments {
attachments = append(attachments, ToAttachment(attachment))
}
// attachments are loaded separately via loadEmailAttachmentsWithContext
// which properly loads EmailAttachment with isInline status from junction table
return &model.Email{
ID: id,
CreatedAt: row.CreatedAt,
@@ -357,7 +370,7 @@ func ToEmail(row *database.Email) *model.Email {
Content: content,
AddTrackingPixel: addTrackingPixel,
CompanyID: companyID,
Attachments: attachments,
Attachments: []*model.EmailAttachment{},
}
}
-39
View File
@@ -434,45 +434,6 @@ func (r *Recipient) GetOrphaned(
return result, nil
}
// DeleteAllOrphaned deletes all recipients that are not in any group
func (r *Recipient) DeleteAllOrphaned(
ctx context.Context,
companyID *uuid.UUID,
) (int64, error) {
// build optimized LEFT JOIN delete query for orphaned recipients
var companyFilter string
var args []interface{}
if companyID != nil {
companyFilter = fmt.Sprintf("AND r.company_id = ?")
args = append(args, companyID)
} else {
companyFilter = fmt.Sprintf("AND r.company_id IS NULL")
}
// use raw SQL for optimized LEFT JOIN delete
query := fmt.Sprintf(`
DELETE FROM %s
WHERE id IN (
SELECT r.id FROM %s r
LEFT JOIN %s rgr ON r.id = rgr.recipient_id
WHERE rgr.recipient_id IS NULL %s
)`,
database.RECIPIENT_TABLE,
database.RECIPIENT_TABLE,
database.RECIPIENT_GROUP_RECIPIENT_TABLE,
companyFilter,
)
result := r.DB.Exec(query, args...)
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
// GetByID gets a recipient by id
func (r *Recipient) GetByID(
ctx context.Context,
+23 -7
View File
@@ -2,6 +2,7 @@ package seed
import (
"crypto/rand"
"strings"
"github.com/go-errors/errors"
"github.com/google/uuid"
@@ -350,20 +351,35 @@ func SeedSettings(
return nil
}
// Migration approach
// Migration migrates db
func migrate(db *gorm.DB) error {
// First add column as nullable
// migration for attachments.embedded_content
// first add column as nullable
if err := db.Exec(`ALTER TABLE attachments ADD COLUMN embedded_content BOOLEAN`).Error; err != nil {
// column might already exist, ignore error
errMsg := strings.ToLower(err.Error())
if !strings.Contains(errMsg, "duplicate") && !strings.Contains(errMsg, "already exists") {
return errs.Wrap(err)
}
}
// update existing rows
if err := db.Exec(`UPDATE attachments SET embedded_content = false WHERE embedded_content IS NULL`).Error; err != nil {
return errs.Wrap(err)
}
// Update existing rows
if err := db.Exec(`UPDATE attachments SET embedded_content = false`).Error; err != nil {
return errs.Wrap(err)
// migration for email_attachments.is_inline
// first add column as nullable
if err := db.Exec(`ALTER TABLE email_attachments ADD COLUMN is_inline BOOLEAN`).Error; err != nil {
// column might already exist, ignore error
errMsg := strings.ToLower(err.Error())
if !strings.Contains(errMsg, "duplicate") && !strings.Contains(errMsg, "already exists") {
return errs.Wrap(err)
}
}
// Then make it not nullable
if err := db.Exec(`ALTER TABLE attachments MODIFY COLUMN embedded_content BOOLEAN NOT NULL DEFAULT false`).Error; err != nil {
// update existing rows - default to false (regular attachments)
if err := db.Exec(`UPDATE email_attachments SET is_inline = false WHERE is_inline IS NULL`).Error; err != nil {
return errs.Wrap(err)
}
+131 -6
View File
@@ -2129,19 +2129,82 @@ func (c *Campaign) sendCampaignMessages(
m.SetBodyString("text/html", bodyBuffer.String())
// attachments
attachments := email.Attachments
for _, attachment := range attachments {
for _, emailAttachment := range attachments {
attachment := emailAttachment.Attachment
p, err := c.MailService.AttachmentService.GetPath(attachment)
if err != nil {
return fmt.Errorf("failed to get attachment path: %s", err)
}
if !attachment.EmbeddedContent.MustGet() {
// 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
m.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)),
)
// generate custom campaign URL for attachment
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url for attachment", "error", err)
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
domain,
campaignRecipient,
&attachmentAsEmail,
nil,
customCampaignURL,
campaignCompanyID,
)
if err != nil {
return errs.Wrap(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
m.EmbedReader(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
}
} else if !attachment.EmbeddedContent.MustGet() {
// regular attachment - shows in attachment list
m.AttachFile(p.String())
} else {
// regular attachment with template processing
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
}
// hacky setup of attachment for executing as email template
// setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
@@ -2156,7 +2219,6 @@ func (c *Campaign) sendCampaignMessages(
Attachments: email.Attachments,
Company: email.Company,
}
// really hacky / unsafe
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
@@ -3716,14 +3778,77 @@ func (c *Campaign) sendSingleEmailSMTP(
// handle attachments
attachments := email.Attachments
for _, attachment := range attachments {
for _, emailAttachment := range attachments {
attachment := emailAttachment.Attachment
p, err := c.MailService.AttachmentService.GetPath(attachment)
if err != nil {
return fmt.Errorf("failed to get attachment path: %s", err)
}
if !attachment.EmbeddedContent.MustGet() {
// 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
m.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)),
)
// generate custom campaign URL for attachment
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url for attachment", "error", err)
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
domain,
campaignRecipient,
&attachmentAsEmail,
nil,
customCampaignURL,
campaignCompanyID,
)
if err != nil {
return errs.Wrap(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
m.EmbedReader(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
}
} else if !attachment.EmbeddedContent.MustGet() {
// regular attachment - shows in attachment list
m.AttachFile(p.String())
} else {
// regular attachment with template processing
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
+84 -19
View File
@@ -37,12 +37,18 @@ type Email struct {
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,
attachmentIDs []*uuid.UUID,
attachments []AttachmentWithInline,
) error {
ae := NewAuditEvent("Email.AddAttachments", session)
ae.Details["messageId"] = messageID.String()
@@ -69,13 +75,13 @@ func (m *Email) AddAttachments(
}
// add attachment to message
attachmentIdsStr := []string{}
for _, attachmentID := range attachmentIDs {
attachmentIdsStr = append(attachmentIdsStr, attachmentID.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,
attachmentID,
attachment.ID,
)
if err != nil {
m.Logger.Errorw("failed to add attachment to email", "error", err)
@@ -84,7 +90,8 @@ func (m *Email) AddAttachments(
err = m.EmailRepository.AddAttachment(
ctx,
messageID,
attachmentID,
attachment.ID,
attachment.IsInline,
)
if err != nil {
m.Logger.Errorw("failed to add attachment to email", "error", err)
@@ -640,19 +647,73 @@ func (m *Email) SendTestEmail(
msg.SetBodyString("text/html", bodyBuffer.String())
// attachments
attachments := email.Attachments
for _, attachment := range 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)
}
if !attachment.EmbeddedContent.MustGet() {
// 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)
}
// hacky setup of attachment for executing as email template
// setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
@@ -667,7 +728,6 @@ func (m *Email) SendTestEmail(
Attachments: email.Attachments,
Company: email.Company,
}
// really hacky / unsafe
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
@@ -684,7 +744,9 @@ func (m *Email) SendTestEmail(
if err != nil {
return fmt.Errorf("failed to setup attachment with embedded content: %s", err)
}
msg.AttachReadSeeker(
// 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),
)
@@ -907,25 +969,25 @@ func (m *Email) loadEmailAttachmentsWithContext(
email *model.Email,
companyID *uuid.UUID,
) error {
// get all attachment IDs associated with this email
attachmentIDs, err := m.EmailRepository.GetAttachmentIDsByEmailID(ctx, email.ID.MustGet())
// 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(attachmentIDs) == 0 {
email.Attachments = []*model.Attachment{}
if len(emailAttachments) == 0 {
email.Attachments = []*model.EmailAttachment{}
return nil
}
// get attachments with proper context filtering
contextFilteredAttachments := []*model.Attachment{}
for _, attachmentID := range attachmentIDs {
attachment, err := m.AttachmentService.AttachmentRepository.GetByID(ctx, attachmentID)
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", attachmentID, "error", err)
m.Logger.Debugw("attachment not found", "attachmentID", ea.AttachmentID, "error", err)
continue
}
@@ -947,7 +1009,10 @@ func (m *Email) loadEmailAttachmentsWithContext(
}
if isAttachmentAccessible {
contextFilteredAttachments = append(contextFilteredAttachments, attachment)
contextFilteredAttachments = append(contextFilteredAttachments, &model.EmailAttachment{
Attachment: attachment,
IsInline: ea.IsInline,
})
}
}
+24 -5
View File
@@ -149,14 +149,33 @@ func (o *Option) SetOptionByKey(
)
}
case data.OptionKeyLogLevel:
// is allow listed
fallthrough
// validate log level value
if v != "debug" && v != "info" && v != "warn" && v != "error" {
o.Logger.Debugw("invalid log level value",
"value", v,
)
return validate.WrapErrorWithField(
errs.NewValidationError(
errors.New("invalid log level"),
),
"log level",
)
}
case data.OptionKeyDBLogLevel:
// is allow listed
fallthrough
// validate db log level value
if v != "silent" && v != "info" && v != "warn" && v != "error" {
o.Logger.Debugw("invalid db log level value",
"value", v,
)
return validate.WrapErrorWithField(
errs.NewValidationError(
errors.New("invalid db log level"),
),
"db log level",
)
}
case data.OptionKeyAdminSSOLogin:
// is allow listed
fallthrough
case data.OptionKeyDisplayMode:
// validate display mode value
if v != data.OptionValueDisplayModeWhitebox && v != data.OptionValueDisplayModeBlackbox {
+49 -20
View File
@@ -437,15 +437,30 @@ func (r *Recipient) DeleteAllOrphaned(
r.AuditLogNotAuthorized(ae)
return 0, errs.ErrAuthorizationFailed
}
// delete orphaned recipients
count, err := r.RecipientRepository.DeleteAllOrphaned(
// get all orphaned recipients
orphanedRecipients, err := r.RecipientRepository.GetOrphaned(
ctx,
companyID,
&repository.RecipientOption{}, // no pagination, get all
)
if err != nil {
r.Logger.Errorw("failed to delete orphaned recipients - failed to delete orphaned recipients", "error", err)
r.Logger.Errorw("failed to get orphaned recipients", "error", err)
return 0, errs.Wrap(err)
}
// delete each orphaned recipient using core deletion logic
var count int64
for _, recipient := range orphanedRecipients.Rows {
recipientID := recipient.ID.MustGet()
err = r.deleteRecipientByID(ctx, &recipientID)
if err != nil {
r.Logger.Errorw("failed to delete orphaned recipient", "error", err, "recipientID", recipientID.String())
return count, errs.Wrap(err)
}
count++
}
ae.Details["count"] = count
r.AuditLogAuthorized(ae)
return count, nil
@@ -686,26 +701,13 @@ func getStringFromOptional(field nullable.Nullable[vo.OptionalString127]) string
return ""
}
// Delete deletes a recipient
func (r *Recipient) DeleteByID(
// deleteRecipientByID is the core deletion logic without permission checks or audit logging
func (r *Recipient) deleteRecipientByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("Recipient.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) {
r.LogAuthError(err)
return err
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// remove recipient from all groups
err = r.RecipientGroupRepository.RemoveRecipientByIDFromAllGroups(ctx, id)
err := r.RecipientGroupRepository.RemoveRecipientByIDFromAllGroups(ctx, id)
if err != nil {
r.Logger.Errorw("failed to delete recipient - failed to remove recipient from all groups",
"error", err,
@@ -758,7 +760,34 @@ func (r *Recipient) DeleteByID(
r.Logger.Errorw("failed to delete recipient - failed to delete recipient", "error", err)
return err
}
r.AuditLogAuthorized(ae)
return nil
}
// DeleteByID deletes a recipient
func (r *Recipient) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("Recipient.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) {
r.LogAuthError(err)
return err
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
err = r.deleteRecipientByID(ctx, id)
if err != nil {
return err
}
r.AuditLogAuthorized(ae)
return nil
}
+3 -3
View File
@@ -1528,12 +1528,12 @@ export class API {
* Add attachments to a email.
*
* @param {string} emailID
* @param {string[]} attachmentIDs
* @param {Array<{id: string, isInline: boolean}>} attachments - Array of attachment objects with id and isInline flag
* @returns {Promise<ApiResponse>}
*/
addAttachments: async (emailID, attachmentIDs) => {
addAttachments: async (emailID, attachments) => {
return await postJSON(this.getPath(`/email/${emailID}/attachment`), {
ids: attachmentIDs
attachments: attachments
});
},
@@ -19,6 +19,7 @@
export let domainMap = new BiMap({});
export let selectedDomain = '';
export let externalVimMode = null; // allow external control of vim mode
export let attachments = []; // array of email attachments for inline image preview
let localVimMode = externalVimMode !== null ? externalVimMode : $vimModeEnabled;
let editor = null;
let previewFrame = null;
@@ -287,6 +288,54 @@
};
const replaceTemplateVariables = async (text) => {
// replace inline image cid: references with data URLs for preview in shadow DOM
if (attachments && attachments.length > 0) {
// find all cid: references in the text
const cidRegex = /src=["']cid:([^"']+)["']/gi;
const matches = [];
let match;
while ((match = cidRegex.exec(text)) !== null) {
matches.push(match[1]);
}
// fetch and replace each cid: reference with data URL
for (const filename of matches) {
// find the attachment with this filename that is inline
const attachment = attachments.find((att) => att.isInline && att.fileName === filename);
if (attachment && attachment.id) {
try {
// fetch attachment content from API
const response = await fetch(`/api/v1/attachment/${attachment.id}/content`);
const json = await response.json();
if (json.success && json.data && json.data.file) {
// determine mime type from filename extension
const ext = filename.split('.').pop().toLowerCase();
const mimeTypes = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
bmp: 'image/bmp'
};
const mimeType = mimeTypes[ext] || 'image/png';
// create data URL from base64 data
const dataUrl = `data:${mimeType};base64,${json.data.file}`;
// replace cid: with data URL (escape filename for regex)
const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(`cid:${escapedFilename}`, 'g'), dataUrl);
}
} catch (error) {
console.error('Failed to fetch attachment for preview:', error);
}
}
}
}
let param = '?id=905f286e-486b-434b-8ecc-d82456a07f7b';
let _baseURL = `https://${baseURL}`;
let _url = `https://${baseURL}${param}`;
+11 -3
View File
@@ -54,7 +54,8 @@
mailEnvelopeFrom: null,
mailHeaderFrom: null,
mailHeaderSubject: null,
addTrackingPixel: false
addTrackingPixel: false,
attachments: []
};
let isDeleteAlertVisible = false;
let deleteValues = {
@@ -259,7 +260,8 @@
mailEnvelopeFrom: null,
mailHeaderFrom: null,
mailHeaderSubject: null,
addTrackingPixel: false
addTrackingPixel: false,
attachments: []
};
form.reset();
isModalVisible = false;
@@ -310,6 +312,7 @@
formValues.mailHeaderFrom = email.mailHeaderFrom;
formValues.mailHeaderSubject = email.mailHeaderSubject;
formValues.addTrackingPixel = email.addTrackingPixel;
formValues.attachments = email.attachments || [];
};
const openSendTestModal = async (id) => {
@@ -437,7 +440,12 @@
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting} {modalMode}>
<Editor contentType="email" {domainMap} bind:value={formValues.content}>
<Editor
contentType="email"
{domainMap}
bind:value={formValues.content}
attachments={formValues.attachments}
>
<div class="flex flex-col lg:flex-row w-full pl-4">
<div class="flex flex-col lg:flex-row justify-between w-1/3">
<TextField
@@ -12,6 +12,8 @@
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import TextFieldMultiSelect from '$lib/components/TextFieldMultiSelect.svelte';
import CheckboxField from '$lib/components/CheckboxField.svelte';
import TableCellCheck from '$lib/components/table/TableCellCheck.svelte';
import Modal from '$lib/components/Modal.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import { BiMap } from '$lib/utils/maps';
@@ -34,7 +36,8 @@
// bindings
let formValues = {
emailID: '',
attachmentIDs: []
attachmentIDs: [],
isInline: false
};
// local state
@@ -140,16 +143,19 @@
const onClickAddAttachment = async () => {
try {
isSubmitting = true;
const idsToAdd = formValues.attachmentIDs.map((id) => {
return availableAttachmentMap.byValue(id);
const attachmentsToAdd = formValues.attachmentIDs.map((id) => {
return {
id: availableAttachmentMap.byValue(id),
isInline: formValues.isInline
};
});
const res = await api.email.addAttachments($page.params.id, idsToAdd);
const res = await api.email.addAttachments($page.params.id, attachmentsToAdd);
if (!res.success) {
addError = res.error;
return;
}
addError = '';
const msg = idsToAdd.length > 1 ? 'attachments' : 'attachment';
const msg = attachmentsToAdd.length > 1 ? 'attachments' : 'attachment';
addToast(`Added ${msg}`, 'Success');
refreshData();
showAddAttachmentModal = false;
@@ -186,12 +192,14 @@
const openModal = () => {
showAddAttachmentModal = true;
formValues.attachmentIDs = [];
formValues.isInline = false;
addError = '';
};
const closeModal = () => {
showAddAttachmentModal = false;
formValues.attachmentIDs = [];
formValues.isInline = false;
addError = '';
};
</script>
@@ -201,8 +209,8 @@
<Headline>Attachments: {emailName}</Headline>
<BigButton on:click={openModal}>Add attachement</BigButton>
<Table
columns={['Name', 'Description', 'Filename']}
sortable={['Name', 'Description', 'Filename']}
columns={['Name', 'Description', 'Filename', { column: 'Inline', alignText: 'center' }]}
sortable={['Name', 'Description', 'Filename', 'Inline']}
pagination={tableParams}
hasData={!!allSelectedAttachmentsChunk}
{hasNextPage}
@@ -214,6 +222,7 @@
<TableCell value={attachment.name} />
<TableCell value={attachment.description} />
<TableCell value={attachment.fileName} />
<TableCellCheck value={attachment.isInline} />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
@@ -243,6 +252,13 @@
>Attachment
</TextFieldMultiSelect>
</FormColumn>
<FormColumn>
<CheckboxField
bind:value={formValues.isInline}
toolTipText="Inline attachments can be referenced in email HTML using cid:filename.jpg"
>Inline (for images)</CheckboxField
>
</FormColumn>
</FormColumns>
<FormError message={addError} />
<FormFooter {closeModal} {isSubmitting} />