From c75ea49f1c989179d2c4b6503e29b515d6804e08 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Wed, 17 Sep 2025 22:16:04 +0200 Subject: [PATCH] recipient manual send action Signed-off-by: Ronni Skansing --- backend/app/administration.go | 8 +- backend/controller/campaign.go | 21 + backend/service/campaign.go | 441 ++++++++++++++++++ frontend/src/lib/api/api.js | 10 + .../src/routes/campaign/[id]/+page.svelte | 90 ++++ 5 files changed, 567 insertions(+), 3 deletions(-) diff --git a/backend/app/administration.go b/backend/app/administration.go index 5a37b1a..87b8444 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -134,9 +134,10 @@ const ( ROUTE_V1_CAMPAIGN_STATS_ALL = "/api/v1/campaign/stats/all" ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported" // campaign-recipient - ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email" - ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url" - ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent" + ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email" + ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url" + ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent" + ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL = "/api/v1/campaign/recipient/:id/send" // asset ROUTE_V1_ASSET = "/api/v1/asset" ROUTE_V1_ASSET_ID = "/api/v1/asset/:id" @@ -373,6 +374,7 @@ func setupRoutes( GET(ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL, middleware.SessionHandler, controllers.Campaign.GetCampaignEmail). GET(ROUTE_V1_CAMPAIGN_RECIPIENT_URL, middleware.SessionHandler, controllers.Campaign.GetCampaignURL). POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT, middleware.SessionHandler, controllers.Campaign.SetSentAtByCampaignRecipientID). + POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL, middleware.SessionHandler, controllers.Campaign.SendEmailByCampaignRecipientID). // asset GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID). GET(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.GetByID). diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index ee2c6cc..f5a379a 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -854,6 +854,27 @@ func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) { c.Response.OK(g, gin.H{}) } +// SendEmailByCampaignRecipientID sends an email to a specific campaign recipient +func (c *Campaign) SendEmailByCampaignRecipientID(g *gin.Context) { + // handle session + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse request + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + // send email + err := c.CampaignService.SendEmailByCampaignRecipientID(g.Request.Context(), session, id) + // handle responses + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + // DeleteByID deletes a campaign by its id func (c *Campaign) DeleteByID(g *gin.Context) { // handle session diff --git a/backend/service/campaign.go b/backend/service/campaign.go index c2398c0..935df1e 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -2961,6 +2961,447 @@ func (c *Campaign) AnonymizeByID( return nil } +// SendEmailByCampaignRecipientID sends an email to a specific campaign recipient +// Multiple sends to the same recipient are allowed to support retry scenarios and follow-ups. +func (c *Campaign) SendEmailByCampaignRecipientID( + ctx context.Context, + session *model.Session, + campaignRecipientID *uuid.UUID, +) error { + ae := NewAuditEvent("Campaign.SendEmailByCampaignRecipientID", session) + ae.Details["campaignRecipientId"] = campaignRecipientID.String() + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + + // get campaign recipient + campaignRecipient, err := c.CampaignRecipientRepository.GetByID( + ctx, + campaignRecipientID, + &repository.CampaignRecipientOption{ + WithRecipient: true, + WithCampaign: true, + }, + ) + if err != nil { + c.Logger.Errorw("failed to get campaign recipient by id", "error", err) + return errs.Wrap(err) + } + + campaign := campaignRecipient.Campaign + if campaign == nil { + return errors.New("campaign recipient has no campaign loaded") + } + + // check if campaign is active + if !campaign.IsActive() { + return errors.New("campaign is not active") + } + + // check if recipient exists (not anonymized) + if campaignRecipient.Recipient == nil { + return errors.New("recipient is anonymized or deleted") + } + + // check if cancelled + if !campaignRecipient.CancelledAt.IsNull() { + return errors.New("recipient has been cancelled") + } + + campaignID := campaign.ID.MustGet() + + // add resend information to audit log + isResend := !campaignRecipient.SentAt.IsNull() + ae.Details["isResend"] = isResend + if isResend { + ae.Details["previouslySentAt"] = campaignRecipient.SentAt.MustGet().Format(time.RFC3339) + } + + // send the email using existing logic from sendCampaignMessages + err = c.sendSingleCampaignMessage(ctx, session, &campaignID, campaignRecipient) + if err != nil { + c.Logger.Errorw("failed to send campaign message", "error", err) + return errs.Wrap(err) + } + + c.AuditLogAuthorized(ae) + return nil +} + +// sendSingleCampaignMessage sends an email to a single campaign recipient +func (c *Campaign) sendSingleCampaignMessage( + ctx context.Context, + session *model.Session, + campaignID *uuid.UUID, + campaignRecipient *model.CampaignRecipient, +) error { + // get campaign template details - similar logic from sendCampaignMessages + campaign, err := c.CampaignRepository.GetByID( + ctx, + campaignID, + &repository.CampaignOption{}, + ) + if err != nil { + c.Logger.Errorw("failed to get campaign by id", "error", err) + return errs.Wrap(err) + } + + templateID, err := campaign.TemplateID.Get() + if err != nil { + return errors.New("campaign has no template") + } + + cTemplate, err := c.CampaignTemplateService.GetByID( + ctx, + session, + &templateID, + &repository.CampaignTemplateOption{ + WithDomain: true, + WithSMTPConfiguration: true, + WithIdentifier: true, + }, + ) + if err != nil { + c.Logger.Errorw("failed to get campaign template by id", "error", err) + return errs.Wrap(err) + } + + // check domain + domain := cTemplate.Domain + if domain == nil { + return errors.New("campaign template has no domain") + } + + // get email details + emailID, err := cTemplate.EmailID.Get() + if err != nil { + return errors.New("campaign template has no email") + } + + email, err := c.MailService.GetByID(ctx, session, &emailID) + if err != nil { + c.Logger.Errorw("failed to get email by id", "error", err) + return errs.Wrap(err) + } + + // update last attempt timestamp + campaignRecipientID := campaignRecipient.ID.MustGet() + campaignRecipient.LastAttemptAt = nullable.NewNullableWithValue(time.Now()) + err = c.CampaignRecipientRepository.UpdateByID(ctx, &campaignRecipientID, campaignRecipient) + if err != nil { + c.Logger.Errorw("failed to update last attempted at", "error", err) + return errs.Wrap(err) + } + + // prepare template for rendering + content, err := email.Content.Get() + if err != nil { + return errors.New("failed to get email content") + } + + t := template.New("email") + t = t.Funcs(TemplateFuncs()) + mailTmpl, err := t.Parse(content.String()) + if err != nil { + return errs.Wrap(err) + } + + // check sending method + isSmtpCampaign := cTemplate.SMTPConfigurationID.IsSpecified() && !cTemplate.SMTPConfigurationID.IsNull() + isAPISenderCampaign := cTemplate.APISenderID.IsSpecified() && !cTemplate.APISenderID.IsNull() + + if !isSmtpCampaign && !isAPISenderCampaign { + return errors.New("campaign template has no SMTP configuration or API sender") + } + + if isAPISenderCampaign { + // send via API + err = c.APISenderService.Send( + ctx, + session, + cTemplate, + campaignRecipient, + domain, + mailTmpl, + email, + ) + } else { + // send via SMTP + err = c.sendSingleEmailSMTP(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email) + } + + // save sending result + saveErr := c.saveSendingResult(ctx, campaignRecipient, err) + if saveErr != nil { + c.Logger.Errorw("failed to save sending result", "error", saveErr) + return errs.Wrap(saveErr) + } + + return err +} + +// sendSingleEmailSMTP sends an email to a single recipient via SMTP +func (c *Campaign) sendSingleEmailSMTP( + ctx context.Context, + session *model.Session, + cTemplate *model.CampaignTemplate, + campaignRecipient *model.CampaignRecipient, + domain *model.Domain, + mailTmpl *template.Template, + email *model.Email, +) error { + // get SMTP configuration + smtpConfigID, err := cTemplate.SMTPConfigurationID.Get() + if err != nil { + return errors.New("failed to get SMTP configuration from template") + } + + smtpConfig, err := c.SMTPConfigService.GetByID( + ctx, + session, // use the actual session passed to the method + &smtpConfigID, + &repository.SMTPConfigurationOption{ + WithHeaders: true, + }, + ) + if err != nil { + c.Logger.Errorw("smtp configuration did not load", "error", err) + return errs.Wrap(err) + } + + smtpPort, err := smtpConfig.Port.Get() + if err != nil { + return errs.Wrap(err) + } + + smtpHost, err := smtpConfig.Host.Get() + if err != nil { + return errs.Wrap(err) + } + + smtpIgnoreCertErrors, err := smtpConfig.IgnoreCertErrors.Get() + if err != nil { + return errs.Wrap(err) + } + + // setup SMTP client options + emailOptions := []mail.Option{ + mail.WithPort(smtpPort.Int()), + mail.WithTLSConfig( + &tls.Config{ + ServerName: smtpHost.String(), + InsecureSkipVerify: smtpIgnoreCertErrors, + }, + ), + } + + // setup authentication if provided + username, err := smtpConfig.Username.Get() + if err != nil { + return errs.Wrap(err) + } + password, err := smtpConfig.Password.Get() + if err != nil { + 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)) + } + } + + // create message + messageOptions := []mail.MsgOption{ + mail.WithNoDefaultUserAgent(), + } + m := mail.NewMsg(messageOptions...) + + // set envelope from + err = m.EnvelopeFrom(email.MailEnvelopeFrom.MustGet().String()) + if err != nil { + c.Logger.Errorw("failed to set envelope from", "error", err) + return errs.Wrap(err) + } + + // set headers + err = m.From(email.MailHeaderFrom.MustGet().String()) + if err != nil { + c.Logger.Errorw("failed to set mail header 'From'", "error", err) + return errs.Wrap(err) + } + + recpEmail := campaignRecipient.Recipient.Email.MustGet().String() + err = m.To(recpEmail) + if err != nil { + c.Logger.Errorw("failed to set mail header 'To'", "error", err) + return errs.Wrap(err) + } + + // custom headers + if headers := smtpConfig.Headers; headers != nil { + for _, header := range headers { + key := header.Key.MustGet() + value := header.Value.MustGet() + m.SetGenHeader( + mail.Header(key.String()), + value.String(), + ) + } + } + + m.Subject(email.MailHeaderSubject.MustGet().String()) + + // setup template variables + domainName, err := domain.Name.Get() + if err != nil { + return errs.Wrap(err) + } + + urlIdentifier := cTemplate.URLIdentifier + if urlIdentifier == nil { + return errors.New("url identifier must be loaded for the campaign template") + } + + urlPath := cTemplate.URLPath.MustGet().String() + t := c.TemplateService.CreateMail( + domainName.String(), + urlIdentifier.Name.MustGet(), + urlPath, + campaignRecipient, + email, + nil, + ) + + err = m.SetBodyHTMLTemplate(mailTmpl, t) + if err != nil { + c.Logger.Errorw("failed to set body html template", "error", err) + return errs.Wrap(err) + } + + // handle attachments + attachments := email.Attachments + for _, attachment := range attachments { + p, err := c.MailService.AttachmentService.GetPath(attachment) + if err != nil { + return fmt.Errorf("failed to get attachment path: %s", err) + } + if !attachment.EmbeddedContent.MustGet() { + m.AttachFile(p.String()) + } else { + 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 := c.TemplateService.CreateMailBody( + urlIdentifier.Name.MustGet(), + urlPath, + domain, + campaignRecipient, + &attachmentAsEmail, + nil, + ) + if err != nil { + return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err)) + } + m.AttachReadSeeker( + filepath.Base(p.String()), + strings.NewReader(attachmentStr), + ) + } + } + + // send the email + var mc *mail.Client + + // try different authentication methods + 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...) + mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger)) + mc.SetDebugLog(true) + if build.Flags.Production { + mc.SetTLSPolicy(mail.TLSMandatory) + } else { + mc.SetTLSPolicy(mail.TLSOpportunistic) + } + err = mc.DialAndSendWithContext(ctx, m) + + // 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")) { + c.Logger.Debugf("CRAM-MD5 authentication failed, trying PLAIN auth", "error", err) + emailOptionsBasic := emailOptions + emailOptionsBasic = append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthPlain)) + mc, _ = mail.NewClient(smtpHost.String(), emailOptionsBasic...) + mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger)) + mc.SetDebugLog(true) + if build.Flags.Production { + mc.SetTLSPolicy(mail.TLSMandatory) + } else { + mc.SetTLSPolicy(mail.TLSOpportunistic) + } + err = mc.DialAndSendWithContext(ctx, m) + } + } else { + // no credentials provided, try without authentication + mc, _ = mail.NewClient(smtpHost.String(), emailOptions...) + mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger)) + mc.SetDebugLog(true) + if build.Flags.Production { + mc.SetTLSPolicy(mail.TLSMandatory) + } else { + mc.SetTLSPolicy(mail.TLSOpportunistic) + } + err = mc.DialAndSendWithContext(ctx, m) + + // 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")) { + c.Logger.Warnw("Server requires authentication but no credentials provided", "error", err) + } + } + + if err != nil { + c.Logger.Errorw("failed to send email", "error", err) + return errs.Wrap(err) + } + + return nil +} + // SetNotableCampaignEvent checks and update if most notable event for a campaign func (c *Campaign) setMostNotableCampaignEvent( ctx context.Context, diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index ed08fdb..a196582 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -689,6 +689,16 @@ export class API { return await postJSON(this.getPath(`/campaign/recipient/${campaignRecipient}/sent`)); }, + /** + * Send email to campaign recipient + * + * @param {string} campaignRecipientID + * @returns {Promise} + */ + sendEmail: async (campaignRecipientID) => { + return await postJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/send`)); + }, + /** * Get campaign recipient landingpage URL. * diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte index aae61c1..323a92f 100644 --- a/frontend/src/routes/campaign/[id]/+page.svelte +++ b/frontend/src/routes/campaign/[id]/+page.svelte @@ -121,6 +121,8 @@ let isRecipientTableLoading = false; let isCloseModalVisible = false; let isAnonymizeModalVisible = false; + let isSendEmailModalVisible = false; + let sendEmailRecipient = null; let lastPoll3399Nano = ''; // hooks @@ -489,6 +491,50 @@ } }; + /** @param {string} campaignRecipientID @param {Object} recipient */ + const showSendEmailModal = (campaignRecipientID, recipient) => { + sendEmailRecipient = { + id: campaignRecipientID, + name: `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(), + email: recipient.email + }; + isSendEmailModalVisible = true; + }; + + const closeSendEmailModal = () => { + isSendEmailModalVisible = false; + sendEmailRecipient = null; + }; + + const onConfirmSendEmail = async () => { + try { + showIsLoading(); + // Check if this is a resend before sending + const isResend = campaignRecipients.find((r) => r.id === sendEmailRecipient.id)?.sentAt; + const res = await api.campaign.sendEmail(sendEmailRecipient.id); + if (!res.success) { + throw res.error; + } + const campaignType = isSelfManaged ? 'self-managed' : 'scheduled'; + const message = isResend ? `Email sent again successfully` : `Email sent successfully`; + addToast(message, 'Success'); + await setCampaign(); + await getEvents(); + await refreshCampaignRecipients(); + closeSendEmailModal(); + } catch (e) { + addToast('Failed to send email', 'Error'); + console.error('failed to send email', e); + } finally { + hideIsLoading(); + } + }; + + // reactive statement to clean up send email modal state when it closes + $: if (!isSendEmailModalVisible && sendEmailRecipient) { + sendEmailRecipient = null; + } + const showCloseCampaignModal = () => { isCloseModalVisible = true; }; @@ -1412,6 +1458,16 @@ disabled={!recp.recipient} on:click={() => openEventsModal(recp.recipientID)} /> + showSendEmailModal(recp.id, recp.recipient)} + disabled={!!campaign.closedAt || recp.cancelledAt} + /> {#if !campaign.sendStartAt} + + +
+ {#if sendEmailRecipient} + {@const recipient = campaignRecipients.find((r) => r.id === sendEmailRecipient.id)} + {#if recipient} +

+ {recipient.sentAt + ? 'Are you sure you want to send the campaign email again to:' + : 'Are you sure you want to send the campaign email to:'} +

+
+

{sendEmailRecipient.name}

+

{sendEmailRecipient.email}

+

+ Campaign type: {isSelfManaged ? 'Self-managed' : 'Scheduled'} +

+ {#if recipient.sentAt} +

+ ⚠️ Previously sent on {new Date(recipient.sentAt).toLocaleString()} +

+ {/if} +
+

+ This action will immediately send the email and cannot be undone. +

+ {/if} + {/if} +
+