From 072be0caf97c4eda54660df2cfb97828e07118f5 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 00:33:50 -0800 Subject: [PATCH 01/28] Added Attachment to Message entity --- api/pkg/entities/message.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/api/pkg/entities/message.go b/api/pkg/entities/message.go index bf846c0b..eb71f51b 100644 --- a/api/pkg/entities/message.go +++ b/api/pkg/entities/message.go @@ -81,17 +81,23 @@ func (s SIM) String() string { return string(s) } +type MessageAttachment struct { + ContentType string `json:"content_type" example:"image/jpeg"` + URL string `json:"url" example:"https://example.com/image.jpg"` +} + // Message represents a message sent between 2 phone numbers type Message struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` - RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"` - Owner string `json:"owner" example:"+18005550199"` - UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` - Contact string `json:"contact" example:"+18005550100"` - Content string `json:"content" example:"This is a sample text message"` - Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"` - Type MessageType `json:"type" example:"mobile-terminated"` - Status MessageStatus `json:"status" example:"pending"` + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"` + Owner string `json:"owner" example:"+18005550199"` + UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` + Contact string `json:"contact" example:"+18005550100"` + Content string `json:"content" example:"This is a sample text message"` + Attachments []MessageAttachment `json:"attachments,omitempty" gorm:"type:json;serializer:json"` + Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"` + Type MessageType `json:"type" example:"mobile-terminated"` + Status MessageStatus `json:"status" example:"pending"` // SIM is the SIM card to use to send the message // * SMS1: use the SIM card in slot 1 // * SMS2: use the SIM card in slot 2 From 09cf30b4812b65141df74c07dd2ab557dbc25030 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 00:36:53 -0800 Subject: [PATCH 02/28] Added Attachments to APISentPayload & send request --- api/pkg/events/message_api_sent_event.go | 23 ++++++++++++----------- api/pkg/requests/message_send_request.go | 8 +++++--- api/pkg/services/message_service.go | 3 +++ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/api/pkg/events/message_api_sent_event.go b/api/pkg/events/message_api_sent_event.go index 7abea843..9aeabd90 100644 --- a/api/pkg/events/message_api_sent_event.go +++ b/api/pkg/events/message_api_sent_event.go @@ -13,15 +13,16 @@ const EventTypeMessageAPISent = "message.api.sent" // MessageAPISentPayload is the payload of the EventTypeMessageSent event type MessageAPISentPayload struct { - MessageID uuid.UUID `json:"message_id"` - UserID entities.UserID `json:"user_id"` - Owner string `json:"owner"` - RequestID *string `json:"request_id"` - MaxSendAttempts uint `json:"max_send_attempts"` - Contact string `json:"contact"` - ScheduledSendTime *time.Time `json:"scheduled_send_time"` - RequestReceivedAt time.Time `json:"request_received_at"` - Content string `json:"content"` - Encrypted bool `json:"encrypted"` - SIM entities.SIM `json:"sim"` + MessageID uuid.UUID `json:"message_id"` + UserID entities.UserID `json:"user_id"` + Owner string `json:"owner"` + RequestID *string `json:"request_id"` + MaxSendAttempts uint `json:"max_send_attempts"` + Contact string `json:"contact"` + ScheduledSendTime *time.Time `json:"scheduled_send_time"` + RequestReceivedAt time.Time `json:"request_received_at"` + Content string `json:"content"` + Attachments []entities.MessageAttachment `json:"attachments"` + Encrypted bool `json:"encrypted"` + SIM entities.SIM `json:"sim"` } diff --git a/api/pkg/requests/message_send_request.go b/api/pkg/requests/message_send_request.go index 3301691d..1a1d6830 100644 --- a/api/pkg/requests/message_send_request.go +++ b/api/pkg/requests/message_send_request.go @@ -14,9 +14,10 @@ import ( // MessageSend is the payload for sending and SMS message type MessageSend struct { request - From string `json:"from" example:"+18005550199"` - To string `json:"to" example:"+18005550100"` - Content string `json:"content" example:"This is a sample text message"` + From string `json:"from" example:"+18005550199"` + To string `json:"to" example:"+18005550100"` + Content string `json:"content" example:"This is a sample text message"` + Attachments []entities.MessageAttachment `json:"attachments" validate:"optional"` // Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app Encrypted bool `json:"encrypted" example:"false" validate:"optional"` @@ -47,5 +48,6 @@ func (input *MessageSend) ToMessageSendParams(userID entities.UserID, source str RequestReceivedAt: time.Now().UTC(), Contact: input.sanitizeAddress(input.To), Content: input.Content, + Attachments: input.Attachments, } } diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 5a95b265..eca2334c 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -430,6 +430,7 @@ type MessageSendParams struct { Contact string Encrypted bool Content string + Attachments []entities.MessageAttachment Source string SendAt *time.Time RequestID *string @@ -456,6 +457,7 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe Contact: params.Contact, RequestReceivedAt: params.RequestReceivedAt, Content: params.Content, + Attachments: params.Attachments, ScheduledSendTime: params.SendAt, SIM: sim, } @@ -968,6 +970,7 @@ func (service *MessageService) storeSentMessage(ctx context.Context, payload eve Contact: payload.Contact, UserID: payload.UserID, Content: payload.Content, + Attachments: payload.Attachments, RequestID: payload.RequestID, SIM: payload.SIM, Encrypted: payload.Encrypted, From 69ecce11ff420d331765e500ad88f83d56df1e42 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 00:45:08 -0800 Subject: [PATCH 03/28] Added validator for attachment urls --- .../validators/message_handler_validator.go | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index 33ab4dfe..fd690e26 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -106,6 +106,28 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context return result } + if len(request.Attachments) > 10 { + result.Add("attachments", "you cannot attach more than 10 files to a single message") + } + + for i, attachment := range request.Attachments { + if strings.TrimSpace(attachment.ContentType) == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d is missing content_type", i)) + } + + if strings.TrimSpace(attachment.URL) == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d is missing url", i)) + } else { + // Basic URL validation + parsedURL, err := url.ParseRequestURI(attachment.URL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d has an invalid url format", i)) + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + result.Add("attachments", fmt.Sprintf("attachment at index %d must use http or https scheme", i)) + } + } + } + if request.SendAt != nil && request.SendAt.After(time.Now().Add(480*time.Hour)) { result.Add("send_at", "the scheduled time cannot be more than 20 days (480 hours) in the future") } From b5bfdb45587b6a5d595c397288f21d84d37767bb Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 00:45:48 -0800 Subject: [PATCH 04/28] Added attachment to bulk message struct --- api/pkg/requests/message_bulk_send_request.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/pkg/requests/message_bulk_send_request.go b/api/pkg/requests/message_bulk_send_request.go index a21570bb..47c2af7d 100644 --- a/api/pkg/requests/message_bulk_send_request.go +++ b/api/pkg/requests/message_bulk_send_request.go @@ -16,6 +16,7 @@ type MessageBulkSend struct { From string `json:"from" example:"+18005550199"` To []string `json:"to" example:"+18005550100,+18005550100"` Content string `json:"content" example:"This is a sample text message"` + Attachments []entities.MessageAttachment `json:"attachments" validate:"optional"` // Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app Encrypted bool `json:"encrypted" example:"false"` @@ -52,6 +53,7 @@ func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source Contact: to, SendAt: &sendAt, Content: input.Content, + Attachments: input.Attachments, }) } From 53112cbf3d32149b38975e6f873c6e1dc63fa70d Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 00:47:15 -0800 Subject: [PATCH 05/28] Added same validation to bulk message --- .../validators/message_handler_validator.go | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index fd690e26..20a8ed9f 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -114,11 +114,10 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context if strings.TrimSpace(attachment.ContentType) == "" { result.Add("attachments", fmt.Sprintf("attachment at index %d is missing content_type", i)) } - + if strings.TrimSpace(attachment.URL) == "" { result.Add("attachments", fmt.Sprintf("attachment at index %d is missing url", i)) } else { - // Basic URL validation parsedURL, err := url.ParseRequestURI(attachment.URL) if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { result.Add("attachments", fmt.Sprintf("attachment at index %d has an invalid url format", i)) @@ -178,6 +177,27 @@ func (validator MessageHandlerValidator) ValidateMessageBulkSend(ctx context.Con return result } + if len(request.Attachments) > 10 { + result.Add("attachments", "you cannot attach more than 10 files to a single message") + } + + for i, attachment := range request.Attachments { + if strings.TrimSpace(attachment.ContentType) == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d is missing content_type", i)) + } + + if strings.TrimSpace(attachment.URL) == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d is missing url", i)) + } else { + parsedURL, err := url.ParseRequestURI(attachment.URL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d has an invalid url format", i)) + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + result.Add("attachments", fmt.Sprintf("attachment at index %d must use http or https scheme", i)) + } + } + } + _, err := validator.phoneService.Load(ctx, userID, request.From) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { result.Add("from", fmt.Sprintf("no phone found with with 'from' number [%s]. Install the android app on your phone to start sending messages", request.From)) From 34b2cfb5d67ff3913731b2eabf955e520c4b088e Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 00:54:09 -0800 Subject: [PATCH 06/28] Added attachmenturls to the BulkMessage struct for csv support and a basic file type check --- api/pkg/requests/bulk_message_request.go | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/api/pkg/requests/bulk_message_request.go b/api/pkg/requests/bulk_message_request.go index ffb3f35c..0ab6c024 100644 --- a/api/pkg/requests/bulk_message_request.go +++ b/api/pkg/requests/bulk_message_request.go @@ -18,6 +18,7 @@ type BulkMessage struct { ToPhoneNumber string `csv:"ToPhoneNumber"` Content string `csv:"Content"` SendTime *time.Time `csv:"SendTime(optional)"` + AttachmentURLs string `csv:"AttachmentURLs(optional)" validate:"optional"` // Comma separated list of URLs } // Sanitize sets defaults to BulkMessage @@ -25,12 +26,43 @@ func (input *BulkMessage) Sanitize() *BulkMessage { input.ToPhoneNumber = input.sanitizeAddress(input.ToPhoneNumber) input.Content = strings.TrimSpace(input.Content) input.FromPhoneNumber = input.sanitizeAddress(input.FromPhoneNumber) + input.AttachmentURLs = strings.TrimSpace(input.AttachmentURLs) return input } // ToMessageSendParams converts BulkMessage to services.MessageSendParams func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID uuid.UUID, source string) services.MessageSendParams { from, _ := phonenumbers.Parse(input.FromPhoneNumber, phonenumbers.UNKNOWN_REGION) + + var attachments []entities.MessageAttachment + if input.AttachmentURLs != "" { + urls := strings.Split(input.AttachmentURLs, ",") + for _, u := range urls { + cleanURL := strings.TrimSpace(u) + if cleanURL == "" { + continue + } + + // Since there's no easy way to set a type in the CSV, defaulting to octet-stream and then just checking the file extension in the URL + contentType := "application/octet-stream" + lowerURL := strings.ToLower(cleanURL) + if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") { + contentType = "image/jpeg" + } else if strings.HasSuffix(lowerURL, ".png") { + contentType = "image/png" + } else if strings.HasSuffix(lowerURL, ".gif") { + contentType = "image/gif" + } else if strings.HasSuffix(lowerURL, ".mp4") { + contentType = "video/mp4" + } + + attachments = append(attachments, entities.MessageAttachment{ + ContentType: contentType, + URL: cleanURL, + }) + } + } + return services.MessageSendParams{ Source: source, Owner: from, @@ -40,5 +72,6 @@ func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID RequestReceivedAt: time.Now().UTC(), Contact: input.sanitizeAddress(input.ToPhoneNumber), Content: input.Content, + Attachments: attachments, } } From bc5faf1c42ccf218378aaebfe63f04ae86940b97 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 00:56:01 -0800 Subject: [PATCH 07/28] Added attachment parsing to the xlsx parser --- api/pkg/validators/bulk_message_handler_validator.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go index 9881c53f..acabcbba 100644 --- a/api/pkg/validators/bulk_message_handler_validator.go +++ b/api/pkg/validators/bulk_message_handler_validator.go @@ -143,11 +143,17 @@ func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user } } + var attachmentURLs string + if len(row) > 4 && strings.TrimSpace(row[4]) != "" { + attachmentURLs = strings.TrimSpace(row[4]) + } + messages = append(messages, &requests.BulkMessage{ FromPhoneNumber: strings.TrimSpace(row[0]), ToPhoneNumber: strings.TrimSpace(row[1]), Content: row[2], SendTime: sendAt, + AttachmentURLs: attachmentURLs, }) } From 84974984f017cc03fa4af9db1d90aed19e720d39 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 00:59:10 -0800 Subject: [PATCH 08/28] Added validation to csv based bulk messages --- .../bulk_message_handler_validator.go | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go index acabcbba..84844392 100644 --- a/api/pkg/validators/bulk_message_handler_validator.go +++ b/api/pkg/validators/bulk_message_handler_validator.go @@ -218,6 +218,29 @@ func (v *BulkMessageHandlerValidator) parseCSV(ctxLogger telemetry.Logger, user func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.BulkMessage) url.Values { result := url.Values{} for index, message := range messages { + + if message.AttachmentURLs != "" { + urls := strings.Split(message.AttachmentURLs, ",") + + if len(urls) > 10 { + result.Add("document", fmt.Sprintf("Row [%d]: You cannot attach more than 10 files per message.", index+2)) + } + + for _, u := range urls { + cleanURL := strings.TrimSpace(u) + if cleanURL == "" { + continue + } + + parsedURL, err := url.ParseRequestURI(cleanURL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] has an invalid url format.", index+2, cleanURL)) + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] must use http or https.", index+2, cleanURL)) + } + } + } + if _, err := phonenumbers.Parse(message.FromPhoneNumber, phonenumbers.UNKNOWN_REGION); err != nil { result.Add("document", fmt.Sprintf("Row [%d]: The FromPhoneNumber [%s] is not a valid E.164 phone number", index+2, message.FromPhoneNumber)) } From 569b56da2b129e712ce329c12a40cb4bfc6fbfb3 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:02:28 -0800 Subject: [PATCH 09/28] Added attachment_urls to discord slash command --- api/pkg/services/discord_service.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/pkg/services/discord_service.go b/api/pkg/services/discord_service.go index 059231b9..8c608e9f 100644 --- a/api/pkg/services/discord_service.go +++ b/api/pkg/services/discord_service.go @@ -169,6 +169,12 @@ func (service *DiscordService) createSlashCommand(ctx context.Context, serverID Type: 3, Required: true, }, + { + Name: "attachment_urls", + Description: "Comma-separated list of media URLs to attach", + Type: 3, + Required: false, + }, }, }) if err != nil { From a0fc868569bca37fd8628993b92a3b19ef866011 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:05:01 -0800 Subject: [PATCH 10/28] Added attachment file type check to CreateRequest --- api/pkg/handlers/discord_handler.go | 40 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/api/pkg/handlers/discord_handler.go b/api/pkg/handlers/discord_handler.go index 95591c2b..d556dc45 100644 --- a/api/pkg/handlers/discord_handler.go +++ b/api/pkg/handlers/discord_handler.go @@ -8,9 +8,11 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/google/uuid" + "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/requests" "github.com/NdoleStudio/httpsms/pkg/services" @@ -290,10 +292,42 @@ func (h *DiscordHandler) createRequest(payload map[string]any) requests.MessageS } return "" } + var attachments []entities.MessageAttachment + attachmentURLsStr := getOption("attachment_urls") + + if attachmentURLsStr != "" { + urls := strings.Split(attachmentURLsStr, ",") + for _, u := range urls { + cleanURL := strings.TrimSpace(u) + if cleanURL == "" { + continue + } + + // Same as with bulk CSV attachments, can't easily ask for the MIME type so + // just inferring based on the file extension + contentType := "application/octet-stream" + lowerURL := strings.ToLower(cleanURL) + if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") { + contentType = "image/jpeg" + } else if strings.HasSuffix(lowerURL, ".png") { + contentType = "image/png" + } else if strings.HasSuffix(lowerURL, ".gif") { + contentType = "image/gif" + } else if strings.HasSuffix(lowerURL, ".mp4") { + contentType = "video/mp4" + } + + attachments = append(attachments, entities.MessageAttachment{ + ContentType: contentType, + URL: cleanURL, + }) + } + } return requests.MessageSend{ - From: getOption("from"), - To: getOption("to"), - Content: getOption("message"), + From: getOption("from"), + To: getOption("to"), + Content: getOption("message"), + Attachments: attachments, } } From 2f6c94a2406ec9c797cb3498f5b198d8e54ace4d Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:08:03 -0800 Subject: [PATCH 11/28] Added embed for discord confirmation --- api/pkg/handlers/discord_handler.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/api/pkg/handlers/discord_handler.go b/api/pkg/handlers/discord_handler.go index d556dc45..7be60f3b 100644 --- a/api/pkg/handlers/discord_handler.go +++ b/api/pkg/handlers/discord_handler.go @@ -375,6 +375,19 @@ func (h *DiscordHandler) sendSMS(ctx context.Context, c *fiber.Ctx, payload map[ }, } + if len(request.Attachments) > 0 { + var urls []string + for _, att := range request.Attachments { + urls = append(urls, att.URL) + } + + fields := messageEmbed["fields"].([]fiber.Map) + messageEmbed["fields"] = append(fields, fiber.Map{ + "name": "Attachments:", + "value": strings.Join(urls, "\n"), + }) + } + if errors := h.messageValidator.ValidateMessageSend(ctx, discord.UserID, request.Sanitize()); len(errors) != 0 { msg := fmt.Sprintf("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body()) ctxLogger.Warn(stacktrace.NewError(msg)) From 64a40c75298975c10fb1881872aab2dd5e153e3e Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:11:13 -0800 Subject: [PATCH 12/28] Defined attachment --- android/app/src/main/java/com/httpsms/Models.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/app/src/main/java/com/httpsms/Models.kt b/android/app/src/main/java/com/httpsms/Models.kt index ccfe590b..5b718450 100644 --- a/android/app/src/main/java/com/httpsms/Models.kt +++ b/android/app/src/main/java/com/httpsms/Models.kt @@ -29,6 +29,13 @@ data class Phone ( val userID: String, ) +data class Attachment ( + @Json(name = "content_type") + val contentType: String, + + val url: String +) + data class Message ( val contact: String, val content: String, @@ -69,4 +76,6 @@ data class Message ( @Json(name = "updated_at") val updatedAt: String + + val attachments: List? = null ) From 173a4f1d2076ca0d868bde67a8e9b4d98f450a2d Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:14:20 -0800 Subject: [PATCH 13/28] Added a sendMultimediaMessage function --- .../main/java/com/httpsms/SmsManagerService.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/android/app/src/main/java/com/httpsms/SmsManagerService.kt b/android/app/src/main/java/com/httpsms/SmsManagerService.kt index 17987b5c..196c3868 100644 --- a/android/app/src/main/java/com/httpsms/SmsManagerService.kt +++ b/android/app/src/main/java/com/httpsms/SmsManagerService.kt @@ -76,4 +76,20 @@ class SmsManagerService { context.getSystemService(SmsManager::class.java).createForSubscriptionId(subscriptionId) } } + + fun sendMultimediaMessage( + context: Context, + pduUri: android.net.Uri, + sim: String, + sentIntent: PendingIntent + ) { + val smsManager = getSmsManager(context, sim) + smsManager.sendMultimediaMessage( + context, + pduUri, + null, + null, + sentIntent + ) + } } From 8a19b1d9021c564a1bedd2c76285b15f29ad681e Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:25:10 -0800 Subject: [PATCH 14/28] Added a filesize validation to make sure we're under 1.5MB --- .../java/com/httpsms/SmsManagerService.kt | 15 ++------- .../bulk_message_handler_validator.go | 6 +++- .../validators/message_handler_validator.go | 4 +++ api/pkg/validators/validator.go | 31 +++++++++++++++++++ 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/java/com/httpsms/SmsManagerService.kt b/android/app/src/main/java/com/httpsms/SmsManagerService.kt index 196c3868..59fbdad8 100644 --- a/android/app/src/main/java/com/httpsms/SmsManagerService.kt +++ b/android/app/src/main/java/com/httpsms/SmsManagerService.kt @@ -77,19 +77,8 @@ class SmsManagerService { } } - fun sendMultimediaMessage( - context: Context, - pduUri: android.net.Uri, - sim: String, - sentIntent: PendingIntent - ) { + fun sendMultimediaMessage(context: Context, pduUri: android.net.Uri, sim: String, sentIntent: PendingIntent) { val smsManager = getSmsManager(context, sim) - smsManager.sendMultimediaMessage( - context, - pduUri, - null, - null, - sentIntent - ) + smsManager.sendMultimediaMessage(context, pduUri, null, null, sentIntent) } } diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go index 84844392..b96829af 100644 --- a/api/pkg/validators/bulk_message_handler_validator.go +++ b/api/pkg/validators/bulk_message_handler_validator.go @@ -221,7 +221,7 @@ func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.Bulk if message.AttachmentURLs != "" { urls := strings.Split(message.AttachmentURLs, ",") - + if len(urls) > 10 { result.Add("document", fmt.Sprintf("Row [%d]: You cannot attach more than 10 files per message.", index+2)) } @@ -237,6 +237,10 @@ func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.Bulk result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] has an invalid url format.", index+2, cleanURL)) } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] must use http or https.", index+2, cleanURL)) + } else { + if err := validateAttachmentURL(cleanURL); err != nil { + result.Add("attachments", fmt.Sprintf("Row [%d]: The attachment URL [%s] failed validation: %s", index+2, cleanURL, err.Error())) + } } } } diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index 20a8ed9f..ffbdd778 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -123,6 +123,10 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context result.Add("attachments", fmt.Sprintf("attachment at index %d has an invalid url format", i)) } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { result.Add("attachments", fmt.Sprintf("attachment at index %d must use http or https scheme", i)) + } else { + if err := validateAttachmentURL(attachment.URL); err != nil { + result.Add("attachments", fmt.Sprintf("attachment at index %d failed validation: %s", i, err.Error())) + } } } } diff --git a/api/pkg/validators/validator.go b/api/pkg/validators/validator.go index bc7111e8..bcef0a9b 100644 --- a/api/pkg/validators/validator.go +++ b/api/pkg/validators/validator.go @@ -2,9 +2,11 @@ package validators import ( "fmt" + "net/http" "net/url" "regexp" "strings" + "time" "github.com/NdoleStudio/httpsms/pkg/events" @@ -160,3 +162,32 @@ func (validator *validator) ValidateUUID(ID string, name string) url.Values { return v.ValidateStruct() } + +func validateAttachmentURL(attachmentURL string) error { + client := &http.Client{ + Timeout: 5 * time.Second, + } + + req, err := http.NewRequest(http.MethodHead, attachmentURL, nil) + if err != nil { + return fmt.Errorf("invalid url format") + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("could not reach the url") + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return fmt.Errorf("url returned an error status code: %d", resp.StatusCode) + } + + const maxSizeBytes = 1.5 * 1024 * 1024 + + if resp.ContentLength > int64(maxSizeBytes) { + return fmt.Errorf("file size (%.2f MB) exceeds the 1.5 MB carrier limit", float64(resp.ContentLength)/(1024*1024)) + } + + return nil +} From 40f718cc5bfd83f77ebea57e64077798be18ac89 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:25:45 -0800 Subject: [PATCH 15/28] Added missing validation to bulk send validator --- api/pkg/validators/message_handler_validator.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index ffbdd778..ec23ab78 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -198,6 +198,10 @@ func (validator MessageHandlerValidator) ValidateMessageBulkSend(ctx context.Con result.Add("attachments", fmt.Sprintf("attachment at index %d has an invalid url format", i)) } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { result.Add("attachments", fmt.Sprintf("attachment at index %d must use http or https scheme", i)) + } else { + if err := validateAttachmentURL(attachment.URL); err != nil { + result.Add("attachments", fmt.Sprintf("attachment at index %d failed validation: %s", i, err.Error())) + } } } } From f2a0ab8a64831856ac92852c117012d6845f7e98 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:35:27 -0800 Subject: [PATCH 16/28] Added cache for csv uploads to avoid 1000s of duplicate http requests --- .../bulk_message_handler_validator.go | 10 ++++-- .../validators/message_handler_validator.go | 8 +++-- api/pkg/validators/validator.go | 34 ++++++++++++++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go index b96829af..137442d0 100644 --- a/api/pkg/validators/bulk_message_handler_validator.go +++ b/api/pkg/validators/bulk_message_handler_validator.go @@ -17,6 +17,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/requests" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/dustin/go-humanize" "github.com/jszwec/csvutil" "github.com/nyaruka/phonenumbers" @@ -30,6 +31,7 @@ type BulkMessageHandlerValidator struct { userService *services.UserService logger telemetry.Logger tracer telemetry.Tracer + cache cache.Cache } // NewBulkMessageHandlerValidator creates a new handlers.BulkMessageHandlerValidator validator @@ -38,12 +40,14 @@ func NewBulkMessageHandlerValidator( tracer telemetry.Tracer, phoneService *services.PhoneService, userService *services.UserService, + appCache cache.Cache, ) (v *BulkMessageHandlerValidator) { return &BulkMessageHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, userService: userService, phoneService: phoneService, + cache: appCache, } } @@ -79,7 +83,7 @@ func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID messages[index] = message.Sanitize() } - result = v.validateMessages(messages) + result = v.validateMessages(ctx, messages) if len(result) != 0 { return messages, result } @@ -215,7 +219,7 @@ func (v *BulkMessageHandlerValidator) parseCSV(ctxLogger telemetry.Logger, user return messages, url.Values{} } -func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.BulkMessage) url.Values { +func (v *BulkMessageHandlerValidator) validateMessages(ctx context.Context, messages []*requests.BulkMessage) url.Values { result := url.Values{} for index, message := range messages { @@ -238,7 +242,7 @@ func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.Bulk } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] must use http or https.", index+2, cleanURL)) } else { - if err := validateAttachmentURL(cleanURL); err != nil { + if err := validateAttachmentURL(ctx, v.cache, cleanURL); err != nil { result.Add("attachments", fmt.Sprintf("Row [%d]: The attachment URL [%s] failed validation: %s", index+2, cleanURL, err.Error())) } } diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index ec23ab78..52741891 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/palantir/stacktrace" @@ -25,6 +26,7 @@ type MessageHandlerValidator struct { tracer telemetry.Tracer phoneService *services.PhoneService tokenValidator *TurnstileTokenValidator + cache cache.Cache } // NewMessageHandlerValidator creates a new handlers.MessageHandler validator @@ -33,12 +35,14 @@ func NewMessageHandlerValidator( tracer telemetry.Tracer, phoneService *services.PhoneService, tokenValidator *TurnstileTokenValidator, + appCache cache.Cache, ) (v *MessageHandlerValidator) { return &MessageHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, phoneService: phoneService, tokenValidator: tokenValidator, + cache: appCache, } } @@ -124,7 +128,7 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { result.Add("attachments", fmt.Sprintf("attachment at index %d must use http or https scheme", i)) } else { - if err := validateAttachmentURL(attachment.URL); err != nil { + if err := validateAttachmentURL(ctx, validator.cache, attachment.URL); err != nil { result.Add("attachments", fmt.Sprintf("attachment at index %d failed validation: %s", i, err.Error())) } } @@ -199,7 +203,7 @@ func (validator MessageHandlerValidator) ValidateMessageBulkSend(ctx context.Con } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { result.Add("attachments", fmt.Sprintf("attachment at index %d must use http or https scheme", i)) } else { - if err := validateAttachmentURL(attachment.URL); err != nil { + if err := validateAttachmentURL(ctx, validator.cache, attachment.URL); err != nil { result.Add("attachments", fmt.Sprintf("attachment at index %d failed validation: %s", i, err.Error())) } } diff --git a/api/pkg/validators/validator.go b/api/pkg/validators/validator.go index bcef0a9b..850d1e76 100644 --- a/api/pkg/validators/validator.go +++ b/api/pkg/validators/validator.go @@ -1,6 +1,7 @@ package validators import ( + "context" "fmt" "net/http" "net/url" @@ -8,6 +9,7 @@ import ( "strings" "time" + "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/nyaruka/phonenumbers" @@ -163,31 +165,53 @@ func (validator *validator) ValidateUUID(ID string, name string) url.Values { return v.ValidateStruct() } -func validateAttachmentURL(attachmentURL string) error { +func validateAttachmentURL(ctx context.Context, c cache.Cache, attachmentURL string) error { + cacheKey := "mms-url-validation:" + attachmentURL + + if cachedVal, err := c.Get(ctx, cacheKey); err == nil { + if cachedVal == "valid" { + return nil + } + return fmt.Errorf(cachedVal) + } + client := &http.Client{ Timeout: 5 * time.Second, } req, err := http.NewRequest(http.MethodHead, attachmentURL, nil) if err != nil { - return fmt.Errorf("invalid url format") + errMsg := fmt.Sprintf("invalid url format") + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) } resp, err := client.Do(req) if err != nil { - return fmt.Errorf("could not reach the url") + errMsg := fmt.Sprintf("could not reach the url") + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 400 { - return fmt.Errorf("url returned an error status code: %d", resp.StatusCode) + errMsg := fmt.Sprintf("url returned an error status code: %d", resp.StatusCode) + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) } const maxSizeBytes = 1.5 * 1024 * 1024 if resp.ContentLength > int64(maxSizeBytes) { - return fmt.Errorf("file size (%.2f MB) exceeds the 1.5 MB carrier limit", float64(resp.ContentLength)/(1024*1024)) + errMsg := fmt.Sprintf("file size (%.2f MB) exceeds the 1.5 MB carrier limit", float64(resp.ContentLength)/(1024*1024)) + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) } + saveToCache(ctx, c, cacheKey, "valid") return nil } + +func saveToCache(ctx context.Context, c cache.Cache, key string, value string) { + _ = c.Set(ctx, key, value, 24*time.Hour) +} From 3829d74b7830eae1a8ab301c711ce4719fdd7975 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:40:58 -0800 Subject: [PATCH 17/28] Added provider to manifest --- android/app/src/main/AndroidManifest.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6d704ade..409b7140 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -90,6 +90,17 @@ + + + + + From 41c7bedea4bd8a811f5cb135165fde819b029d8a Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:41:58 -0800 Subject: [PATCH 18/28] File path for mms attachments/cache --- android/app/src/main/res/xml/file_paths.xml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 android/app/src/main/res/xml/file_paths.xml diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..0df3af41 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From e8140cdf4b84e62a7156af4bf9e28cec5bd2824f Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 01:55:58 -0800 Subject: [PATCH 19/28] added missing cache arguments --- api/pkg/di/container.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 632e4706..02d37738 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -534,6 +534,7 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes container.Tracer(), container.PhoneService(), container.TurnstileTokenValidator(), + container.Cache(), ) } @@ -556,6 +557,7 @@ func (container *Container) BulkMessageHandlerValidator() (validator *validators container.Tracer(), container.PhoneService(), container.UserService(), + container.Cache(), ) } From b35f60d7278609ae6c870db844673d124fa33433 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 02:15:11 -0800 Subject: [PATCH 20/28] Added web support for attachments --- web/components/MessageThread.vue | 11 +++++++++-- web/models/message.ts | 6 ++++++ web/pages/messages/index.vue | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/web/components/MessageThread.vue b/web/components/MessageThread.vue index 0f0f222b..e2ed4739 100644 --- a/web/components/MessageThread.vue +++ b/web/components/MessageThread.vue @@ -95,8 +95,13 @@ {{ thread.contact | phoneNumber }} - - {{ thread.last_message_content }} + + + {{ thread.last_message_content }} + + + {{ mdiPaperclip }} Multimedia Message + @@ -150,6 +155,7 @@ import { mdiCheck, mdiAlert, mdiAccount, + mdiPaperclip, } from '@mdi/js' @Component @@ -160,6 +166,7 @@ export default class MessageThread extends Vue { mdiAlert = mdiAlert mdiCheck = mdiCheck mdiCheckAll = mdiCheckAll + mdiPaperclip = mdiPaperclip get threads(): Array { return this.$store.getters.getThreads diff --git a/web/models/message.ts b/web/models/message.ts index 35306648..c0d09f12 100644 --- a/web/models/message.ts +++ b/web/models/message.ts @@ -1,6 +1,12 @@ +export interface MessageAttachment { + content_type: string + url: string +} + export interface Message { contact: string content: string + attachments?: MessageAttachment[] created_at: string failure_reason: string id: string diff --git a/web/pages/messages/index.vue b/web/pages/messages/index.vue index a1a2a154..78a46533 100644 --- a/web/pages/messages/index.vue +++ b/web/pages/messages/index.vue @@ -33,6 +33,16 @@ placeholder="Enter your message here" label="Content" > + { @@ -113,6 +144,9 @@ export default { ), ) } + if (response.data.data.attachments) { + errors.set('attachments', response.data.data.attachments) + } if (response.data.data.from) { this.$store.dispatch('addNotification', { message: response.data.data.from[0], From adfa171d0fdf94431b62f6ae5de6381ec6da4d2d Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 02:52:01 -0800 Subject: [PATCH 21/28] Added PDU generation via android-smsmms and MMS sender/handler --- android/app/build.gradle | 1 + .../com/httpsms/FirebaseMessagingService.kt | 113 ++++++++++++++++++ .../java/com/httpsms/HttpSmsApiService.kt | 47 ++++++++ .../src/main/java/com/httpsms/SentReceiver.kt | 22 ++++ 4 files changed, 183 insertions(+) diff --git a/android/app/build.gradle b/android/app/build.gradle index f49acb2b..66eb98c1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -67,6 +67,7 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'com.googlecode.libphonenumber:libphonenumber:9.0.4' + implementation 'com.klinkerapps:android-smsmms:5.2.6' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt index 8f1e448c..dca73a5e 100644 --- a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt +++ b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt @@ -9,6 +9,13 @@ import com.google.firebase.messaging.RemoteMessage import com.httpsms.SentReceiver.FailedMessageWorker import timber.log.Timber +import com.google.android.mms.pdu.CharacterSets +import com.google.android.mms.pdu.EncodedStringValue +import com.google.android.mms.pdu.PduBody +import com.google.android.mms.pdu.PduComposer +import com.google.android.mms.pdu.PduPart +import com.google.android.mms.pdu.SendReq + class MyFirebaseMessagingService : FirebaseMessagingService() { // [START receive_message] override fun onMessageReceived(remoteMessage: RemoteMessage) { @@ -158,6 +165,11 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } Receiver.register(applicationContext) + + if (message.attachments != null && message.attachments.isNotEmpty()) { + return handleMmsMessage(message) + } + val parts = getMessageParts(applicationContext, message) if (parts.size == 1) { return handleSingleMessage(message, parts.first()) @@ -165,6 +177,107 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { return handleMultipartMessage(message, parts) } + private fun handleMmsMessage(message: Message): Result { + Timber.d("Processing MMS for message ID [${message.id}]") + val apiService = HttpSmsApiService.create(applicationContext) + + val downloadedFiles = mutableListOf() + + try { + for ((index, attachment) in message.attachments!!.withIndex()) { + val file = apiService.downloadAttachment(applicationContext, attachment.url, message.id, index) + if (file == null) { + handleFailed(applicationContext, message.id, "Failed to download attachment or file size exceeded 1.5MB.") + return Result.failure() + } + downloadedFiles.add(file) + } + + val sendReq = SendReq() + + val encodedContact = EncodedStringValue(message.contact) + sendReq.to = arrayOf(encodedContact) + + val pduBody = PduBody() + + if (message.content.isNotEmpty()) { + val textPart = PduPart() + textPart.setCharset(CharacterSets.UTF_8) + textPart.contentType = "text/plain".toByteArray() + textPart.name = "text".toByteArray() + textPart.contentId = "text".toByteArray() + textPart.contentLocation = "text".toByteArray() + + var messageBody = message.content + val encryptionKey = Settings.getEncryptionKey(applicationContext) + if (message.encrypted && !encryptionKey.isNullOrEmpty()) { + messageBody = Encrypter.decrypt(encryptionKey, messageBody) + } + textPart.data = messageBody.toByteArray(Charsets.UTF_8) + + pduBody.addPart(textPart) + } + + for ((index, file) in downloadedFiles.withIndex()) { + val attachment = message.attachments[index] + val fileBytes = file.readBytes() + + val mediaPart = PduPart() + mediaPart.contentType = attachment.contentType.toByteArray() + + val fileName = "attachment_$index".toByteArray() + mediaPart.name = fileName + mediaPart.contentId = fileName + mediaPart.contentLocation = fileName + mediaPart.data = fileBytes + + pduBody.addPart(mediaPart) + } + + sendReq.body = pduBody + + val pduComposer = PduComposer(applicationContext, sendReq) + val pduBytes = pduComposer.make() + + if (pduBytes == null) { + Timber.e("PduComposer failed to generate PDU byte array") + handleFailed(applicationContext, message.id, "Failed to compose MMS PDU.") + return Result.failure() + } + + val mmsDir = java.io.File(applicationContext.cacheDir, "mms_attachments") + if (!mmsDir.exists()) { + mmsDir.mkdirs() + } + + val pduFile = java.io.File(mmsDir, "pdu_${message.id}.dat") + java.io.FileOutputStream(pduFile).use { it.write(pduBytes) } + + val pduUri = androidx.core.content.FileProvider.getUriForFile( + applicationContext, + "${BuildConfig.APPLICATION_ID}.fileprovider", + pduFile + ) + + val sentIntent = createPendingIntent(message.id, SmsManagerService.sentAction()) + SmsManagerService().sendMultimediaMessage(applicationContext, pduUri, message.sim, sentIntent) + + Timber.d("Successfully dispatched MMS for message ID [${message.id}]") + return Result.success() + + } catch (e: Exception) { + Timber.e(e, "Failed to send MMS for message ID [${message.id}]") + handleFailed(applicationContext, message.id, e.message ?: "Internal error while building or sending MMS.") + return Result.failure() + } finally { + downloadedFiles.forEach { file -> + if (file.exists()) { + file.delete() + } + } + } + } + private fun handleMultipartMessage(message:Message, parts: ArrayList): Result { Timber.d("sending multipart SMS for message with ID [${message.id}]") return try { diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index 3d813e13..4f1fe5dd 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -11,6 +11,8 @@ import java.net.URI import java.net.URL import java.util.logging.Level import java.util.logging.Logger.getLogger +import java.io.File +import java.io.FileOutputStream class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { @@ -156,6 +158,51 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return true } + fun downloadAttachment(context: Context, urlString: String, messageId: String, attachmentIndex: Int): File? { + val request = Request.Builder().url(urlString).build() + + try { + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Timber.e("Failed to download attachment: ${response.code}") + response.close() + return null + } + + val maxSizeBytes = 1.5 * 1024 * 1024 // most (modern?) carriers have a 2MB limit, so targetting 1.5MB should be safe + val contentLength = response.body?.contentLength() ?: -1L + if (contentLength > maxSizeBytes) { + Timber.e("Attachment is too large ($contentLength bytes).") + response.close() + return null + } + + val mmsDir = File(context.cacheDir, "mms_attachments") + if (!mmsDir.exists()) { + mmsDir.mkdirs() + } + + val tempFile = File(mmsDir, "mms_${messageId}_$attachmentIndex") + val inputStream = response.body?.byteStream() + val outputStream = FileOutputStream(tempFile) + inputStream?.copyTo(outputStream) + + outputStream.close() + inputStream?.close() + response.close() + + if (tempFile.length() > maxSizeBytes) { + tempFile.delete() + Timber.e("Downloaded file exceeded 1.5MB limit.") + return null + } + + return tempFile + } catch (e: Exception) { + Timber.e(e, "Exception while downloading attachment") + return null + } + } private fun sendEvent(messageId: String, event: String, timestamp: String, reason: String? = null): Boolean { var reasonString = "null" diff --git a/android/app/src/main/java/com/httpsms/SentReceiver.kt b/android/app/src/main/java/com/httpsms/SentReceiver.kt index 7995c35c..00e76c2c 100644 --- a/android/app/src/main/java/com/httpsms/SentReceiver.kt +++ b/android/app/src/main/java/com/httpsms/SentReceiver.kt @@ -17,6 +17,8 @@ import timber.log.Timber internal class SentReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + val messageId = intent.getStringExtra(Constants.KEY_MESSAGE_ID) + cleanupPduFile(context, messageId) when (resultCode) { Activity.RESULT_OK -> handleMessageSent(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID)) SmsManager.RESULT_ERROR_GENERIC_FAILURE -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "GENERIC_FAILURE") @@ -27,6 +29,26 @@ internal class SentReceiver : BroadcastReceiver() { } } + private fun cleanupPduFile(context: Context, messageId: String?) { + if (messageId == null) return + + try { + val baseMessageId = messageId.substringBefore(".") + val mmsDir = File(context.cacheDir, "mms_attachments") + val pduFile = File(mmsDir, "pdu_$baseMessageId.dat") + + if (pduFile.exists()) { + if (pduFile.delete()) { + Timber.d("Cleaned up PDU file for message ID [$baseMessageId]") + } else { + Timber.w("Failed to delete PDU file for message ID [$baseMessageId]") + } + } + } catch (e: Exception) { + Timber.e(e, "Error cleaning up PDU file for message ID [$messageId]") + } + } + private fun handleMessageSent(context: Context, messageId: String?) { if (!Receiver.isValid(context, messageId)) { return From 5508157e936e35ee5aa32e74739164c2f5c4fdb7 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Fri, 27 Feb 2026 09:36:36 -0800 Subject: [PATCH 22/28] Added annotations for Codacy --- android/app/src/main/java/com/httpsms/HttpSmsApiService.kt | 1 + android/app/src/main/java/com/httpsms/Models.kt | 1 + android/app/src/main/java/com/httpsms/SmsManagerService.kt | 1 + 3 files changed, 3 insertions(+) diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index 4f1fe5dd..c3ae8e13 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -158,6 +158,7 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return true } + // Downloads the attachment URL content locally fun downloadAttachment(context: Context, urlString: String, messageId: String, attachmentIndex: Int): File? { val request = Request.Builder().url(urlString).build() diff --git a/android/app/src/main/java/com/httpsms/Models.kt b/android/app/src/main/java/com/httpsms/Models.kt index 5b718450..d2559fb0 100644 --- a/android/app/src/main/java/com/httpsms/Models.kt +++ b/android/app/src/main/java/com/httpsms/Models.kt @@ -29,6 +29,7 @@ data class Phone ( val userID: String, ) +// mms attachment data class Attachment ( @Json(name = "content_type") val contentType: String, diff --git a/android/app/src/main/java/com/httpsms/SmsManagerService.kt b/android/app/src/main/java/com/httpsms/SmsManagerService.kt index 59fbdad8..5f7ce6f5 100644 --- a/android/app/src/main/java/com/httpsms/SmsManagerService.kt +++ b/android/app/src/main/java/com/httpsms/SmsManagerService.kt @@ -77,6 +77,7 @@ class SmsManagerService { } } + // Wrapper for the smsManager's sendMultimediaMessage fun sendMultimediaMessage(context: Context, pduUri: android.net.Uri, sim: String, sentIntent: PendingIntent) { val smsManager = getSmsManager(context, sim) smsManager.sendMultimediaMessage(context, pduUri, null, null, sentIntent) From a8c976bb5598f25ccebabab756c4713c55cd85ea Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Sat, 28 Feb 2026 20:22:34 -0800 Subject: [PATCH 23/28] Fixed imports and missing comma --- .../java/com/httpsms/FirebaseMessagingService.kt | 12 ++++++------ android/app/src/main/java/com/httpsms/Models.kt | 2 +- .../app/src/main/java/com/httpsms/SentReceiver.kt | 1 + 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt index dca73a5e..03c99bb4 100644 --- a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt +++ b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt @@ -9,12 +9,12 @@ import com.google.firebase.messaging.RemoteMessage import com.httpsms.SentReceiver.FailedMessageWorker import timber.log.Timber -import com.google.android.mms.pdu.CharacterSets -import com.google.android.mms.pdu.EncodedStringValue -import com.google.android.mms.pdu.PduBody -import com.google.android.mms.pdu.PduComposer -import com.google.android.mms.pdu.PduPart -import com.google.android.mms.pdu.SendReq +import com.google.android.mms.pdu_alt.CharacterSets +import com.google.android.mms.pdu_alt.EncodedStringValue +import com.google.android.mms.pdu_alt.PduBody +import com.google.android.mms.pdu_alt.PduComposer +import com.google.android.mms.pdu_alt.PduPart +import com.google.android.mms.pdu_alt.SendReq class MyFirebaseMessagingService : FirebaseMessagingService() { // [START receive_message] diff --git a/android/app/src/main/java/com/httpsms/Models.kt b/android/app/src/main/java/com/httpsms/Models.kt index d2559fb0..1ee2dbc8 100644 --- a/android/app/src/main/java/com/httpsms/Models.kt +++ b/android/app/src/main/java/com/httpsms/Models.kt @@ -76,7 +76,7 @@ data class Message ( val type: String, @Json(name = "updated_at") - val updatedAt: String + val updatedAt: String, val attachments: List? = null ) diff --git a/android/app/src/main/java/com/httpsms/SentReceiver.kt b/android/app/src/main/java/com/httpsms/SentReceiver.kt index 00e76c2c..8786ba2c 100644 --- a/android/app/src/main/java/com/httpsms/SentReceiver.kt +++ b/android/app/src/main/java/com/httpsms/SentReceiver.kt @@ -14,6 +14,7 @@ import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf import timber.log.Timber +import java.io.File internal class SentReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { From cafaab90086d37d9158e0306a59a351d75162b0b Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Sat, 28 Feb 2026 20:39:51 -0800 Subject: [PATCH 24/28] Updated UI to show attachments --- web/components/MessageThread.vue | 5 +--- web/pages/threads/_id/index.vue | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/web/components/MessageThread.vue b/web/components/MessageThread.vue index e2ed4739..51676b85 100644 --- a/web/components/MessageThread.vue +++ b/web/components/MessageThread.vue @@ -96,12 +96,9 @@ {{ thread.contact | phoneNumber }} - + {{ thread.last_message_content }} - - {{ mdiPaperclip }} Multimedia Message - diff --git a/web/pages/threads/_id/index.vue b/web/pages/threads/_id/index.vue index 06042a45..21ff39c7 100644 --- a/web/pages/threads/_id/index.vue +++ b/web/pages/threads/_id/index.vue @@ -173,6 +173,46 @@ > + + Message Attachments + + + + + + + +
+ +
Unsupported file type
+
+
+
+
+

{{ new Date(message.order_timestamp).toLocaleString() }} From 47dde30802b6bfdad79585c8705f701e0d009440 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Sat, 28 Feb 2026 20:50:16 -0800 Subject: [PATCH 25/28] Updated templates --- web/static/templates/httpsms-bulk.csv | 6 +++--- web/static/templates/httpsms-bulk.xlsx | Bin 8991 -> 8246 bytes 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/static/templates/httpsms-bulk.csv b/web/static/templates/httpsms-bulk.csv index 38891f63..715ecab4 100644 --- a/web/static/templates/httpsms-bulk.csv +++ b/web/static/templates/httpsms-bulk.csv @@ -1,3 +1,3 @@ -FromPhoneNumber,ToPhoneNumber,Content -+18005550199,+18005550100,This is a sample text message1 -+18005550199,+18005550100,This is a sample text message2 +FromPhoneNumber,ToPhoneNumber,Content,SendTime(optional),AttachmentURLs(optional) ++18005550199,+18005550100,This is a sample text message1,,http://thisisasample.com/attachment.png ++18005550199,+18005550100,This is a sample text message2,2023-11-11T02:10:01, diff --git a/web/static/templates/httpsms-bulk.xlsx b/web/static/templates/httpsms-bulk.xlsx index bb713087050c20208ebcbf516a03b162f3514f34..20f2b543d704e8dfcb2836fbb5002e850cfda523 100644 GIT binary patch literal 8246 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 DfILSV literal 8991 zcmeHtg;yNe_H_p*0n#`G2=4Aqf(Hp2TpD+1+=IKjYaqA>mk`{7LvV-S?*8lK&3u{3 z%=Z_(SG`u%>a}X0Rdw&)=j?lK$w@)OU;y9%2mkC2{)ag9#{l`Q}b|onCUj>NNuU zzE2Mtjl95@&e-lEeT@uL)5nb9TcA)jrWt1?+VH#^%kiK|m6c_!Pf&N00#c}V?-wO) zJWj^0o;~tbY}^=Ib+yAR47}IOxFArtQ7RzGrmjPQO=Lw-vgCycCYFHLTKa5{GU`%y z15%NC0eA0Q+p>)EA}UvZ;W%(@6n(WEyJ_UW zGy&0TWW4q!_8FT7$eD*$W0kU44ZrVvg_$EgaMVt;a7(^|j^DY2XV91t8D$`YLt;s{ z{IMrPM3$SA#>XSSx38Y-i|oEWzPnZ4DEvB%=+z^}1?qO==@%hj-xp%C_ksJ%z1zHQ zdm|efz6Zw%+ny{BZ~(yLBQ!wnZ?ddWVj?|*a7`Mbx@Qnsf@}>f>=+q-oc|}s|6&dP z<<(1K-pY0`Aq5=1xeM&QoLq}zNhlfyFKMk)X+YwQjRQOlg>8bEP030ps^jGO5T9SaP*ODEHm%Uhu;n3f0U20)ITcB5MZb5amr3hY zAYnthVi^|iAQ;%8rR*9P@WEs3K9Fijc(s{5n5wvWCkl`~=FeM*#u6+=7gKM{ zTaB>qxnwhoF`Xf!~jt!r@PLf zR!&=%obc$)oh z;gXxH0FGbd(SYFOw9E&#?ke^1U$Fr%;9O<{I)(#14cc@fDZRIs3lb925NqQo0hU%0 zgq@Le=-JPyr}4ipbCFH0uO}D1I8d{Bj4iq2Pk4k2C;^EH6O{_mqLLlPnk;6dmS(0i zm=YduU3txEJj* zJk2*s+<6O$kdJ=@u{{V+!a0y!LXA=|u&Lfd$qw34omH-$gY@wt4&8)q1iSMoDFf*p zQCVe4O$MhTe>fHh)~_|lr6 zAl$i{?su%)dgm8pQ^=w(WLA4tH~cCO@x$^c)r5H;yR*PBLzXJ%6|N*aRH0+~dz2c% zq4z2$2n(%wGB`b^-*L0`-2y^SrV&`-as4y%m~BJJD)+!Zq|2O`742`lN>p2g2R<+; z(uvNj>QR{I)vh_mX8R9F?2k-gwO|?@hsOnlDlG7gyp*M#cvDDeek1y8X~wmu8B@M1 z5z19?$1doGO9%I&Tl&Zrmb7xIBcvgd?>yf^_ejMMF;z+6;913N0Gm~CGW*VAVTH9E6SM3sSM$9Ll~Ew>5T?I>5auACdcVT!Y3*Zts%<*8@~@Ea2-E z(?;O_N+k63EXuqKoz@!-I(tE!tHezYKdasohzW({f&cEKMB%MeClj#c*+U?cQ>r5->Wn=j$)3{g zbA-NX>NyHxR*$nKJeo!=)o)Ua(BJ@<01aRYPmNJP#W#sb*PhL{{!%+kWcSxf8m9vqPix zzMjb?$V~q;>2HT9!kk0sQV;P>IFLL3NcwgrhKBZbj6W~TKeB&X>|8`D6ENVA_M9N< zuq`b%D@sYtajvDR#sRf#QPcpi8RMHE+#bVJGrdzkv@zVC|Z+g$@k(8!9*T1`d3 zLu`HEHi^8c)Wr?Siy+ky<>?Lt@`&}B@gl_(dW{k{!p<;Ww{Y2=Z~bfw6=;kz{83?drHgX@B-*4K8;)d6*<$78EC*#~AmSsdt4@(P1}>)%n^GP~;s*{>I=M7`%oW(FV9r6AS`ba_T%k*sSya};79%gCG{kT*#gKV4 z#?ADr&+sG0IuD46sCj_cfXBz5go%sRdfLu}IC+LoOv5V-%-HLJnyt7*|l9Fmcc_B=n%rjS?0bK;e@RiqcoTm2ITgr#Wb^i*Xi`qq;_=?daZ<^eyGMRx~4swYmq;(I0VA z7pb{M`gle3umRl>5-C(yJcHFRHe62l^AUfZs;J*c%bvfblN|m^tK2K`mbOxfWrS5l zfvR76*sD_NC zq&AF)9+-2Xy zg`pZET3tV*yOWyB^sq&>>_h08hQ1cD$W9=X;GUzw>Ou&JuK!}h<-J4^*7K&FG!v@_ zpTKu935)ilLMaYu%$2r}o}Alz7Glypx58%G73OFm!{Dxo0% zErkOD;@K133})V!t8QbLRA^`(CfDTmo7i=XSfk1mA{%gO!d6+2EDh2+?y949C);gi zV?+qP>TVxF&;^=3=ezgnt+iwMPfXZ+;`3^!d3NKiqI8kvZYHyvR1TE}uog!^TAvyS ziX#1|vfg=C1P~%ME{i@Z7o--@>@ zHOyifv~NYAFCzI0w!}~m%SRy>?1M-Zy&L#eoz zJLky95zxQ^XcRj2@2HU_m9EMKydUyNLu#2=a^tmhf>rSpE}Zo``=o2{XtO3ci5N5Z z-5!p%PXk-|J9#p*mJ;OUuOH@!|)i$QsS}7bxP~0ROv@ylc zvMOB>e0BgV_YE2+7q- z34{Ee0=Eb=JHh8rgLM{L&o*(2GDb2H*AVK{8+|%IsqZ|2=Pv|XpCYZH+`iQ6tz;=(E;T7Q*#+R#Np07i!ZNE1nsfS%R zONpZlx-lFgsB8{PZXv|B_o)aqoaL*Xt{Dtw<>fgojGt}7QM3>1#&N3?uu~n;WgXaT z7*!$Bi6_{TETQckQaVr5d14q-Wd4oXHW7i=@bWm$vcec_M7VQrkJKyiQRAnHRD8AtOIu~Yg!NRryd>9BLDtp7+iNV zT%wD*i$S2gxeuRi0K!uw>;<17%m+=8^kQ|3gSgb`RF3D+7-GsT=&|eJUBkguly^@3 zFP)y)!NEpq9N)(Aq*57fighZS7Xk1&M+;GS| z`Bc68{Nh~DEV}WrNw1A(E>U z_9VLbI8ypG8;VzyEVGd#Nb16d3t!Sgq|e_uvr=T~=LR~^VrGt%-pZUhM~08f7&nwO zrW8Jdjk$KF)(jU;l+4>w=RaI5o!Ir*dgr8;2uC$Dj-xAWgeUKSBM(9m!q7JM)1FCI z9ko{-mD@?oRu{9`c*R`zr#SmENk_^&!;>gaVv}Nxy@Ff*NU$n?Ba1u`T+rgQB zH_q##*_p79-45hkM=m}Qo~WYvXmu|`Z(C;0(13zA=_1*@GVwkXx{ZlG^DE0+wPu-) zkWZiF=c-xh5RhaiIIb)m#JKp6%P3#y6{k37wBs3rARL=BA{7^q{tYUyz)pk9(op3Vi3Qkd0bf?C*%9JaAv3 zx#K|dd|4-2UK3?}_vVSF?ue+PX?f2S!@=4H{h&W;Xs9&kD|XGw&FW{(8F!mQRHF3T zn*J;`OGh(*qZ)oHm=>9wk-cv`{@SG)hsdrA6{GlkM=hktD6^ARa=V;QYi#Q@5X{I8 zJ#y3v;NvBE3+nLhv+jv#^>sF=-y3`eT$(l2KK^Ta!X?|wA{XLR6(CL(`9I~?&fdks z(C&vl%~i5ApJM_(2uyndZw~q$*XR&y2gWAkwz{(j_KNief(x|6$c%@&A8utFwefjs zM?HdNHplIT7wiv!9hvfyj^<1j1jgcQFGXcfS0Ln7TRC@L5UF?1fL``owG zDpfl7?4sWAE3uaxM~B8{3M_H$Jd4DByj7A9V+}_P&vXiMWw>s2)k72p8A=CrbQ~=o z5uL&HE25Z`&00iBdc2*hlxGJQ{8(aDn)Bc|4tshpGwin}!Hp(ndK1a|F^jh(k?S8v zm8TA#S_hYzYgZ-OZyLy$xQIcmPZ5hEBCdtb@}5fOA~IX_w77H0nwO@Dezb~*X7(-s zx2>ve9~K>DHroaDE8ary7XW1aBr+1zO?>g+z^Cu>he&dW%qz4_1>9DoNHSdb@?Jz} z?+d4`kGLI@W%TG)S_eI|(Zh6uTJBYh8fMOUI-TWvUkxT|@sYsOxvBRQ0V6Gl+pzGw zzVUNu{Fdj_Cvds8)vwrQ1}}oVWO2V^58Z0Mk6)0PkC~5GT5$9ciHH)qpE?;B>2cFK zi9M|q{*Lz9_CYaH2mi@it`xc6HvFT2@E*b(m@m9elQi4j?p+gODBL4?3IZ+8P=t+S{618UJK;T$CVWb&BD4=<`Uto*TH3i)l|S zaZynrj#~1HsCJlRq`9+MMeVT#71aP%Flpdukcr92GbGg6SBN+}eo$HJ30E%jvcZO; z=lotS(FNBHIkDCpZ{u^m>Bw` zdQ~>kvAJRe4{%TM&oQsEmlopPB%Td zMdgh@dX4{d+vs7}FC`#jV+@fzDx{ZT0M?hY1zX!O>Vs_!|JXuNPVE>ga#BWS$wOXtx<@uvC!2E z1Ba$SkUP{=z(Oqkq!?CS@b%)Vfo}qnH zq+8gih4|Wq1{NKzew+e?^N|*pu2VVRndQ(?)n-@OV__F<{r+LXmBGynr1#nv$(@cP zj*AA|*P&l2;v&6DmP72o$i_(;sbsP)uK82I zRl-gFCvH89f-8@T<@mJFknLI>P*qM2Lws7DnR$31JN$09GYt6>+|J9d71iN3xHUVb zlfEC5p4odkTdI7xvIN4VNuE*gq6byN%}s>W$i7i~)pZv}56F(OND9#Bz8o^Y6lD~L z#@_5E)`8Mp!MEA$g?=#Opcv=v9N<3gWjdDOn)3$p*bQ-NC(GVYx)C%Pv`70Zo_F{g zyT562Hb>B&auIkp_syW-8dhdk0C|ER%yLU?u#i#_cr$t0%Eb4ucGhuz;wtaKaIIy` zO1|_^_3-RZ`aXf8hm?|k{}kcR>H72fFCQkzN&P**-#1PF1pc@tLNxK0E!5wEzi%Y{ zf;L0E+i%-Szk~nY!~O*Y06d=l1pj|J+P}y7y|MW#QYF&=dx(FuIe(Axd(Y=rlzB+m zg1pM_9iZO>{O($R1<=6$8Q_ Date: Sat, 28 Feb 2026 21:33:15 -0800 Subject: [PATCH 26/28] Added a custom copyTo to cancel downloads as soon as they breach the size limit --- .../java/com/httpsms/HttpSmsApiService.kt | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index c3ae8e13..9581cedc 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -13,6 +13,9 @@ import java.util.logging.Level import java.util.logging.Logger.getLogger import java.io.File import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { @@ -158,6 +161,28 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return true } + fun InputStream.copyToWithLimit( + out: OutputStream, + limit: Long, + bufferSize: Int = DEFAULT_BUFFER_SIZE + ): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = read(buffer) + + while (bytes >= 0) { + bytesCopied += bytes + + if (bytesCopied > limit) { + throw IOException("Download aborted: File exceeded maximum allowed size of $limit bytes.") + } + + out.write(buffer, 0, bytes) + bytes = read(buffer) + } + return bytesCopied + } + // Downloads the attachment URL content locally fun downloadAttachment(context: Context, urlString: String, messageId: String, attachmentIndex: Int): File? { val request = Request.Builder().url(urlString).build() @@ -186,18 +211,13 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { val tempFile = File(mmsDir, "mms_${messageId}_$attachmentIndex") val inputStream = response.body?.byteStream() val outputStream = FileOutputStream(tempFile) - inputStream?.copyTo(outputStream) + + inputStream?.copyToWithLimit(outputStream, maxSizeBytes.toLong()) outputStream.close() inputStream?.close() response.close() - if (tempFile.length() > maxSizeBytes) { - tempFile.delete() - Timber.e("Downloaded file exceeded 1.5MB limit.") - return null - } - return tempFile } catch (e: Exception) { Timber.e(e, "Exception while downloading attachment") From 182d86006e32a24596251ad3f55a75042249268c Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Sat, 28 Feb 2026 21:34:17 -0800 Subject: [PATCH 27/28] Changed cache timeout to 15min --- api/pkg/validators/validator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/pkg/validators/validator.go b/api/pkg/validators/validator.go index 850d1e76..3cd85de2 100644 --- a/api/pkg/validators/validator.go +++ b/api/pkg/validators/validator.go @@ -213,5 +213,5 @@ func validateAttachmentURL(ctx context.Context, c cache.Cache, attachmentURL str } func saveToCache(ctx context.Context, c cache.Cache, key string, value string) { - _ = c.Set(ctx, key, value, 24*time.Hour) + _ = c.Set(ctx, key, value, 15*time.Minute) } From c4f7e185582a39a8ef4f5283c8cf41e7ee4cb872 Mon Sep 17 00:00:00 2001 From: Jake Daynes Date: Sat, 28 Feb 2026 21:42:05 -0800 Subject: [PATCH 28/28] Centralised the content type check into message entities file --- api/pkg/entities/message.go | 17 +++++++++++++++++ api/pkg/handlers/discord_handler.go | 14 +------------- api/pkg/requests/bulk_message_request.go | 13 +------------ 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/api/pkg/entities/message.go b/api/pkg/entities/message.go index eb71f51b..6f0f75e6 100644 --- a/api/pkg/entities/message.go +++ b/api/pkg/entities/message.go @@ -1,6 +1,7 @@ package entities import ( + "strings" "time" "github.com/google/uuid" @@ -233,3 +234,19 @@ func (message *Message) updateOrderTimestamp(timestamp time.Time) { message.OrderTimestamp = timestamp } } + +func GetAttachmentContentType(url string) string { + // Since there's no easy way to set a type in the CSV, defaulting to octet-stream and then just checking the file extension in the URL + contentType := "application/octet-stream" + lowerURL := strings.ToLower(url) + if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") { + contentType = "image/jpeg" + } else if strings.HasSuffix(lowerURL, ".png") { + contentType = "image/png" + } else if strings.HasSuffix(lowerURL, ".gif") { + contentType = "image/gif" + } else if strings.HasSuffix(lowerURL, ".mp4") { + contentType = "video/mp4" + } + return contentType +} diff --git a/api/pkg/handlers/discord_handler.go b/api/pkg/handlers/discord_handler.go index 7be60f3b..729c6ce6 100644 --- a/api/pkg/handlers/discord_handler.go +++ b/api/pkg/handlers/discord_handler.go @@ -303,19 +303,7 @@ func (h *DiscordHandler) createRequest(payload map[string]any) requests.MessageS continue } - // Same as with bulk CSV attachments, can't easily ask for the MIME type so - // just inferring based on the file extension - contentType := "application/octet-stream" - lowerURL := strings.ToLower(cleanURL) - if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") { - contentType = "image/jpeg" - } else if strings.HasSuffix(lowerURL, ".png") { - contentType = "image/png" - } else if strings.HasSuffix(lowerURL, ".gif") { - contentType = "image/gif" - } else if strings.HasSuffix(lowerURL, ".mp4") { - contentType = "video/mp4" - } + contentType := entities.GetAttachmentContentType(cleanURL) attachments = append(attachments, entities.MessageAttachment{ ContentType: contentType, diff --git a/api/pkg/requests/bulk_message_request.go b/api/pkg/requests/bulk_message_request.go index 0ab6c024..a5aca7ce 100644 --- a/api/pkg/requests/bulk_message_request.go +++ b/api/pkg/requests/bulk_message_request.go @@ -43,18 +43,7 @@ func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID continue } - // Since there's no easy way to set a type in the CSV, defaulting to octet-stream and then just checking the file extension in the URL - contentType := "application/octet-stream" - lowerURL := strings.ToLower(cleanURL) - if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") { - contentType = "image/jpeg" - } else if strings.HasSuffix(lowerURL, ".png") { - contentType = "image/png" - } else if strings.HasSuffix(lowerURL, ".gif") { - contentType = "image/gif" - } else if strings.HasSuffix(lowerURL, ".mp4") { - contentType = "video/mp4" - } + contentType := entities.GetAttachmentContentType(cleanURL) attachments = append(attachments, entities.MessageAttachment{ ContentType: contentType,