The SMS Gateway module replaces Odoo IAP for sending SMS messages. Instead of a cloud service, it uses physical Android phones as SMS gateways. Each phone is paired with Odoo via a QR code and automatically sends SMS messages from the queue.
| Component | Description |
|---|---|
sms_gateway |
Odoo module - manages phones, queue, API endpoints |
sms-gateway-app |
React Native (Expo) application for Android |
sms.gateway.phone |
Model representing a single registered phone |
sms.sms |
Extended with gateway_phone_id and gateway_state
|
sms, mass_mailing_sms,
phone_validation
qrcode (for QR code generation)pip install qrcode[pil]
sms_gateway module to the Odoo addons directory:
cp -r extra/sms/sms_modules/sms_gateway /path/to/odoo/addons/
sms_gateway module is mutually
exclusive with sms_httpsms. If you are using sms_httpsms,
uninstall it before installing sms_gateway.
Download the latest release APK from
GitHub Releases.
On your phone, open the downloaded .apk file and allow installation from unknown sources.
SEND_SMS and READ_SMS permissions, which require a special review.
Sideloading the APK from GitHub Releases is the intended distribution method.
Connect the phone via USB (with USB debugging enabled). From the
sms-gateway module root (extra/sms):
# Latest release
./scripts/install-release.sh
# A specific tag
./scripts/install-release.sh v1.3.0
# Re-download even if cached
./scripts/install-release.sh --force
Downloads app-release.apk from the GitHub release (cached per tag under
.release-cache/), installs it with adb install -r and grants the
SMS-limit permission. Requires gh (authenticated) and adb —
no build toolchain needed.
gh in PATH
Connect the phone via USB. From the sms-gateway module root (extra/sms):
./scripts/setup.sh # full flow: deps, prebuild, build, install
./scripts/setup.sh --clean # prebuild with --clean (after app.json / native changes)
./scripts/setup.sh --no-install # build only, skip ADB install
The script bootstraps nvm + Node 20 (installs them if missing), initializes the
sms-gateway-app submodule, detects the Android SDK, installs dependencies,
prebuilds, builds the release APK, installs it via ADB and grants
WRITE_SECURE_SETTINGS.
git submodule update --init --recursive # fetch sms-gateway-app sources
cd sms-gateway-app
# Install dependencies
npm install
# Prebuild native project (required for native modules)
npx expo prebuild
# Run on a connected phone
npx expo run:android
# OR build a release APK via EAS
eas build --profile production-apk --platform android
gh release create v1.x.x ./build/*.apk \
--repo Varyshop/sms-gateway-app \
--title "v1.x.x" \
--notes "Release notes"
| Permission | Purpose |
|---|---|
SEND_SMS |
Sending SMS without opening the app |
RECEIVE_SMS |
Reading incoming SMS (STOP detection) |
READ_PHONE_STATE |
Reading SIM card numbers |
READ_PHONE_NUMBERS |
Reading the phone number |
CAMERA |
Scanning QR codes |
| Field | Description | Example |
|---|---|---|
| Name | Phone name | Phone 1 - O2 |
| Phone Number | Main number (international format) | +420777123456 |
| Phone Number 2 | Second number (dual SIM, optional) | +420608987654 |
| Daily Limit | Max SMS per day | 500 |
| SMS per Minute | Max SMS per minute (rate limit) | 100 |
| Heartbeat Timeout | Minutes without heartbeat before going offline | 5 |
{
"type": "sms_gateway",
"url": "https://vas-odoo-server.cz",
"api_key": "nahodny_bezpecnostni_token"
}
Ahoj {{ object.name }}! Mate 20% slevu na vse.
Nakupte na https://www.example.cz/akce
STOP pro odhlaseni
https://vas-server.cz/r/ABC123/s/42). Original unsubscribe
links are replaced with the text STOP pro odhlaseni.
Use the Odoo domain filter to select recipients:
| Filter example | Description |
|---|---|
[("category_id", "in", [10])] |
Contacts with category ID 10 |
[("country_id.code", "=", "CZ")] |
Czech contacts |
[("customer_rank", ">", 0)] |
Customers only |
[("opt_out", "=", False)] |
Only those who have not unsubscribed |
Each gateway phone can have a Partner Domain Filter configured. This filter determines which SMS messages will be assigned to the given phone based on the recipient's partner.
sms.sms._send() assigns SMS to the queue, it searches for
available phones
domain_filter, it verifies whether
the recipient's partner matches the filter
| Scenario | Domain Filter | Explanation |
|---|---|---|
| Phone for VIP customers | [("category_id", "in", [10])] |
Only customers with category "VIP" (ID 10) |
| Phone for Prague | [("state_id.name", "=", "Praha")] |
Only customers from Prague |
| Phone for everyone | (empty) | Accepts SMS for all partners |
| Phone for companies | [("is_company", "=", True)] |
Companies only, not individuals |
failure_type='sms_server'). We recommend having at least one
phone without a filter as a fallback.
sms.sms records in the outgoing state
_send() assigns each SMS to a gateway phone and changes the state to
pending
If SMS records were not created automatically:
A campaign can be paused without cancelling it:
All URLs in the SMS body are automatically converted to short tracking links:
| Phase | Example |
|---|---|
| Original text | Nakupte na https://www.example.cz/akce |
| After conversion | Nakupte na https://vas-server.cz/r/ABC123 |
| After SMS ID assignment | Nakupte na https://vas-server.cz/r/ABC123/s/42 |
link.tracker record with a short code and UTM parameters
/s/{sms_id} appended to identify the specific recipient
mailing.trace (which recipient clicked)https://www.example.cz/akce?utm_campaign=Q1_2025&utm_medium=sms&utm_source=kampan
Automatically added on redirect:
| Parameter | Value | Source |
|---|---|---|
utm_campaign |
Campaign name | mailing.mailing.campaign_id |
utm_medium |
"sms" | mailing.mailing.medium_id |
utm_source |
Source name | mailing.mailing.source_id |
The system supports two ways to unsubscribe from SMS campaigns:
/sms-gateway/inbound
phone.blacklist
Odoo normally generates an unsubscribe URL in the format
/sms/{mailing_id}/{trace_code}. Our module replaces these URLs
with the text "STOP pro odhlaseni", because:
Odoo uses the phone.blacklist model from the
phone_validation module:
+420777123456)
SMS Marketing > Configuration > Phone Blacklist
| Metric | Description | How it's measured |
|---|---|---|
| Sent | Number of successfully sent SMS | sms.sms in state sent |
| Delivered | Delivered SMS | = sent + opened + replied |
| Clicked | Recipients who clicked a link | mailing.trace with links_click_datetime |
| Click Rate | Percentage of clicks | (unique clicks / total sent) × 100 |
| Bounced | Invalid numbers | mailing.trace in state bounce |
| Failed | Send error | mailing.trace in state error |
Each link in an SMS has its own link.tracker record:
| Limit | Where to set | Description |
|---|---|---|
| Daily Limit | sms.gateway.phone |
Max SMS per day per phone. The sent_today counter resets
at midnight.
|
| SMS per Minute | sms.gateway.phone |
Max SMS per minute. The app calculates the delay between SMS:
60000 / rate_limit ms.
|
| Android SMS limit | Android OS | Android by default limits to ~30 SMS per 30 minutes. This can be changed in settings. |
rate_limit on each gateway phone
(e.g. 100 SMS/min)
60000 / rate_limit milliseconds
daily_limit, no further SMS is assigned to it
adb shell settings put global sms_outgoing_check_max_count 10000
The system supports phones with two SIM cards:
phone_number and
phone_number_2
SimManager module
All endpoints require the X-API-Key header with a valid gateway phone API key.
Send a heartbeat and get the queue status.
// Request
{
"phone_numbers": ["+420777123456", "+420608987654"],
"battery_level": 85,
"signal_strength": -70
}
// Response
{
"success": true,
"pending_count": {"+420777123456": 12, "+420608987654": 5},
"rate_limit": 100
}
Get SMS waiting to be sent.
// Request
{"phone_numbers": ["+420777123456"], "limit": 20}
// Response
{
"success": true,
"sms_list": [
{
"id": 123,
"phone_number": "+420999888777",
"message": "Text SMS zpravy...",
"uuid": "abc-123-def",
"gateway_phone_number": "+420777123456"
}
]
}
Confirm the send status of an SMS.
// Request - uspech
{"status": "sent"}
// Request - chyba
{"status": "error", "error_message": "Permission denied"}
// Request - probihajici
{"status": "sending"}
// Response
{"success": true}
Report an incoming SMS (for STOP detection). The message is saved to the sms.gateway.inbound model and if it contains STOP, the number is added to the blacklist.
// Request
{
"from_number": "+420999888777",
"message": "STOP",
"to_number": "+420777123456"
}
// Response
{
"success": true,
"blacklisted": true,
"partner_found": true
}
Bulk report of multiple incoming SMS at once. Since v1.2.0 the app sends all received
SMS (not just STOP) to the server. The server deduplicates based on the combination from_number + to_number + message + received_at,
so resending the same messages (e.g. on inbox rescan) will not create duplicates.
// Request
{
"messages": [
{
"from_number": "+420999888777",
"message": "STOP",
"to_number": "+420777123456",
"received_at": "2026-03-22T10:30:00Z"
},
{
"from_number": "+420111222333",
"message": "Dekuji za informaci",
"to_number": "+420777123456",
"received_at": "2026-03-22T11:15:00Z"
}
]
}
// Response
{
"success": true,
"processed": 2,
"duplicates_skipped": 0
}
All incoming SMS are stored in the sms.gateway.inbound model:
| Field | Type | Description |
|---|---|---|
from_number |
Char | Sender number (E.164) |
to_number |
Char | Gateway phone number the SMS arrived at |
message |
Text | SMS message content |
received_at |
Datetime | Time the message was received on the phone |
partner_id |
Many2one | Partner found by sender number (may be empty) |
is_stop |
Boolean | Whether the message contains the STOP keyword |
is_blacklisted |
Boolean | Whether the sender number is on the blacklist |
gateway_phone_id |
Many2one | Reference to sms.gateway.phone |
Gateway phone statistics.
// Response
{
"success": true,
"phones": [
{
"id": 1,
"name": "Telefon 1 - O2",
"phone_number": "+420777123456",
"phone_number_2": null,
"state": "online",
"sent_today": 142,
"daily_limit": 500,
"sent_total": 4582,
"pending_count": 23,
"rate_limit": 100
}
]
}
Register an FCM token for push notifications. The phone sends its FCM token after pairing or when the token is renewed.
// Request
{
"fcm_token": "dK3xP9...dlouhy_firebase_token"
}
// Response - uspech
{
"success": true,
"message": "FCM token registered"
}
// Response - FCM neni povoleno
{
"success": true,
"message": "FCM not enabled, token ignored"
}
Bulk confirmation of the status of multiple SMS at once. More efficient than individual /confirm/{sms_id} calls.
// Request
{
"results": [
{"sms_id": 123, "status": "sent"},
{"sms_id": 124, "status": "sent"},
{"sms_id": 125, "status": "error", "error_message": "Number unreachable"}
]
}
// Response
{
"success": true,
"processed": 3
}
Since version v18.0.2.2.0 the module includes models for SMS marketing directly from the mobile app. An admin defines segments and templates in Odoo; an operator uses them from the app without backend access.
Defines a group of recipients (e.g. "No order in 3 months").
| Field | Type | Description |
|---|---|---|
name | Char | Segment name (translatable) |
code | Char, unique | Slug (no_order_3m, one_order_only, new_customers_30d) |
domain_filter | Char | Declarative Odoo domain, e.g. [("country_id.code", "=", "CZ")]. If set, it is used instead of code-based logic. |
sequence | Integer | Order in the list |
active | Boolean | Active/inactive |
description | Text | Description shown in the app |
| Code | Name | Logic |
|---|---|---|
no_order_3m | No order in 3 months | SQL: partners with an order but none in the last 90 days |
one_order_only | Single order only | SQL: partners with exactly 1 confirmed order |
new_customers_30d | New customers (30 days) | Domain: [("create_date", ">=", today - 30d)] |
| Method | Description |
|---|---|
_get_domain() |
Returns the basic Odoo domain of the segment. Uses domain_filter or dispatches to _domain_{code}(). |
_get_full_domain(phone, exclude_contacted_days) |
Single source of truth — assembles the complete domain: segment + blacklist + phone filter + exclusion of contacted partners. |
_get_storable_domain(phone, exclude_contacted_days) |
Returns a domain suitable for storage in mailing_domain. Declarative segments are stored as a readable domain; SQL segments are pre-resolved to ('id', 'in', [...]). |
_resolve_recipient_ids(phone, exclude_days, limit) |
Resolves the domain to a list of partner IDs. Used for counts and preview. |
_get_recipient_count(phone, exclude_days) |
Counts the number of matching partners. |
_get_exclusion_domain(days) |
Returns a declarative domain leaf ('stats_last_sms_days', '>', days) excluding partners contacted in the last N days. If days <= 0, returns an empty list (filter ignored). Since v18.0.2.3.0 it is fully declarative — no SQL on mailing_trace. |
_get_storable_domain()
combines 3 declarative filters into a single stored domain:
domain_filterdomain_filter from sms.gateway.phone('stats_last_sms_days', '>', exclude_contacted_days) from the templatemailing.mailing.mailing_domain as a pure declarative Odoo domain —
Odoo re-evaluates it on every send, without runtime SQL queries.
The exclusion leaf uses the computed field res.partner.stats_last_sms_days
with its own _search_stats_last_sms_days() handler (v18.0.2.4.0+),
which for operators >/>= returns an OR branch:
partners with a stats row satisfying the date or partners without a stats row
(('stats_id', '=', False)). This ensures that brand-new partners
without any SMS history satisfy the condition "not contacted in the last N days".
The previous fields.Integer(related='stats_id.last_sms_sent_days') variant
did NOT work, because Odoo converted the search to a LEFT JOIN via O2m, which filtered
NULL rows out (see changelog v18.0.2.4.0).
SMS template assigned to a gateway phone.
| Field | Type | Description |
|---|---|---|
name | Char | Template name |
body | Text | SMS text (may contain {{object.name}}) |
phone_id | Many2one → sms.gateway.phone | Assigned phone |
segment_ids | Many2many → sms.marketing.segment | Allowed segments |
default_limit | Integer (100) | Default suggested recipient count |
max_limit | Integer (500) | Hard cap on recipient count |
exclude_contacted_days | Integer (0) | Exclude partners contacted in the last N days |
| Field | Type | Description |
|---|---|---|
gateway_phone_forced_id | Many2one → sms.gateway.phone | Forced phone for all SMS in the campaign |
recipient_limit | Integer | Max number of recipients |
marketing_template_id | Many2one → sms.marketing.template | Back reference to the template |
created_from_app | Boolean | Created from the mobile app |
paused | Boolean | Paused campaign (not sending, but SMS are in the queue) |
Endpoints for the mobile app enabling creation and monitoring of SMS campaigns
without backend access. All endpoints: POST,
auth via X-API-Key, JSON body/response.
Returns templates assigned to the phone identified by the API key.
// Response
{
"success": true,
"templates": [
{
"id": 1,
"name": "Akce tento tyden",
"body": "Ahoj {{object.name}}! Mate slevu 20%...",
"segments": [
{"id": 1, "name": "Neobjednali 3 mesice", "code": "no_order_3m"},
{"id": 2, "name": "Jedina objednavka", "code": "one_order_only"}
],
"default_limit": 100,
"max_limit": 500,
"exclude_contacted_days": 14
}
]
}
For each segment, counts the number of matching recipients (including phone domain filter and exclusion).
// Request
{"template_id": 1}
// Response
{
"success": true,
"filters": [
{
"id": 1,
"code": "no_order_3m",
"name": "Neobjednali 3 mesice",
"description": "Partneri s objednavkou ale zadnou za 90 dni",
"recipient_count": 342
}
]
}
Campaign preview — recipient count and rendered text with a sample partner.
// Request
{"template_id": 1, "segment_id": 1, "limit": 200}
// Response
{
"success": true,
"recipient_count": 200,
"preview_text": "Ahoj Jan Novak! Mate slevu 20%...",
"template_name": "Akce tento tyden",
"segment_name": "Neobjednali 3 mesice"
}
Creates a mailing.mailing with full tracking. Accepts optional custom_body (text edit from the app) and a send_now switch.
// Request
{
"template_id": 1,
"segment_id": 1,
"limit": 200,
"custom_body": "Upraveny text SMS...",
"send_now": true
}
// Response
{
"success": true,
"campaign_id": 42,
"recipient_count": 200,
"state": "sending"
}
send_now=false, the campaign is created in the
in_queue state with paused=true. SMS are in the queue but are not sent
until the operator presses "Send now" in the app (endpoint assign-sim).
domain_filter
a readable domain is stored in mailing_domain (e.g. [("country_id.code", "=", "CZ"), ...]).
For SQL segments it is pre-resolved to a compact [("id", "in", [1, 2, 3, ...])].
Assigns a gateway phone and SIM to the SMS in the campaign. Uses the same logic as the "Send via Gateway" action in Odoo.
// Request — jedina SIM (nebo auto-prirazeni bez SIM cisla)
{
"campaign_id": 42,
"mode": "single",
"sim_number": "+420777123456"
}
// Request — rozdeleni mezi vice SIM
{
"campaign_id": 42,
"mode": "split",
"sim_numbers": ["+420777123456", "+420608987654"]
}
// Response
{
"success": true,
"assigned": 200,
"message": "Assigned 200 SMS to phone"
}
outgoing/error state (unassigned) and pending without a SIMsms_provider='gateway', gateway_phone_id, gateway_sim_numbersplit mode distributes SMS round-robin among all SIMsstate='sending', paused=FalseList of campaigns created from the app for the given phone.
// Response
{
"success": true,
"campaigns": [
{
"id": 42,
"name": "Akce tento tyden - 04.04.2026 10:30",
"state": "done",
"date_created": "2026-04-04 10:30:00",
"total": 200,
"sent": 195,
"pending": 0,
"error": 5,
"clicked": 42,
"total_clicks": 67,
"order_count": 8,
"revenue": 12400.0,
"optout": 3
}
]
}
Campaign detail with marketing statistics.
// Response
{
"success": true,
"id": 42,
"name": "Akce tento tyden - 04.04.2026 10:30",
"state": "done",
"total": 200,
"sent": 195,
"pending": 0,
"error": 5,
"clicked": 42,
"total_clicks": 67,
"order_count": 8,
"revenue": 12400.0,
"optout": 3,
"created_at": "2026-04-04 10:30:00"
}
| Field | Description | Source |
|---|---|---|
clicked | Unique recipients who clicked | mailing.trace with links_click_datetime |
total_clicks | Total click count (including repeated clicks) | link.tracker.click via UTM campaign |
order_count | Number of orders from the campaign | sale.order with campaign_id = UTM campaign |
revenue | Total revenue from orders | Sum of amount_total from matching orders |
optout | Number of unsubscribes (STOP) | mailing.trace in state cancel |
| Problem | Cause | Solution |
|---|---|---|
| Phone is still Offline | Heartbeat not reaching the server |
|
| SMS are not being sent | App does not have SEND_SMS permission | Open Android Settings > Apps > SMS Gateway > Permissions > allow SMS |
| SMS in queue but no phone is picking them up | No phone matches the domain_filter | Check domain_filter on gateway phones. At least one should be without a filter. |
| All SMS have error status | No gateway phone is online | Make sure the app is running, paired, and sending heartbeats |
| Daily limit reached too soon | sent_today counter | Increase daily_limit or add more phones. Reset happens at midnight (cron). |
| Android blocks SMS after 30 messages | Android hardware limit |
Use ADB command:
adb shell settings put global sms_outgoing_check_max_count
10000
|
| STOP does not add number to blacklist | App does not have RECEIVE_SMS permission | Enable RECEIVE_SMS permission in Android settings |
| Links in SMS don't work | Incorrect web.base.url configuration | In Odoo set the correct System Parameters > web.base.url (must be publicly accessible) |
| Campaign is not marked as Done | Some SMS still pending | Check that all SMS are in sent or error state. The campaign may be paused. |
| SMS are not sent when screen is locked | Android kills the app due to battery optimization |
|
| STOP SMS missing from blacklist | InboundSmsWorker failed or no network |
|
| Incoming SMS not reaching the server | InboundSmsWorker used incorrect authentication (Authorization: Bearer instead of X-API-Key) |
|
| FCM push not arriving | FCM is not correctly configured |
|
# Test heartbeat
curl -X POST https://vas-server.cz/sms-gateway/heartbeat \
-H "Content-Type: application/json" \
-H "X-API-Key: VAS_API_KLIC" \
-d '{"phone_numbers": ["+420777123456"]}'
# Overeni pending SMS
curl -X POST https://vas-server.cz/sms-gateway/pending \
-H "Content-Type: application/json" \
-H "X-API-Key: VAS_API_KLIC" \
-d '{"phone_numbers": ["+420777123456"], "limit": 5}'
# Test inbound STOP
curl -X POST https://vas-server.cz/sms-gateway/inbound \
-H "Content-Type: application/json" \
-H "X-API-Key: VAS_API_KLIC" \
-d '{"from_number": "+420999888777", "message": "STOP", "to_number": "+420777123456"}'
Since version v18.0.2.0.0 the module supports Firebase Cloud Messaging (FCM) for immediately waking up the phone when new SMS are in the queue. Instead of waiting for the next polling interval, the phone receives a push notification and immediately fetches pending SMS.
| System Parameter | Type | Description |
|---|---|---|
sms_gateway.fcm_enabled |
Boolean | Enables/disables FCM push notifications. Default: False |
sms_gateway.fcm_credentials_json |
Text (JSON) | Inline content of the Firebase service account JSON file. Takes precedence over fcm_credentials_path. |
sms_gateway.fcm_credentials_path |
Text (path) | Absolute path to the Firebase service account JSON file on the server. Used if fcm_credentials_json is not set. |
pip install firebase-admin
The firebase-admin library is automatically installed when the module is installed
(declared in external_dependencies in the manifest). If installation fails
(e.g. missing system libraries), the FCM feature silently deactivates and phones
continue in polling mode.
tools/fcm_service.py
The main function send_fcm_wake(phone) sends an FCM data message to the registered
phone. A data message (unlike a notification message) wakes the app in the background
without showing a notification to the user.
# Format FCM data message
{
"type": "sms_pending",
"phone_id": "7",
"timestamp": "1710751200"
}
| Field | Description |
|---|---|
type |
Always "sms_pending" — the app decides the action based on the type |
phone_id |
ID of the sms.gateway.phone record in Odoo |
timestamp |
Unix timestamp of sending — for deduplication on the app side |
fcm_token field on sms.gateway.phone) continue in polling mode
without any behavioral change. FCM is a purely additive enhancement — no existing
phone will stop working.
Android aggressively restricts background app activity to save battery. This section documents the strategies used in the SMS Gateway app for reliable operation even with a locked screen and in Doze mode.
For regular polling (heartbeat + fetching SMS) the app uses
AlarmManager with exact wakeup instead of
ScheduledExecutorService or Handler.postDelayed().
| Approach | Doze behavior | MIUI/HarmonyOS |
|---|---|---|
ScheduledExecutorService |
Stopped after ~1 min in Doze | Aggressively terminated |
Handler.postDelayed |
Stopped after ~1 min in Doze | Aggressively terminated |
AlarmManager.setExactAndAllowWhileIdle |
Wakes the device from Doze | Works even on MIUI (with battery exception) |
SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM is required.
The app requests it automatically on first launch.
Processing incoming SMS (STOP detection) uses WorkManager
(InboundSmsWorker) instead of direct HTTP calls from the BroadcastReceiver.
Reasons:
NetworkType.CONNECTED)
The app uses PowerManager.WakeLock in two places
to prevent the CPU from sleeping during critical operations:
| Component | WakeLock tag | Duration | Purpose |
|---|---|---|---|
FcmMessageHandler |
sms-gateway:fcm-wake |
Max 60 seconds | Keeps CPU awake during poll + SMS sending after FCM wake |
SmsBroadcastReceiver |
sms-gateway:inbound-sms |
Max 30 seconds | Keeps CPU awake during processing of incoming SMS and enqueue into WorkManager |
The foreground service notification uses a channel with IMPORTANCE_HIGH.
This ensures Android does not kill the service even under low memory conditions. The user
sees a persistent notification in the status bar (Android requirement for foreground
service).
On first app launch the user is asked to grant a battery optimization exception
(REQUEST_IGNORE_BATTERY_OPTIMIZATIONS). Without this exception Android may:
Since v1.2.0 the app includes a native method SmsModule.rescanInbox(days),
which reads the SMS inbox for the last N days (default: 30) and sends all found messages
to the server via the endpoint /sms-gateway/inbound-batch.
This feature is available to the user via the Re-scan received SMS button in Settings > Service. The server performs deduplication, so it is safe to run the rescan repeatedly.
// React Native volani
import { NativeModules } from 'react-native';
const messages = await NativeModules.SmsModule.rescanInbox(30);
// messages: Array<{from: string, body: string, date: number}>
// Nasledne se zpravy odeslou pres inbound-batch endpoint
To increase the native Android limit on the number of outgoing SMS, the app needs
the WRITE_SECURE_SETTINGS permission. This permission cannot be
granted from the phone's GUI — it requires an ADB connection:
# Primo pres ADB
adb shell pm grant com.varyshop.smsgatewayapp android.permission.WRITE_SECURE_SETTINGS
# Nebo pri buildu ze zdrojaku
yarn grant-permission
Even with FCM there can be situations where a push notification does not arrive (e.g. temporary Google service outage). Therefore the app maintains a heartbeat safety net:
pending_count for each numberpending_count > 0, the app immediately starts a poll cycle/sms-gateway/pending now checks the remaining daily/monthly capacity of the phone before issuing SMS. If the limit is exhausted, active campaigns are automatically paused and the response contains limit_reached: truePOST /campaign/pause/<id> and POST /campaign/resume/<id>. A paused campaign stays in state sending with paused=True — its SMS are not picked up in the pending endpoint (filter m.paused = false in the pickup query). Resume sets paused=False and the campaign continuesPOST /campaign/archive/<id> sets active=False. In the app available only in advanced mode for completed campaignsin_queue, sending and paused campaigns. Added "Completed" and "Archived" toggles (archived only in advanced mode). API parameters include_done and include_archived in /campaign/listin_queue state — previously it reappeared after leaving and returning to the screenstats_last_sms_days (on both res.partner and res.partner.stats) now returns -1 if the partner has no last_sms_sent_date. Previously it returned 0, which the UI could not distinguish from "contacted today" — the operator could not tell which was which. The value -1 gives an unambiguous signal in form view and in exports. Search semantics remain identical (> N still includes never-contacted partners via the OR branch, so campaigns with exclude_contacted_days > 0 will include brand-new partners and exclude only those contacted in the last N days).days=0, never contacted → days=-1, contacted 3 days ago → days=3. Search leaf ('stats_last_sms_days', '>', 30): today match=0, never match=1, 3d_ago match=0 — exactly as intended (campaign includes never-contacted + old, excludes today + recent).res.partner.stats_last_sms_days was a related field via O2m stats_id, so Odoo converted the domain search to a LEFT JOIN res_partner_stats and NULL rows (partners without a stats row) fell out. A campaign with exclude_contacted_days=30 would thus not receive even a single never-contacted partner — the exact opposite of intended behavior.
_search_stats_last_sms_days handler>/>= it returns an OR branch: partners with a stats row satisfying the date or partners without a stats row (('stats_id', '=', False))('stats_last_sms_days','>',30) returned 5 026 partners, after fix 14 467 (9 441 never contacted + 5 026 old)EXPLAIN ANALYZE: 8.98 ms execution (limit=20), partial index mailing_trace_sms_res_id_write_date_idx is index-only scan, 20 lookups instead of sequential scan — confirms feasibility at target 100k+/10k+ scale._update_last_sms_sent processed 14 750 partners in 1.19 s via execute_values bulk UPDATE.sync_attempts (0–2 always, 3–5 ~30s, 6–9 ~5min, 10+ ~30min)POST /sms-gateway/reconcile — returns already_confirmed_ids, stuck_ids, not_found_ids for synchronization on app startunsynced_count: Server logs a warning if unsynced SMS > 10AtomicBoolean isPollingActive protects against overlapping poll cyclesSELECT ... FOR UPDATE in _update_gateway_status prevents TOCTOU racesentReceiver: getOrPut → null-check (ignores late callbacks after sweep)sweepStaleTrackers requires strict equality sentParts == totalParts && failedParts == 0_cron_reset_stuck_gateway_sms resets SMS stuck > 30 min back to pending'failed' → 'error' (valid Odoo selection)_get_exclusion_domain() now returns
('stats_last_sms_days', '>', days) instead of an SQL query on mailing_trace.
Exclusion is part of the stored mailing_domain and Odoo applies it in a single search query
together with the segment and phone filter — eliminating large ('id', 'not in', [...]) clauses
and runtime SQL in mailing.mailing._get_recipients()
/campaign/filters now shows for each segment
the recipient count already after subtracting exclude_contacted_days (thanks to the declarative leaf it propagates automatically)
_search_last_sms_sent_days for operators >/>= now includes partners without SMS history (last_sms_sent_date IS NULL)res.partner.stats.last_sms_sent_date is now updated in real time on every successful SMS send (_touch_last_sms_sent called from sms.sms._update_gateway_status). Previously the field was only updated by a nightly cron, causing duplicate SMS to the same recipients within the same day — the exclusion filter saw stale values_update_last_sms_sent now reads from mailing_trace states ('pending', 'sent', 'open', 'reply') instead of only 'sent' (in Odoo 18 'sent' means "delivered", not "sent")/sms-gateway/pending now just before issuing SMS to the phone cancels records whose recipient has been contacted by another campaign within the exclude_contacted_days window in the meantime. Solves race condition: campaign A created at 10:00 with 10 000 recipients, before sending finishes B sends SMS to some of the same recipients at 11:00. The check is optimized for large databases (100k+ contacts, 10k+ per campaign):
limit SMS (typically 20) ordered ORDER BY id ASC — same as the pickup query. Does not check the entire campaign, only the next batchmailing_trace with filter mt.mass_mailing_id != s.mailing_id (not self)mailing_trace_sms_res_id_write_date_idx created in post_init_hook on columns (res_id, write_date) with WHERE clause trace_type='sms' AND trace_status IN ('pending','sent','open','reply') — EXISTS is an index-only scanlimit index lookups (<1 ms) instead of sequential scan of entire mailing_tracefailure_type='sms_duplicate' — visible in Odoo UIRecompute Last SMS Sent on model res.partner (available from list/form view) — manual recomputation of last_sms_sent_date from mailing_trace. Parallel to the existing Recompute Order Stats actionstats_last_sms_sent and stats_last_sms_days, so it is visible why a partner is in/out of the exclusion filtercampaign/status/campaign/list now counts trace_status IN ('pending', 'sent', 'open', 'reply') same as Odoo UIsend_now=truesms.marketing.segment with declarative and SQL-based domains (3 default segments)sms.marketing.template assigned to gateway phones('id', 'in', [...])sms.gateway.phone.domain_filter is automatically combined with the segment domain in _get_full_domain()mailing_traceemail_from NOT NULL error when creating an SMS campaigngateway_phone_forced_id as fallback in sms.sms._send()SMS Gateway v18.0.2.6.0 • VaryShop • 2026