Summary#
use Osysharp.Sms@0; gives an app one way to send a text message, whichever carrier it ends up using:
SendSms(new SmsMessage { To = booking.Mobile, Text = $"See you at {booking.StartsAt:HH:mm}." }, $"reminder-{booking.Id}");The message is a row in the outbox, written in the same commit as whatever it is about — so a text about a change
that was rolled back is never sent — and delivered after that commit through the sender app.Sms names.
Signature#
use Osysharp.Sms@0; // with Osysharp.Identity@0 and Osysharp.Workflow
OutgoingSms SendSms(SmsMessage message, string? idempotencyKey = null);
string NormalisedPhone(string number); // "+46 70-123 45 67" → "+46701234567"; "" when not E.164
bool IsPhoneNumber(string number);
app.Sms = new SmsSetup { Sender = <an ISmsSender>, From = "Acme" };
public interface ISmsSender {
string Key { get; }
bool IsReady();
string SendSms(SmsMessage message, string idempotencyKey);
string NotReadyReason() => $"The text-message sender '{Key}' is not ready — its credentials are not set."; // a default
}Description#
What does a message carry, and which numbers are accepted?#
SmsMessage has From (a number the app owns, or a sender name of up to eleven letters and digits — which cannot be
replied to), To and Text. To must be E.164 — a +, the country code and the number. SendSms normalises what
people type (+46 70-123 45 67, 0046 70 123 45 67) and refuses a local form (0701234567) where the message is
written, naming the missing country code; a carrier's own refusal would arrive later and name a parameter.
What happens to a message after SendSms?#
OutgoingSms is the delivery record: Status is Queued, then Sent (with SentAt, SentWith — the sender's Key —
and ProviderId, the carrier's id) or Held (with Problem saying why). A sender that throws is retried by the delivery
workflow.
- No sender, or one not ready: the message is Held, with the sender's own
NotReadyReason(). The app keeps working, and the held codes are there to read. idempotencyKey: the same key twice is ONE message, so a handler that runs twice texts once.
Who may send, and who may read the outbox?#
The outbox grants only the sign-in flow (IsAuthenticator, which texts one-time codes). An app grants its own people
with a partial:
partial entity OutgoingSms { security { allow create, read when IsAgent; } }Nobody else can put a row there — so nobody can text strangers at the app's expense — and nobody reads it who was not granted, not even the recipient: a one-time code sits in those rows, and reading it out of the outbox would skip the phone it proves.
Forgetting a number#
EraseSmsTo(number) deletes every text the outbox holds to that number (written any way — it is normalised first) —
for a person exercising their right to erasure. It runs with the caller's rights; the app decides who may ask.
Osysharp.Conversations calls it through its text channel when a customer is forgotten.
Which carriers can send, and how is one chosen?#
A sender implements ISmsSender: Key, IsReady() (false leaves messages held) and SendSms(message, idempotencyKey),
answering the carrier's id. NotReadyReason() has a default ([[class-interfaces#defaults]]); a sender that knows which
credential is missing says so. osy kits Osysharp.Sms lists the implementations the project you run it in can see, and
which one this app wired:
Generated from the kits in scope (osy kits Osysharp.Sms lists the same):
ElksSmsSender—Osysharp.Sms.FortySixElks(use Osysharp.Sms.FortySixElks@0;)
Osysharp.Sms.FortySixElks takes the API username and password from the app's own secrets, and DryRun = true prices every
message without sending it — for a staging app that must never text a real person:
use Osysharp.Sms.FortySixElks@0 { egress "api.46elks.com"; }
use Osysharp.FortySixElks@0 { egress "api.46elks.com"; }
app.Sms = new SmsSetup {
Sender = new ElksSmsSender {
Username = Security.IsSecretSet("ElksUsername") ? Secret.ElksUsername : "",
Password = Security.IsSecretSet("ElksPassword") ? Secret.ElksPassword : "",
},
From = "Acme",
};
app.Secrets = [ new Secret("ElksUsername"), new Secret("ElksPassword") ];Is a texted code a second factor?#
Not one an app can require. A code by text proves a number today; a SIM swap or a port-out moves the number to somebody else (NIST 800-63B lists SMS as "restricted"). The UserAccounts kit — sign-in done properly, once uses this kit to prove a number, to sign in with a texted code, and as a second step an account may CHOOSE — and still asks an account the app requires a second factor of for an authenticator app.
Examples#
Text a customer a reminder through 46elks, and keep the record#
app Bookings {
use Osysharp.Workflow;
use Osysharp.Identity;
use Osysharp.Sms;
use Osysharp.Sms.FortySixElks { egress "api.46elks.com"; }
use Osysharp.FortySixElks { egress "api.46elks.com"; }
use Osysharp.Http;
model "model/**/*.osy";
}
using Osysharp.Identity;
using Osysharp.Sms;
using Osysharp.Sms.FortySixElks;
[Role] enum AppRole { Authenticator, Clerk }
policy IsClerk => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Clerk);
app.Sms = new SmsSetup {
Sender = new ElksSmsSender {
Username = Security.IsSecretSet("ElksUsername") ? Secret.ElksUsername : "",
Password = Security.IsSecretSet("ElksPassword") ? Secret.ElksPassword : "",
},
From = "Acme",
};
app.Secrets = [ new Secret("ElksUsername"), new Secret("ElksPassword") ];
// The app's clerks may send, and read what was sent.
partial entity OutgoingSms { security { allow create, read when IsClerk; } }
entity Booking {
[Required, MaxLength(20)] string Mobile;
[MaxLength(40)] string? ReminderStatus;
security { allow read, create, update when IsClerk; }
}
void Remind(Booking booking) {
var sms = SendSms(new SmsMessage { To = booking.Mobile, Text = "Your table is ready at seven." }, $"reminder-{booking.Id}");
booking.ReminderStatus = sms.Status.ToString();
}See also#
- The Mail kit — send a message, keep a record, pick a sender — the same shape for mail
- The UserAccounts kit — sign-in done properly, once — one-time codes by text: a proven number, a sign-in code, an optional second step
- Interfaces — a contract several types can satisfy — a contract grows by a member with a default