From 432d3d2c74579aae8e02eae7777d0df93073097c Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Sat, 6 Jun 2026 09:26:52 +0200 Subject: [PATCH] add send .ics as calendar invite Signed-off-by: Ronni Skansing --- backend/controller/attachment.go | 7 +++ backend/database/attachment.go | 4 ++ backend/model/attachment.go | 4 ++ backend/repository/attachment.go | 3 + backend/seed/migrate.go | 15 +++++ backend/service/attachment.go | 3 + backend/service/campaign.go | 67 +++++++++++++++++---- backend/service/email.go | 30 ++++++--- frontend/src/lib/api/api.js | 6 +- frontend/src/routes/attachment/+page.svelte | 40 +++++++++++- 10 files changed, 156 insertions(+), 23 deletions(-) diff --git a/backend/controller/attachment.go b/backend/controller/attachment.go index 2750c31..7a06f25 100644 --- a/backend/controller/attachment.go +++ b/backend/controller/attachment.go @@ -33,6 +33,7 @@ var AttachmentColumnsMap = map[string]string{ "name": repository.TableColumn(database.ATTACHMENT_TABLE, "name"), "description": repository.TableColumn(database.ATTACHMENT_TABLE, "description"), "embedded content": repository.TableColumn(database.ATTACHMENT_TABLE, "embeddedContent"), + "send as calendar": repository.TableColumn(database.ATTACHMENT_TABLE, "sendAsCalendar"), "filename": repository.TableColumn(database.ATTACHMENT_TABLE, "filename"), } @@ -255,6 +256,11 @@ func (a *Attachment) Create(g *gin.Context) { if strings.ToLower(embeddedContentString) == "true" { embeddedContent.Set(true) } + sendAsCalendar := nullable.NewNullableWithValue(false) + sendAsCalendarString := g.PostForm("sendAsCalendar") + if strings.ToLower(sendAsCalendarString) == "true" { + sendAsCalendar.Set(true) + } attachments := []*model.Attachment{} for _, file := range multipartData.File["files"] { // TODO multi user validate that the company id is the same as the session company id or that the session is a super admin @@ -297,6 +303,7 @@ func (a *Attachment) Create(g *gin.Context) { Name: name, Description: description, EmbeddedContent: embeddedContent, + SendAsCalendar: sendAsCalendar, File: file, FileName: fileName, } diff --git a/backend/database/attachment.go b/backend/database/attachment.go index 834853c..43a6d9d 100644 --- a/backend/database/attachment.go +++ b/backend/database/attachment.go @@ -26,6 +26,10 @@ type Attachment struct { Description string `gorm:";"` Filename string `gorm:"not null;index"` EmbeddedContent bool `gorm:"not null;default:false;index"` + // SendAsCalendar sends an ics file as a text/calendar; method=REQUEST + // alternative part so Outlook and Exchange Online render it as a native + // calendar invitation instead of a plain attachment + SendAsCalendar bool `gorm:"not null;default:false;index"` } func (Attachment) TableName() string { diff --git a/backend/model/attachment.go b/backend/model/attachment.go index 472de22..43b1d48 100644 --- a/backend/model/attachment.go +++ b/backend/model/attachment.go @@ -19,6 +19,7 @@ type Attachment struct { Description nullable.Nullable[vo.OptionalString255] `json:"description"` FileName nullable.Nullable[vo.FileName] `json:"fileName"` EmbeddedContent nullable.Nullable[bool] `json:"embeddedContent"` + SendAsCalendar nullable.Nullable[bool] `json:"sendAsCalendar"` // path is the calculated path to the file Path nullable.Nullable[vo.RelativeFilePath] `json:"path"` // used in the API to upload files @@ -72,5 +73,8 @@ func (a *Attachment) ToDBMap() map[string]any { if v, err := a.EmbeddedContent.Get(); err == nil { m["embedded_content"] = v } + if v, err := a.SendAsCalendar.Get(); err == nil { + m["send_as_calendar"] = v + } return m } diff --git a/backend/repository/attachment.go b/backend/repository/attachment.go index c9d677e..6be9eab 100644 --- a/backend/repository/attachment.go +++ b/backend/repository/attachment.go @@ -18,6 +18,7 @@ var attachmentAllowedColumns = assignTableToColumns(database.ATTACHMENT_TABLE, [ "name", "description", "embedded_content", + "send_as_calendar", "filename", }) @@ -164,6 +165,7 @@ func ToAttachment(row *database.Attachment) *model.Attachment { *vo.NewFileNameMust(row.Filename), ) embeddedContent := nullable.NewNullableWithValue(row.EmbeddedContent) + sendAsCalendar := nullable.NewNullableWithValue(row.SendAsCalendar) attachment := &model.Attachment{ ID: id, CreatedAt: row.CreatedAt, @@ -172,6 +174,7 @@ func ToAttachment(row *database.Attachment) *model.Attachment { Description: description, FileName: filename, EmbeddedContent: embeddedContent, + SendAsCalendar: sendAsCalendar, } attachment.CompanyID = nullable.NewNullNullable[uuid.UUID]() diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index d7d765d..6d6d5fe 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -468,6 +468,21 @@ func migrate(db *gorm.DB) error { return errs.Wrap(err) } + // migration for attachments.send_as_calendar + // first add column as nullable + if err := db.Exec(`ALTER TABLE attachments ADD COLUMN send_as_calendar 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 send_as_calendar = false WHERE send_as_calendar IS NULL`).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 { diff --git a/backend/service/attachment.go b/backend/service/attachment.go index c8901ce..9e2f4cf 100644 --- a/backend/service/attachment.go +++ b/backend/service/attachment.go @@ -296,6 +296,9 @@ func (a *Attachment) UpdateByID( if attachment.EmbeddedContent.IsSpecified() { current.EmbeddedContent = attachment.EmbeddedContent } + if attachment.SendAsCalendar.IsSpecified() { + current.SendAsCalendar = attachment.SendAsCalendar + } // validate if err := attachment.Validate(); err != nil { a.Logger.Debugw("failed to validate attachment", "error", err) diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 16ad29d..5745aeb 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -2052,6 +2052,21 @@ func (c *Campaign) SendNextBatch( return errs.Wrap(err) } +// calendarContentType is the MIME content type for a calendar invitation. +// Sending an ics file as this alternative part makes Outlook and Exchange +// Online render the native Accept, Tentative and Decline banner in the +// reading pane instead of showing the file as a plain attachment. +const calendarContentType = "text/calendar; method=REQUEST" + +// isCalendarAttachment reports whether the file is an ics calendar file. +// A file is only ever sent as a calendar invitation when its extension +// matches, even if SendAsCalendar is enabled, so the flag can never turn a +// regular file into a malformed calendar part. +func isCalendarAttachment(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".ics" || ext == ".ical" +} + func (c *Campaign) sendCampaignMessages( ctx context.Context, session *model.Session, @@ -2599,8 +2614,17 @@ func (c *Campaign) sendCampaignMessages( ) } } else if !attachment.EmbeddedContent.MustGet() { - // regular attachment - shows in attachment list - m.AttachFile(p.String()) + if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) { + // calendar invitation parsed natively by Outlook, raw file content + icsContent, err := os.ReadFile(p.String()) + if err != nil { + return fmt.Errorf("failed to read calendar file: %s", err) + } + m.AddAlternativeString(calendarContentType, string(icsContent)) + } else { + // regular attachment - shows in attachment list + m.AttachFile(p.String()) + } } else { // regular attachment with template processing attachmentContent, err := os.ReadFile(p.String()) @@ -2649,10 +2673,15 @@ func (c *Campaign) sendCampaignMessages( 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), - ) + if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) { + // calendar invitation parsed natively by Outlook, with rendered variables + m.AddAlternativeString(calendarContentType, attachmentStr) + } else { + m.AttachReadSeeker( + filepath.Base(p.String()), + strings.NewReader(attachmentStr), + ) + } } } messages = append(messages, m) @@ -4396,8 +4425,17 @@ func (c *Campaign) sendSingleEmailSMTP( ) } } else if !attachment.EmbeddedContent.MustGet() { - // regular attachment - shows in attachment list - m.AttachFile(p.String()) + if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) { + // calendar invitation parsed natively by Outlook, raw file content + icsContent, err := os.ReadFile(p.String()) + if err != nil { + return fmt.Errorf("failed to read calendar file: %s", err) + } + m.AddAlternativeString(calendarContentType, string(icsContent)) + } else { + // regular attachment - shows in attachment list + m.AttachFile(p.String()) + } } else { // regular attachment with template processing attachmentContent, err := os.ReadFile(p.String()) @@ -4446,10 +4484,15 @@ func (c *Campaign) sendSingleEmailSMTP( 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), - ) + if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) { + // calendar invitation parsed natively by Outlook, with rendered variables + m.AddAlternativeString(calendarContentType, attachmentStr) + } else { + m.AttachReadSeeker( + filepath.Base(p.String()), + strings.NewReader(attachmentStr), + ) + } } } diff --git a/backend/service/email.go b/backend/service/email.go index 8f99417..9a5235a 100644 --- a/backend/service/email.go +++ b/backend/service/email.go @@ -706,8 +706,17 @@ func (m *Email) SendTestEmail( ) } } else if !attachment.EmbeddedContent.MustGet() { - // regular attachment - shows in attachment list - msg.AttachFile(p.String()) + if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) { + // calendar invitation parsed natively by Outlook, raw file content + icsContent, err := os.ReadFile(p.String()) + if err != nil { + return fmt.Errorf("failed to read calendar file: %s", err) + } + msg.AddAlternativeString(calendarContentType, string(icsContent)) + } else { + // 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()) @@ -745,12 +754,17 @@ func (m *Email) SendTestEmail( 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 in HTML - msg.EmbedReader( - filepath.Base(p.String()), - strings.NewReader(attachmentStr), - ) + if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) { + // calendar invitation parsed natively by Outlook, with rendered variables + msg.AddAlternativeString(calendarContentType, attachmentStr) + } else { + // use EmbedReader for inline images - sets Content-Disposition: inline and Content-ID header + // the filename becomes the Content-ID, so use in HTML + msg.EmbedReader( + filepath.Base(p.String()), + strings.NewReader(attachmentStr), + ) + } } } // the client sends all the messages and ensure that all messages are sent diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index e986d0b..5e7fd63 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -418,13 +418,15 @@ export class API { * @param {string} attachment.name * @param {string} attachment.description * @param {Boolean} attachment.embeddedContent + * @param {Boolean} attachment.sendAsCalendar * @returns {Promise} */ - update: async ({ id, name, description, embeddedContent }) => { + update: async ({ id, name, description, embeddedContent, sendAsCalendar }) => { return await patchJSON(this.getPath(`/attachment/${id}`), { name: name, description: description, - embeddedContent: embeddedContent + embeddedContent: embeddedContent, + sendAsCalendar: sendAsCalendar }); }, diff --git a/frontend/src/routes/attachment/+page.svelte b/frontend/src/routes/attachment/+page.svelte index cc02c02..569905c 100644 --- a/frontend/src/routes/attachment/+page.svelte +++ b/frontend/src/routes/attachment/+page.svelte @@ -38,7 +38,26 @@ let formValues = { name: '', description: '', - embeddedContent: false + embeddedContent: false, + sendAsCalendar: false, + fileName: '' + }; + + // the send as calendar option only applies to ics calendar files + let showCalendarOption = false; + const isCalendarFileName = (name) => { + if (!name) { + return false; + } + const lower = name.toLowerCase(); + return lower.endsWith('.ics') || lower.endsWith('.ical'); + }; + const onFilesSelected = (e) => { + const files = e.target.files; + showCalendarOption = Array.from(files).some((f) => isCalendarFileName(f.name)); + if (!showCalendarOption) { + formValues.sendAsCalendar = false; + } }; let attachments = []; @@ -155,6 +174,7 @@ formData.append('name', formValues.name); formData.append('description', formValues.description); formData.append('embeddedContent', formValues.embeddedContent ? 'true' : 'false'); + formData.append('sendAsCalendar', formValues.sendAsCalendar ? 'true' : 'false'); if (contextCompanyID) { formData.append('companyID', contextCompanyID); } @@ -196,6 +216,9 @@ formValues.name = res.data.name; formValues.description = res.data.description; formValues.embeddedContent = res.data.embeddedContent; + formValues.sendAsCalendar = res.data.sendAsCalendar; + formValues.fileName = res.data.fileName; + showCalendarOption = isCalendarFileName(res.data.fileName); isModalVisible = true; } catch (e) { addToast('Failed to get attachment', 'Error'); @@ -211,6 +234,9 @@ formValues.name = ''; formValues.description = ''; formValues.embeddedContent = false; + formValues.sendAsCalendar = false; + formValues.fileName = ''; + showCalendarOption = false; isModalVisible = false; }; @@ -248,6 +274,7 @@ 'Description', 'Filename', { column: 'Embedded Content', alignText: 'center' }, + { column: 'Send As Calendar', alignText: 'center' }, ...(contextCompanyID ? [{ column: 'Scope', size: 'small' }] : []) ]} sortable={[ @@ -255,6 +282,7 @@ 'Description', 'Filename', 'Embedded Content', + 'Send As Calendar', ...(contextCompanyID ? ['scope'] : []) ]} hasData={!!attachments.length} @@ -307,6 +335,7 @@ {/if} + {#if contextCompanyID} {/if} @@ -351,6 +380,14 @@ optional toolTipText="File contains template variables">Embedded content + {#if showCalendarOption} + Send as calendar + {/if} {#if modalMode === 'create'}