Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
72 changes: 72 additions & 0 deletions migration/db/migrations/20260526100500000_add_pending_mail.sql
Original file line number Diff line number Diff line change
@@ -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 (`'<id>' = 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";
17 changes: 17 additions & 0 deletions spec/generator.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions spec/helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
140 changes: 140 additions & 0 deletions spec/pending_mail_spec.cr
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions src/placeos-models/pending_mail.cr
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions src/placeos-models/upload.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading