Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The Sms kit — send a text message, keep a record, pick a carrier

use Osysharp.Sms@0; · SendSms(new SmsMessage { To = "+46…", Text = … }) · app.Sms = new SmsSetup { Sender = …, From = … };

`Osysharp.Sms` is the contract between an app that sends text messages and whatever carries them, shaped like the Mail kit. `SendSms` writes the message to an outbox in the caller's own commit; a workflow delivers it after the commit through the app's sender, retrying a sender that is down, and HOLDS it — kept, with the reason — when there is no sender. A number is written E.164 or refused where it is written. A sender is an interface: `Osysharp.Sms.FortySixElks` (46elks) is one.

preview1 example compiled by CIsmskitintegration

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#

Related

The Mail kit — send a message, keep a record, pick a sender

`Osysharp.Mail` is the contract between an app that sends mail and whatever carries it. `SendMail` writes the message…

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…

The Identity kit — who signs in, and which of the app's roles they hold

`Osysharp.Identity` is the contract every people-aware kit builds on: `Principal`, the one `[Principal]` of the app (an…

Interfaces — a contract several types can satisfy

An `interface` declares what a type must do without saying how — methods and property contracts, and no state. A member…