diff --git a/migration/db/migrations/20260526100500000_add_pending_mail.sql b/migration/db/migrations/20260526100500000_add_pending_mail.sql new file mode 100644 index 00000000..79246e66 --- /dev/null +++ b/migration/db/migrations/20260526100500000_add_pending_mail.sql @@ -0,0 +1,72 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- --------------------------------------------------------------------------- +-- PendingMail: emails queued for later processing/sending. Belongs to an +-- authority and to the user who triggered the mail. `args` carries the JSON +-- scalar values injected into the template. `attachments` / +-- `resource_attachments` hold Upload ids; `zones` holds zone ids. Those id +-- arrays are pruned at the model layer when the referenced upload/zone is +-- deleted (no DB-level FK on array elements). +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS "pending_mail"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + authority_id TEXT NOT NULL REFERENCES "authority"(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + + expiry TIMESTAMPTZ, + + send_to TEXT[] NOT NULL DEFAULT '{}', + template TEXT[] NOT NULL DEFAULT '{}', + args JSONB NOT NULL DEFAULT '{}'::jsonb, + + resource_attachments TEXT[] NOT NULL DEFAULT '{}', + attachments TEXT[] NOT NULL DEFAULT '{}', + + cc TEXT[] NOT NULL DEFAULT '{}', + bcc TEXT[] NOT NULL DEFAULT '{}', + send_from TEXT, + reply_to TEXT, + + zones TEXT[] NOT NULL DEFAULT '{}', + + -- monitoring fields, populated as the mail is processed + sent_at TIMESTAMPTZ, + sent_by TEXT, + rejected_at TIMESTAMPTZ, + rejected_reason TEXT, + + -- provenance, indexed for filtering + source_service TEXT, + source_reference TEXT, + + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + + CHECK (jsonb_typeof(args) = 'object') +); + +CREATE INDEX IF NOT EXISTS pending_mail_authority_id_index + ON "pending_mail" USING BTREE (authority_id); +CREATE INDEX IF NOT EXISTS pending_mail_user_id_index + ON "pending_mail" USING BTREE (user_id); + +-- GIN indexes support array containment/overlap lookups used by the +-- upload/zone cleanup callbacks (`'' = ANY(column)`). +CREATE INDEX IF NOT EXISTS pending_mail_attachments_index + ON "pending_mail" USING GIN (attachments); +CREATE INDEX IF NOT EXISTS pending_mail_resource_attachments_index + ON "pending_mail" USING GIN (resource_attachments); +CREATE INDEX IF NOT EXISTS pending_mail_zones_index + ON "pending_mail" USING GIN (zones); + +-- provenance lookups for filtering +CREATE INDEX IF NOT EXISTS pending_mail_source_service_index + ON "pending_mail" USING BTREE (source_service); +CREATE INDEX IF NOT EXISTS pending_mail_source_reference_index + ON "pending_mail" USING BTREE (source_reference); + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP TABLE IF EXISTS "pending_mail"; diff --git a/spec/generator.cr b/spec/generator.cr index d02f2525..eea30157 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -947,5 +947,22 @@ module PlaceOS::Model playlist_item_id: pi.id.not_nil!, ) end + + def self.pending_mail( + authority : Authority? = nil, + user : User? = nil, + send_to : Array(String) = ["recipient@place.technology"], + ) + authority ||= Authority.find_by_domain("localhost") || self.authority.save! + user ||= self.user(authority: authority).save! + + PendingMail.new( + authority_id: authority.id.not_nil!, + user_id: user.id.not_nil!, + send_to: send_to, + template: ["welcome", "email"], + args: {"name" => "Jen", "count" => 3_i64} of String => (String | Int64 | Float64 | Bool | Nil), + ) + end end end diff --git a/spec/helper.cr b/spec/helper.cr index d9180490..1a5a57c6 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -26,6 +26,7 @@ Spec.after_suite do # Models that inherit directly from ::PgORM::Base (not ModelBase) — # cleared in dependency order (children first so FKs don't fire). [ + PlaceOS::Model::PendingMail, PlaceOS::Model::GroupHistory, PlaceOS::Model::GroupInvitation, PlaceOS::Model::GroupZone, diff --git a/spec/pending_mail_spec.cr b/spec/pending_mail_spec.cr new file mode 100644 index 00000000..9df27c88 --- /dev/null +++ b/spec/pending_mail_spec.cr @@ -0,0 +1,140 @@ +require "./helper" + +module PlaceOS::Model + describe PendingMail do + Spec.before_each do + PendingMail.clear + Asset.clear + Upload.clear + Zone.clear + User.clear + end + + it "saves and round-trips a pending mail" do + mail = Generator.pending_mail + mail.cc = ["cc@place.technology"] + mail.send_from = "Sender@Place.Technology" + mail.reply_to = "reply@place.technology" + mail.expiry = Time.utc(2030, 1, 1) + mail.sent_at = Time.utc(2026, 1, 2) + mail.sent_by = "signage-worker-1" + mail.source_service = "signage" + mail.source_reference = "ref-1234" + mail.save! + + found = PendingMail.find!(mail.id.not_nil!) + found.send_to.should eq ["recipient@place.technology"] + found.cc.should eq ["cc@place.technology"] + found.template.should eq ["welcome", "email"] + found.args["name"].should eq "Jen" + found.args["count"].should eq 3_i64 + found.send_from.should eq "Sender@Place.Technology" + found.authority_id.should eq mail.authority_id + found.user_id.should eq mail.user_id + found.expiry.should eq Time.utc(2030, 1, 1) + + # monitoring fields default empty, persist when set + found.sent_at.should eq Time.utc(2026, 1, 2) + found.sent_by.should eq "signage-worker-1" + found.rejected_at.should be_nil + found.rejected_reason.should be_nil + + # provenance fields + found.source_service.should eq "signage" + found.source_reference.should eq "ref-1234" + end + + it "resolves authority and user associations" do + mail = Generator.pending_mail.save! + mail.authority.id.should eq mail.authority_id + mail.user.id.should eq mail.user_id + end + + it "requires at least one recipient" do + mail = Generator.pending_mail(send_to: [] of String) + mail.save.should eq false + mail.errors.any? { |e| e.field == :send_to }.should eq true + end + + it "validates recipient email formats" do + mail = Generator.pending_mail(send_to: ["not-an-email"]) + mail.save.should eq false + mail.errors.any? { |e| e.field == :send_to }.should eq true + end + + it "validates cc, send_from and reply_to email formats" do + mail = Generator.pending_mail + mail.cc = ["nope"] + mail.send_from = "also-bad" + mail.reply_to = "still-bad" + mail.save.should eq false + + fields = mail.errors.map(&.field) + fields.should contain(:cc) + fields.should contain(:send_from) + fields.should contain(:reply_to) + end + + it "rejects non-scalar (nested) args on deserialization" do + authority = Authority.find_by_domain("localhost").as(Authority) + user = Generator.user(authority: authority).save! + + json = %({ + "authority_id": "#{authority.id}", + "user_id": "#{user.id}", + "send_to": ["a@place.technology"], + "args": {"nested": {"not": "allowed"}} + }) + + expect_raises(JSON::ParseException) do + PendingMail.from_json(json) + end + end + + it "prunes the zone id from zones arrays when a zone is deleted" do + zone = Generator.zone.save! + zone_id = zone.id.as(String) + + mail = Generator.pending_mail + mail.zones = [zone_id] + mail.save! + + asset = Generator.asset + asset.zones = [zone_id] + asset.save! + + zone.destroy + + PendingMail.find!(mail.id.not_nil!).zones.should_not contain(zone_id) + Asset.find!(asset.id.as(String)).zones.should_not contain(zone_id) + end + + it "prunes the upload id from attachment arrays when an upload is deleted" do + authority = Authority.find_by_domain("localhost").as(Authority) + user = Generator.user(authority: authority).save! + + # build an upload with no storage so destroy skips the cloud delete + upload = Upload.new( + uploaded_by: user.id, + uploaded_email: user.email, + file_name: "doc.pdf", + file_size: 1024_i64, + file_md5: "abc123", + object_key: "object-key", + ) + upload.save! + upload_id = upload.id.as(String) + + mail = Generator.pending_mail(authority: authority, user: user) + mail.attachments = [upload_id] + mail.resource_attachments = [upload_id] + mail.save! + + upload.destroy + + found = PendingMail.find!(mail.id.not_nil!) + found.attachments.should_not contain(upload_id) + found.resource_attachments.should_not contain(upload_id) + end + end +end diff --git a/src/placeos-models/pending_mail.cr b/src/placeos-models/pending_mail.cr new file mode 100644 index 00000000..59ae36b1 --- /dev/null +++ b/src/placeos-models/pending_mail.cr @@ -0,0 +1,98 @@ +require "uuid" +require "uuid/json" + +require "./base/model" +require "./authority" +require "./user" +require "./email" + +module PlaceOS::Model + # An email queued for later processing/sending. Each row belongs to an + # authority and to the user who triggered it. The actual content is + # described by a `template` (a list of names resolving to a template) plus + # `args` injected into it. `attachments`/`resource_attachments` reference + # `Upload` ids; those ids are pruned automatically when an upload is + # deleted (see `Upload#remove_from_pending_mail`). `zones` references zone + # ids and is pruned when a zone is deleted (see `Zone#remove_array_references`). + class PendingMail < ::PgORM::Base + include PgORM::Timestamps + + table :pending_mail + + default_primary_key id : UUID, autogenerated: true + + # after which the email should not be sent + attribute expiry : Time? = nil + + # recipients + attribute send_to : Array(String) = [] of String + + # names which resolve to the template to be sent + attribute template : Array(String) = [] of String + + # values injected into the template. Restricted to JSON scalar values; + # nested objects/arrays are rejected on deserialization. + attribute args : Hash(String, String | Int64 | Float64 | Bool | Nil) = {} of String => (String | Int64 | Float64 | Bool | Nil) + + # upload id references. If an upload is deleted, its id is removed from + # these arrays via `Upload`'s before_destroy callback. + attribute resource_attachments : Array(String) = [] of String + attribute attachments : Array(String) = [] of String + + attribute cc : Array(String) = [] of String + attribute bcc : Array(String) = [] of String + attribute send_from : String? = nil + attribute reply_to : String? = nil + + # zone id references. If a zone is deleted, its id is removed from this + # array via `Zone`'s before_destroy callback. + attribute zones : Array(String) = [] of String + + # Monitoring: populated as the mail is processed. `sent_by` and + # `rejected_reason` are free-form text (e.g. a worker/service name or a + # human-readable reason). + attribute sent_at : Time? = nil + attribute sent_by : String? = nil + attribute rejected_at : Time? = nil + attribute rejected_reason : String? = nil + + # Free-form provenance, indexed for filtering (e.g. which service queued + # the mail and an external reference/correlation id). + attribute source_service : String? = nil + attribute source_reference : String? = nil + + attribute authority_id : String + belongs_to :authority, class_name: PlaceOS::Model::Authority + + attribute user_id : String + belongs_to :user, class_name: PlaceOS::Model::User + + # Validation + ############################################################################################### + + validates :authority_id, presence: true + validates :user_id, presence: true + + validate ->(this : PendingMail) { + this.validation_error(:send_to, "requires at least one recipient") if this.send_to.empty? + + this.validate_emails(:send_to, this.send_to) + this.validate_emails(:cc, this.cc) + this.validate_emails(:bcc, this.bcc) + + if send_from = this.send_from.presence + this.validation_error(:send_from, "'#{send_from}' is not a valid email") unless Email.new(send_from).valid? + end + + if reply_to = this.reply_to.presence + this.validation_error(:reply_to, "'#{reply_to}' is not a valid email") unless Email.new(reply_to).valid? + end + } + + protected def validate_emails(field : Symbol, addresses : Array(String)) + addresses.each do |address| + validation_error(field, "'#{address}' is not a valid email") unless Email.new(address).valid? + end + end + end +end diff --git a/src/placeos-models/upload.cr b/src/placeos-models/upload.cr index 190fc2ad..33135b25 100644 --- a/src/placeos-models/upload.cr +++ b/src/placeos-models/upload.cr @@ -76,5 +76,17 @@ module PlaceOS::Model signer = UploadSigner.signer(UploadSigner::StorageType.from_value(cloud_fs.storage_type.value), cloud_fs.access_key, cloud_fs.decrypt_secret, cloud_fs.region, endpoint: cloud_fs.endpoint) signer.delete_file(cloud_fs.bucket_name, self.object_key, self.resumable_id) end + + before_destroy :remove_from_pending_mail + + # Prune this upload's id from any pending_mail attachment arrays + protected def remove_from_pending_mail + ::PgORM::Database.exec_sql(<<-SQL, self.id) + UPDATE pending_mail + SET attachments = array_remove(attachments, $1), + resource_attachments = array_remove(resource_attachments, $1) + WHERE $1 = ANY(attachments) OR $1 = ANY(resource_attachments) + SQL + end end end diff --git a/src/placeos-models/zone.cr b/src/placeos-models/zone.cr index 3d09ef96..315d949c 100644 --- a/src/placeos-models/zone.cr +++ b/src/placeos-models/zone.cr @@ -120,14 +120,20 @@ module PlaceOS::Model # Callbacks ############################################################################################### - before_destroy :remove_zone + before_destroy :remove_array_references before_save :check_triggers after_save :update_triggers - # Removes self from ControlSystems - protected def remove_zone + # Tables (other than `sys`) holding a `zones` text-array that references + # zone ids. Rows are pruned in-place when this zone is destroyed. + ZONE_ARRAY_REFERENCES = {"asset", "pending_mail"} + + # Prune this zone's id from every array column that references zones. + # ControlSystems get special handling (a version bump so caches / + # change-feeds invalidate); the remaining tables are updated in-place. + protected def remove_array_references self.systems.try &.each do |cs| zones = cs.zones if zones @@ -139,6 +145,15 @@ module PlaceOS::Model cs.save! end end + + zone_id = self.id + ZONE_ARRAY_REFERENCES.each do |table| + ::PgORM::Database.exec_sql(<<-SQL, zone_id) + UPDATE #{table} + SET zones = array_remove(zones, $1) + WHERE $1 = ANY(zones) + SQL + end end private property remove_triggers : Array(String) { [] of String }