Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
072be0c
Added Attachment to Message entity
whiteboxsolutions Feb 27, 2026
09cf30b
Added Attachments to APISentPayload & send request
whiteboxsolutions Feb 27, 2026
69ecce1
Added validator for attachment urls
whiteboxsolutions Feb 27, 2026
b5bfdb4
Added attachment to bulk message struct
whiteboxsolutions Feb 27, 2026
53112cb
Added same validation to bulk message
whiteboxsolutions Feb 27, 2026
34b2cfb
Added attachmenturls to the BulkMessage struct for csv support and a …
whiteboxsolutions Feb 27, 2026
bc5faf1
Added attachment parsing to the xlsx parser
whiteboxsolutions Feb 27, 2026
8497498
Added validation to csv based bulk messages
whiteboxsolutions Feb 27, 2026
569b56d
Added attachment_urls to discord slash command
whiteboxsolutions Feb 27, 2026
a0fc868
Added attachment file type check to CreateRequest
whiteboxsolutions Feb 27, 2026
2f6c94a
Added embed for discord confirmation
whiteboxsolutions Feb 27, 2026
64a40c7
Defined attachment
whiteboxsolutions Feb 27, 2026
173a4f1
Added a sendMultimediaMessage function
whiteboxsolutions Feb 27, 2026
8a19b1d
Added a filesize validation to make sure we're under 1.5MB
whiteboxsolutions Feb 27, 2026
40f718c
Added missing validation to bulk send validator
whiteboxsolutions Feb 27, 2026
f2a0ab8
Added cache for csv uploads to avoid 1000s of duplicate http requests
whiteboxsolutions Feb 27, 2026
3829d74
Added provider to manifest
whiteboxsolutions Feb 27, 2026
41c7bed
File path for mms attachments/cache
whiteboxsolutions Feb 27, 2026
e8140cd
added missing cache arguments
whiteboxsolutions Feb 27, 2026
b35f60d
Added web support for attachments
whiteboxsolutions Feb 27, 2026
adfa171
Added PDU generation via android-smsmms and MMS sender/handler
whiteboxsolutions Feb 27, 2026
5508157
Added annotations for Codacy
whiteboxsolutions Feb 27, 2026
a8c976b
Fixed imports and missing comma
whiteboxsolutions Mar 1, 2026
cafaab9
Updated UI to show attachments
whiteboxsolutions Mar 1, 2026
47dde30
Updated templates
whiteboxsolutions Mar 1, 2026
2b77ce4
Added a custom copyTo to cancel downloads as soon as they breach the …
whiteboxsolutions Mar 1, 2026
182d860
Changed cache timeout to 15min
whiteboxsolutions Mar 1, 2026
c4f7e18
Centralised the content type check into message entities file
whiteboxsolutions Mar 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 11 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@
</intent-filter>
</receiver>

<!-- Need this to share the attachment images with native mms service -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>

<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/notification_channel_default" />
Expand Down
113 changes: 113 additions & 0 deletions android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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_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]
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Expand Down Expand Up @@ -158,13 +165,119 @@ 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())
}
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<java.io.File>()

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<String>): Result {
Timber.d("sending multipart SMS for message with ID [${message.id}]")
return try {
Expand Down
68 changes: 68 additions & 0 deletions android/app/src/main/java/com/httpsms/HttpSmsApiService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ 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
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream


class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
Expand Down Expand Up @@ -156,6 +161,69 @@ 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()

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number duplicated in api/pkg/validators/validator.go:203. Consider defining as a constant in a shared location.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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?.copyToWithLimit(outputStream, maxSizeBytes.toLong())

outputStream.close()
inputStream?.close()
response.close()

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"
Expand Down
12 changes: 11 additions & 1 deletion android/app/src/main/java/com/httpsms/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ data class Phone (
val userID: String,
)

// mms attachment
data class Attachment (
@Json(name = "content_type")
val contentType: String,

val url: String
)

data class Message (
val contact: String,
val content: String,
Expand Down Expand Up @@ -68,5 +76,7 @@ data class Message (
val type: String,

@Json(name = "updated_at")
val updatedAt: String
val updatedAt: String,

val attachments: List<Attachment>? = null
)
23 changes: 23 additions & 0 deletions android/app/src/main/java/com/httpsms/SentReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ 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) {
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")
Expand All @@ -27,6 +30,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
Expand Down
6 changes: 6 additions & 0 deletions android/app/src/main/java/com/httpsms/SmsManagerService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,10 @@ class SmsManagerService {
context.getSystemService(SmsManager::class.java).createForSubscriptionId(subscriptionId)
}
}

// 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)
}
}
4 changes: 4 additions & 0 deletions android/app/src/main/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="mms_cache" path="mms_attachments/" />
</paths>
2 changes: 2 additions & 0 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes
container.Tracer(),
container.PhoneService(),
container.TurnstileTokenValidator(),
container.Cache(),
)
}

Expand All @@ -556,6 +557,7 @@ func (container *Container) BulkMessageHandlerValidator() (validator *validators
container.Tracer(),
container.PhoneService(),
container.UserService(),
container.Cache(),
)
}

Expand Down
41 changes: 32 additions & 9 deletions api/pkg/entities/message.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package entities

import (
"strings"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -81,17 +82,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
Expand Down Expand Up @@ -227,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
}
Loading