com.mchange.feedletter.SubscriptionManager.scala Maven / Gradle / Ivy
package com.mchange.feedletter
import com.mchange.feedletter.style.*
import java.sql.Connection
import java.time.{LocalDate,Instant}
import java.time.format.DateTimeFormatter
import java.time.temporal.{ChronoField,WeekFields}
import DateTimeFormatter.ISO_LOCAL_DATE
import scala.collection.immutable
import com.mchange.feedletter.api.ApiLinkGenerator
import com.mchange.feedletter.style.Customizer
import scala.collection.immutable
import com.mchange.conveniences.string.*
import com.mchange.conveniences.collection.*
import com.mchange.feedletter.db.PgDatabase
import scala.util.control.NonFatal
import MLevel.*
import upickle.default.*
import java.time.ZoneId
import com.mchange.feedletter.Destination.Key
object SubscriptionManager extends SelfLogging:
val Json = SubscriptionManagerJson
type Json = SubscriptionManagerJson
sealed trait UntemplatedCompose extends SubscriptionManager:
def composeUntemplateName : String
def withComposeUntemplateName( name : String ) : UntemplatedCompose
def isComposeMultiple : Boolean
sealed trait UntemplatedConfirm extends SubscriptionManager:
def confirmUntemplateName : String
def withConfirmUntemplateName( name : String ) : UntemplatedConfirm
sealed trait UntemplatedStatusChange extends SubscriptionManager:
def statusChangeUntemplateName : String
def withStatusChangeUntemplateName( name : String ) : UntemplatedStatusChange
sealed trait UntemplatedRemovalNotification extends SubscriptionManager:
def removalNotificationUntemplateName : String
def withRemovalNotificationUntemplateName( name : String ) : UntemplatedRemovalNotification
sealed trait PeriodBased extends SubscriptionManager:
def timeZone : Option[ZoneId]
override def bestTimeZone( conn : Connection ) : ZoneId = timeZone.getOrElse( PgDatabase.Config.timeZone( conn ) )
sealed trait SupportsExternalSubscriptionApi extends SubscriptionManager:
/**
* This method must either:
*
* * Send a notification that will lead to a future confirmation by the user, and return `true`
*
* OR
*
* * Update the the confirmed field of the subscription to already `true`, and then return false
*
* @param conn
* @param destination
* @param subscribableName
* @param confirmGetLink
*
* @return whether a subscriber has been prompted for a future confirmation
*/
def maybePromptConfirmation( conn : Connection, as : AppSetup, subscriptionId : SubscriptionId, subscribableName : SubscribableName, destination : this.D, confirmGetLink : String, removeGetLink : String ) : Boolean
def maybeSendRemovalNotification( conn : Connection, as : AppSetup, subscriptionId : SubscriptionId, subscribableName : SubscribableName, destination : this.D, createGetLink : String ) : Boolean
def htmlForStatusChange( statusChangeInfo : StatusChangeInfo ) : String
object Mastodon:
final case class Announce( extraParams : Map[String,String] ) extends SubscriptionManager.Mastodon:
override val sampleWithinTypeId = "https://www.someblog.com/post/1111.html"
override def withExtraParams( extraParams : Map[String,String] ) : Announce = this.copy( extraParams = extraParams )
override def withinTypeId( conn : Connection, subscribableName : SubscribableName, feedId : FeedId, guid : Guid, content : ItemContent, status : ItemStatus ) : Option[String] =
Some( guid.toString() )
override def isComplete( conn : Connection, withinTypeId : String, currentCount : Int, feedLastAssigned : Instant ) : Boolean = true
def formatTemplate( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, content : ItemContent ) : Option[String] =
Customizer.MastoAnnouncement.retrieve( subscribableName ).fold( defaultFormatTemplate( subscribableName, withinTypeId, feedUrl, content ) ): customizer =>
customizer( subscribableName, this, withinTypeId, feedUrl, content )
def defaultFormatTemplate( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, content : ItemContent ) : Option[String] = // ADD EXTRA-PARAMS AND GUIDs
//assert( contents.size == 1, s"Mastodon.Announce expects contents exactly one item, while generating default subject, we found ${contents.size}." )
( content.title, content.author, content.link) match
case (Some(title), Some(author), Some(link)) => Some( s"[${subscribableName}] New Post: ${title}, by ${author} ${link}" )
case (Some(title), None, Some(link)) => Some( s"[${subscribableName}] New Post: ${title} ${link}" )
case (None, Some(author), Some(link)) => Some( s"[${subscribableName}] New Untitled Post, by ${author} ${link}" )
case (None, None, Some(link)) => Some( s"[${subscribableName}] New Untitled Post at ${link}" )
case (_, _, None ) =>
WARNING.log( s"No link found. withinTypeId: ${withinTypeId}" )
None
override def route( conn : Connection, assignableKey : AssignableKey, contents : Seq[ItemContent], idestinations : Set[IdentifiedDestination[D]], apiLinkGenerator : ApiLinkGenerator ) : Unit =
val uniqueContent = contents.uniqueOr: (c, nu) =>
throw new WrongContentsMultiplicity(s"${this}: We expect exactly one item to render, found $nu: " + contents.map( ci => (ci.title orElse ci.link).getOrElse("- ") ).mkString(", "))
val ( feedId, feedUrl ) = PgDatabase.feedIdUrlForSubscribableName( conn, assignableKey.subscribableName )
val mbTemplate = formatTemplate( assignableKey.subscribableName, assignableKey.withinTypeId, feedUrl, uniqueContent )
mbTemplate.foreach: template =>
val mastoDestinationsWithTemplateParams =
idestinations.map: idestination =>
val destination = idestination.destination
val sid = idestination.subscriptionId
val templateParams = composeTemplateParams( assignableKey.subscribableName, assignableKey.withinTypeId, feedUrl, destination, sid, apiLinkGenerator.removeGetLink(sid) )
( destination, templateParams )
mastoDestinationsWithTemplateParams.foreach: ( destination, templateParams ) =>
val fullContent = templateParams.fill( template )
PgDatabase.queueForMastoPost( conn, fullContent, MastoInstanceUrl( destination.instanceUrl ), MastoName( destination.name ), uniqueContent.media )
override def defaultComposeTemplateParams( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, destination : D, subscriptionId : SubscriptionId, removeLink : String ) : Map[String,String] =
Map(
"instanceUrl" -> destination.instanceUrl,
"destinationName" -> destination.name
)
sealed trait Mastodon extends SubscriptionManager:
override type D = Destination.Mastodon
override val sampleDestination = Destination.Mastodon( name = "mothership", instanceUrl = "https://mastodon.social/" )
override def destinationCsvRowHeaders : Seq[String] = Destination.csvRowHeaders[D]
end Mastodon
object Email:
type Companion = Each.type | Daily.type | Weekly.type | Fixed.type
type Instance = Each | Daily | Weekly | Fixed
case class Each(
from : Destination.Email,
replyTo : Option[Destination.Email],
composeUntemplateName : String,
confirmUntemplateName : String,
statusChangeUntemplateName : String,
removalNotificationUntemplateName : String,
extraParams : Map[String,String]
) extends Email:
override val sampleWithinTypeId = "https://www.someblog.com/post/1111.html"
override def withExtraParams( extraParams : Map[String,String] ) : Each = this.copy( extraParams = extraParams )
override def withComposeUntemplateName( name : String ) : Each = this.copy( composeUntemplateName = name )
override def withConfirmUntemplateName( name : String ) : Each = this.copy( confirmUntemplateName = name )
override def withStatusChangeUntemplateName( name : String ) : Each = this.copy( statusChangeUntemplateName = name )
override def withRemovalNotificationUntemplateName( name : String ) : Each = this.copy( removalNotificationUntemplateName = name )
override def isComposeMultiple : Boolean = false
override def withinTypeId( conn : Connection, subscribableName : SubscribableName, feedId : FeedId, guid : Guid, content : ItemContent, status : ItemStatus ) : Option[String] =
Some( guid.toString() )
override def isComplete( conn : Connection, withinTypeId : String, currentCount : Int, feedLastAssigned : Instant ) : Boolean = true
override def route( conn : Connection, assignableKey : AssignableKey, contents : Seq[ItemContent], idestinations : Set[IdentifiedDestination[D]], apiLinkGenerator : ApiLinkGenerator ) : Unit =
routeSingle( conn, assignableKey, contents, idestinations, apiLinkGenerator )
override def defaultSubject( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, contents : Seq[ItemContent] ) : String =
assert( contents.size == 1, s"Email.Each expects contents exactly one item, while generating default subject, we found ${contents.size}." )
s"[${subscribableName}] " + contents.head.title.fold("New Untitled Post")( title => s"New Post: ${title}" )
final case class Weekly(
from : Destination.Email,
replyTo : Option[Destination.Email],
composeUntemplateName : String,
confirmUntemplateName : String,
statusChangeUntemplateName : String,
removalNotificationUntemplateName : String,
timeZone : Option[ZoneId],
extraParams : Map[String,String]
) extends Email, PeriodBased:
private val WtiFormatter = DateTimeFormatter.ofPattern("YYYY-'week'ww")
override val sampleWithinTypeId = "2023-week50"
override def withExtraParams( extraParams : Map[String,String] ) : Weekly = this.copy( extraParams = extraParams )
override def withComposeUntemplateName( name : String ) : Weekly = this.copy( composeUntemplateName = name )
override def withConfirmUntemplateName( name : String ) : Weekly = this.copy( confirmUntemplateName = name )
override def withStatusChangeUntemplateName( name : String ) : Weekly = this.copy( statusChangeUntemplateName = name )
override def withRemovalNotificationUntemplateName( name : String ) : Weekly = this.copy( removalNotificationUntemplateName = name )
override def isComposeMultiple : Boolean = true
// this is only fixed on assignment, should be lastChecked, because week in which firstSeen might already have passed
override def withinTypeId(
conn : Connection,
subscribableName : SubscribableName,
feedId : FeedId,
guid : Guid,
content : ItemContent,
status : ItemStatus
) : Option[String] =
val tz = bestTimeZone( conn )
Some( WtiFormatter.format( status.lastChecked.atZone(tz) ) )
// Regular TemporalFields don't work on the formatter-parsed accessor. We need a WeekFields thang first
private def extractYearWeekAndWeekFields( withinTypeId : String ) : (Int, Int, WeekFields) =
val ( yearStr, restStr ) = withinTypeId.span( Character.isDigit )
val year = yearStr.toInt
val baseDayOfWeek = LocalDate.of( year, 1, 1 ).getDayOfWeek()
val weekNum = restStr.dropWhile( c => !Character.isDigit(c) ).toInt
( year, weekNum, WeekFields.of(baseDayOfWeek, 1) )
override def isComplete( conn : Connection, withinTypeId : String, currentCount : Int, feedLastAssigned : Instant ) : Boolean =
val ( year, woy, weekFields ) = extractYearWeekAndWeekFields( withinTypeId )
val tz = bestTimeZone( conn )
val laZoned = feedLastAssigned.atZone(tz)
val laYear = laZoned.get( ChronoField.YEAR )
laYear > year || (laYear == year && laZoned.get( ChronoField.ALIGNED_WEEK_OF_YEAR ) > woy)
override def route( conn : Connection, assignableKey : AssignableKey, contents : Seq[ItemContent], idestinations : Set[IdentifiedDestination[D]], apiLinkGenerator : ApiLinkGenerator ) : Unit =
routeMultiple( conn, assignableKey, contents, idestinations, apiLinkGenerator )
def weekStartWeekEndLocalDate( withinTypeId : String ) : (LocalDate,LocalDate) =
val ( year, woy, weekFields ) = extractYearWeekAndWeekFields( withinTypeId )
val weekStart = LocalDate.of(year, 1, 1).`with`( weekFields.weekOfWeekBasedYear(), woy ).`with`(weekFields.dayOfWeek(), 1 )
val weekEnd = LocalDate.of(year, 1, 1).`with`( weekFields.weekOfWeekBasedYear(), woy ).`with`(weekFields.dayOfWeek(), 7 )
(weekStart, weekEnd)
def weekStartWeekEndFormattedIsoLocal( withinTypeId : String ) : (String,String) =
val (weekStart, weekEnd) = weekStartWeekEndLocalDate(withinTypeId)
(ISO_LOCAL_DATE.format(weekStart),ISO_LOCAL_DATE.format(weekEnd))
override def defaultSubject( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, contents : Seq[ItemContent] ) : String =
val (weekStart, weekEnd) = weekStartWeekEndFormattedIsoLocal(withinTypeId)
s"[${subscribableName}] All posts, ${weekStart} to ${weekEnd}"
final case class Daily(
from : Destination.Email,
replyTo : Option[Destination.Email],
composeUntemplateName : String,
confirmUntemplateName : String,
statusChangeUntemplateName : String,
removalNotificationUntemplateName : String,
timeZone : Option[ZoneId],
extraParams : Map[String,String]
) extends Email, PeriodBased:
private val WtiFormatter = DateTimeFormatter.ofPattern("YYYY-'day'DD")
override val sampleWithinTypeId = "2024-day4"
override def withExtraParams( extraParams : Map[String,String] ) : Daily = this.copy( extraParams = extraParams )
override def withComposeUntemplateName( name : String ) : Daily = this.copy( composeUntemplateName = name )
override def withConfirmUntemplateName( name : String ) : Daily = this.copy( confirmUntemplateName = name )
override def withStatusChangeUntemplateName( name : String ) : Daily = this.copy( statusChangeUntemplateName = name )
override def withRemovalNotificationUntemplateName( name : String ) : Daily = this.copy( removalNotificationUntemplateName = name )
override def isComposeMultiple : Boolean = true
// this is only fixed on assignment, should be lastChecked, because week in which firstSeen might already have passed
override def withinTypeId(
conn : Connection,
subscribableName : SubscribableName,
feedId : FeedId,
guid : Guid,
content : ItemContent,
status : ItemStatus
) : Option[String] =
val tz = bestTimeZone( conn )
Some( WtiFormatter.format( status.lastChecked.atZone(tz) ) )
// Regular TemporalFields don't work on the formatter-parsed accessor. We need a WeekFields thang first
private def extractYearAndDay( withinTypeId : String ) : (Int, Int) =
val ( yearStr, restStr ) = withinTypeId.span( Character.isDigit )
val dayStr = restStr.dropWhile( c => !Character.isDigit(c) ).toInt
( yearStr.toInt, dayStr.toInt )
override def isComplete( conn : Connection, withinTypeId : String, currentCount : Int, feedLastAssigned : Instant ) : Boolean =
val ( year, day ) = extractYearAndDay( withinTypeId )
val tz = bestTimeZone( conn )
val laZoned = feedLastAssigned.atZone(tz)
val laYear = laZoned.get( ChronoField.YEAR )
laYear > year || (laYear == year && laZoned.get( ChronoField.DAY_OF_YEAR ) > day)
override def route( conn : Connection, assignableKey : AssignableKey, contents : Seq[ItemContent], idestinations : Set[IdentifiedDestination[D]], apiLinkGenerator : ApiLinkGenerator ) : Unit =
routeMultiple( conn, assignableKey, contents, idestinations, apiLinkGenerator )
def dayLocalDate( withinTypeId : String ) : LocalDate =
val ( year, day ) = extractYearAndDay( withinTypeId )
LocalDate.ofYearDay( year, day )
def dayFormattedIsoLocal( withinTypeId : String ) : String = ISO_LOCAL_DATE.format( dayLocalDate( withinTypeId ) )
override def defaultSubject( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, contents : Seq[ItemContent] ) : String =
s"[${subscribableName}] All posts, ${dayFormattedIsoLocal(withinTypeId)}"
final case class Fixed(
from : Destination.Email,
replyTo : Option[Destination.Email],
composeUntemplateName : String,
confirmUntemplateName : String,
statusChangeUntemplateName : String,
removalNotificationUntemplateName : String,
numItemsPerLetter : Int,
extraParams : Map[String,String]
) extends Email:
private val WtiFormatter = DateTimeFormatter.ofPattern("YYYY-'day'DD")
override val sampleWithinTypeId = "1"
override def withExtraParams( extraParams : Map[String,String] ) : Fixed = this.copy( extraParams = extraParams )
override def withComposeUntemplateName( name : String ) : Fixed = this.copy( composeUntemplateName = name )
override def withConfirmUntemplateName( name : String ) : Fixed = this.copy( confirmUntemplateName = name )
override def withStatusChangeUntemplateName( name : String ) : Fixed = this.copy( statusChangeUntemplateName = name )
override def withRemovalNotificationUntemplateName( name : String ) : Fixed = this.copy( removalNotificationUntemplateName = name )
override def isComposeMultiple : Boolean = true
// this is only fixed on assignment, should be lastChecked, because week in which firstSeen might already have passed
override def withinTypeId(
conn : Connection,
subscribableName : SubscribableName,
feedId : FeedId,
guid : Guid,
content : ItemContent,
status : ItemStatus
) : Option[String] =
def nextAfter( wti : String ) : String = (wti.toLong + 1).toString
PgDatabase.mostRecentlyOpenedAssignableWithinTypeStatus( conn, subscribableName ) match
case Some( AssignableWithinTypeStatus( withinTypeId, count ) ) =>
if count < numItemsPerLetter then Some( withinTypeId ) else Some( nextAfter(withinTypeId) )
case None =>
PgDatabase.lastCompletedWithinTypeId( conn, subscribableName ) match
case Some(wti) => Some(nextAfter(wti))
case None => // first series!
Some("1")
override def isComplete( conn : Connection, withinTypeId : String, currentCount : Int, feedLastAssigned : Instant ) : Boolean =
currentCount == numItemsPerLetter
override def route( conn : Connection, assignableKey : AssignableKey, contents : Seq[ItemContent], idestinations : Set[IdentifiedDestination[D]], apiLinkGenerator : ApiLinkGenerator ) : Unit =
routeMultiple( conn, assignableKey, contents, idestinations, apiLinkGenerator )
override def defaultSubject( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, contents : Seq[ItemContent] ) : String =
s"[${subscribableName}] ${numItemsPerLetter} new items"
sealed trait Email extends SubscriptionManager, UntemplatedCompose, UntemplatedConfirm, UntemplatedStatusChange, UntemplatedRemovalNotification, SupportsExternalSubscriptionApi:
def from : Destination.Email
def replyTo : Option[Destination.Email]
def composeUntemplateName : String
def confirmUntemplateName : String
def statusChangeUntemplateName : String
def removalNotificationUntemplateName : String
def extraParams : Map[String,String]
type D = Destination.Email
override def sampleDestination : Destination.Email = Destination.Email("[email protected]", Some("Some User"))
protected def findTosWithTemplateParams( assignableKey : AssignableKey, feedUrl : FeedUrl, idestinations : Set[IdentifiedDestination[D]], apiLinkGenerator : ApiLinkGenerator ) : Set[(AddressHeader[To],TemplateParams)] =
idestinations.map: idestination =>
val to = idestination.destination.rendered
val sid = idestination.subscriptionId
val templateParams = composeTemplateParams( assignableKey.subscribableName, assignableKey.withinTypeId, feedUrl, idestination.destination, sid, apiLinkGenerator.removeGetLink(sid) )
( AddressHeader[To](to), templateParams )
protected def routeSingle( conn : Connection, assignableKey : AssignableKey, contents : Seq[ItemContent], idestinations : Set[IdentifiedDestination[D]], apiLinkGenerator : ApiLinkGenerator ) : Unit =
val uniqueContent = contents.uniqueOr: (c, nu) =>
throw new WrongContentsMultiplicity(s"${this}: We expect exactly one item to render, found $nu: " + contents.map( ci => (ci.title orElse ci.link).getOrElse("
- ") ).mkString(", "))
val ( feedId, feedUrl ) = PgDatabase.feedIdUrlForSubscribableName( conn, assignableKey.subscribableName )
val computedSubject = subject( assignableKey.subscribableName, assignableKey.withinTypeId, feedUrl, contents )
val tz = bestTimeZone(conn)
val fullTemplate =
val info = ComposeInfo.Single( feedUrl, assignableKey.subscribableName, this, assignableKey.withinTypeId, tz, contents.head )
val compose = AllUntemplates.findComposeUntemplateSingle(composeUntemplateName)
compose( info ).text
val tosWithTemplateParams = findTosWithTemplateParams( assignableKey, feedUrl, idestinations, apiLinkGenerator )
PgDatabase.queueForMailing( conn, fullTemplate, AddressHeader[From](from), replyTo.map(AddressHeader.apply[ReplyTo]), tosWithTemplateParams, computedSubject)
protected def routeMultiple( conn : Connection, assignableKey : AssignableKey, contents : Seq[ItemContent], idestinations : Set[IdentifiedDestination[D]], apiLinkGenerator : ApiLinkGenerator ) : Unit =
if contents.nonEmpty then
val ( feedId, feedUrl ) = PgDatabase.feedIdUrlForSubscribableName( conn, assignableKey.subscribableName )
val computedSubject = subject( assignableKey.subscribableName, assignableKey.withinTypeId, feedUrl, contents )
val tz = bestTimeZone(conn)
val fullTemplate =
val info = ComposeInfo.Multiple( feedUrl, assignableKey.subscribableName, this, assignableKey.withinTypeId, tz, contents )
val compose = AllUntemplates.findComposeUntemplateMultiple(composeUntemplateName)
compose( info ).text
val tosWithTemplateParams = findTosWithTemplateParams( assignableKey, feedUrl, idestinations, apiLinkGenerator )
PgDatabase.queueForMailing( conn, fullTemplate, AddressHeader[From](from), replyTo.map(AddressHeader.apply[ReplyTo]), tosWithTemplateParams, computedSubject)
def subject( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, contents : Seq[ItemContent] ) : String =
Customizer.Subject.retrieve( subscribableName ).fold( defaultSubject( subscribableName, withinTypeId, feedUrl, contents ) ): customizer =>
customizer( subscribableName, this, withinTypeId, feedUrl, contents )
def defaultSubject( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, contents : Seq[ItemContent] ) : String
override def defaultComposeTemplateParams( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, destination : D, subscriptionId : SubscriptionId, removeLink : String ) : Map[String,String] =
val toAddress = destination.toAddress
val toFull = toAddress.rendered
val toNickname = toAddress.displayName
val toEmail = toAddress.email
extraParams.toMap ++ Map(
"from" -> from.rendered,
"replyTo" -> replyTo.map( _.rendered ).getOrElse(""),
"to" -> toFull,
"toFull" -> toFull,
"toNickname" -> toNickname.getOrElse(""),
"toEmail" -> toEmail,
"toNicknameOrEmail" -> toNickname.getOrElse( toEmail ),
"removeLink" -> removeLink
).filter( _._2.nonEmpty )
// the destination should already be validated before we get to this point.
// we won't revalidate
override def maybePromptConfirmation( conn : Connection, as : AppSetup, subscriptionId : SubscriptionId, subscribableName : SubscribableName, destination : D, confirmGetLink : String, removeGetLink : String ) : Boolean =
val subject = s"[${subscribableName}] Please confirm your new subscription" // XXX: Hardcoded subject, revisit someday
val confirmHours = PgDatabase.Config.confirmHours( conn )
val mailText =
val confirmUntemplate = AllUntemplates.findConfirmUntemplate( confirmUntemplateName )
val confirmInfo = ConfirmInfo( destination, subscribableName, this, confirmGetLink, removeGetLink, confirmHours )
confirmUntemplate( confirmInfo ).text
PgDatabase.mailImmediately( conn, as, mailText, AddressHeader[From](from), replyTo.map(AddressHeader.apply[ReplyTo]), AddressHeader[To](destination.toAddress),TemplateParams.empty,subject)
true
override def maybeSendRemovalNotification( conn : Connection, as : AppSetup, subscriptionId : SubscriptionId, subscribableName : SubscribableName, destination : this.D, createGetLink : String ) : Boolean =
val subject = s"[${subscribableName}] Unsubscribed! We are sorry to see you go." // XXX: Hardcoded subject, revisit someday
val mailText =
val removalNotificationUntemplate = AllUntemplates.findRemovalNotificationUntemplate( removalNotificationUntemplateName )
val removalNotificationInfo = RemovalNotificationInfo( subscribableName, this, destination, createGetLink )
removalNotificationUntemplate( removalNotificationInfo ).text
PgDatabase.mailImmediately( conn, as, mailText, AddressHeader[From](from), replyTo.map(AddressHeader.apply[ReplyTo]), AddressHeader[To](destination.toAddress),TemplateParams.empty,subject)
true
override def htmlForStatusChange( statusChangeInfo : StatusChangeInfo ) : String =
val untemplate = AllUntemplates.findStatusChangeUntemplate(statusChangeUntemplateName)
untemplate( statusChangeInfo ).text
override def displayShort( destination : D ) : String = destination.displayNamePart.getOrElse( destination.addressPart )
override def destinationCsvRowHeaders : Seq[String] = Destination.csvRowHeaders[D]
end Email
def materialize( json : Json ) : SubscriptionManager = read[SubscriptionManager]( json.toString() )
object Tag:
def forJsonVal( jsonVal : String ) : Option[Tag] = Tag.values.find( _.jsonVal == jsonVal )
enum Tag(val jsonVal : String):
case Email_Each extends Tag("Email.Each")
case Email_Daily extends Tag("Email.Daily")
case Email_Weekly extends Tag("Email.Weekly")
case Email_Fixed extends Tag("Email.Fixed")
case Masto_Announce extends Tag("Mastodon.Announce")
private def toUJsonV1( subscriptionManager : SubscriptionManager ) : ujson.Value =
def esf( emailsub : Email.Instance ) : ujson.Obj = // "email shared fields"
val values = Seq(
"from" -> writeJs[Destination]( emailsub.from ),
"composeUntemplateName" -> ujson.Str( emailsub.composeUntemplateName ),
"confirmUntemplateName" -> ujson.Str( emailsub.confirmUntemplateName ),
"statusChangeUntemplateName" -> ujson.Str( emailsub.statusChangeUntemplateName ),
"removalNotificationUntemplateName" -> ujson.Str( emailsub.removalNotificationUntemplateName ),
"extraParams" -> writeJs( emailsub.extraParams )
) ++ emailsub.replyTo.map( rt => ("replyTo" -> writeJs[Destination](rt) ) )
ujson.Obj.from( values )
def epbsf( pbsm : Email.Instance & PeriodBased ) : ujson.Obj = // "email period-based shared fields"
pbsm.timeZone match
case Some( tz ) => ujson.Obj.from( esf( pbsm ).obj addOne( ("timeZone" -> tz.getId()) ) )
case None => esf( pbsm )
def eef( each : Email.Each ) : ujson.Obj = esf( each )
def edf( daily : Email.Daily ) : ujson.Obj = epbsf( daily )
def ewf( weekly : Email.Weekly ) : ujson.Obj = epbsf( weekly )
def eff( fixed : Email.Fixed ) : ujson.Obj = ujson.Obj.from( esf( fixed ).obj addOne( ("numItemsPerLetter" -> fixed.numItemsPerLetter) ) )
def maf( announce : Mastodon.Announce ) : ujson.Obj = ujson.Obj( // "mastodon announce fields"
"extraParams" -> writeJs( announce.extraParams )
)
val (fields, tpe) =
subscriptionManager match
case each : Email.Each => ( eef(each), Tag.Email_Each )
case daily : Email.Daily => ( edf(daily), Tag.Email_Daily )
case weekly : Email.Weekly => ( ewf(weekly), Tag.Email_Weekly )
case fixed : Email.Fixed => ( eff(fixed), Tag.Email_Fixed )
case announce : Mastodon.Announce => ( maf(announce), Tag.Masto_Announce )
val headerFields =
ujson.Obj(
"version" -> 1,
"type" -> tpe.jsonVal
)
ujson.Obj.from(headerFields.obj ++ fields.obj)
private def fromUJsonV1( jsonValue : ujson.Value ) : SubscriptionManager =
val obj = jsonValue.obj
val version = obj.get("version").map( _.num.toInt )
if version.nonEmpty && version != Some(1) then
throw new InvalidSubscriptionManager(s"Unpickling SubscriptionManager, found version ${version.get}, expected version 1: " + obj.mkString(", "))
else
val tpe = obj("type").str
val tag = Tag.forJsonVal(tpe).getOrElse:
throw new InvalidSubscriptionManager(s"While unpickling a subscription manager, found unknown tag '$tpe': " + jsonValue)
tag match
case Tag.Email_Each => Email.Each(
from = read[Destination](obj("from")).asInstanceOf[Destination.Email],
replyTo = obj.get("replyTo").map( rtv => read[Destination](rtv).asInstanceOf[Destination.Email] ),
composeUntemplateName = obj("composeUntemplateName").str,
confirmUntemplateName = obj("confirmUntemplateName").str,
statusChangeUntemplateName = obj("statusChangeUntemplateName").str,
removalNotificationUntemplateName = obj("removalNotificationUntemplateName").str,
extraParams = read[Map[String,String]](obj("extraParams"))
)
case Tag.Email_Daily => Email.Daily(
from = read[Destination](obj("from")).asInstanceOf[Destination.Email],
replyTo = obj.get("replyTo").map( rtv => read[Destination](rtv).asInstanceOf[Destination.Email] ),
composeUntemplateName = obj("composeUntemplateName").str,
confirmUntemplateName = obj("confirmUntemplateName").str,
statusChangeUntemplateName = obj("statusChangeUntemplateName").str,
removalNotificationUntemplateName = obj("removalNotificationUntemplateName").str,
timeZone = obj.get("timeZone").map( _.str ).map( ZoneId.of ),
extraParams = read[Map[String,String]](obj("extraParams"))
)
case Tag.Email_Weekly => Email.Weekly(
from = read[Destination](obj("from")).asInstanceOf[Destination.Email],
replyTo = obj.get("replyTo").map( rtv => read[Destination](rtv).asInstanceOf[Destination.Email] ),
composeUntemplateName = obj("composeUntemplateName").str,
confirmUntemplateName = obj("confirmUntemplateName").str,
statusChangeUntemplateName = obj("statusChangeUntemplateName").str,
removalNotificationUntemplateName = obj("removalNotificationUntemplateName").str,
timeZone = obj.get("timeZone").map( _.str ).map( ZoneId.of ),
extraParams = read[Map[String,String]](obj("extraParams"))
)
case Tag.Email_Fixed => Email.Fixed(
from = read[Destination](obj("from")).asInstanceOf[Destination.Email],
replyTo = obj.get("replyTo").map( rtv => read[Destination](rtv).asInstanceOf[Destination.Email] ),
composeUntemplateName = obj("composeUntemplateName").str,
confirmUntemplateName = obj("confirmUntemplateName").str,
statusChangeUntemplateName = obj("statusChangeUntemplateName").str,
removalNotificationUntemplateName = obj("removalNotificationUntemplateName").str,
numItemsPerLetter = obj("numItemsPerLetter").num.toInt,
extraParams = read[Map[String,String]](obj("extraParams"))
)
case Tag.Masto_Announce => Mastodon.Announce(
extraParams = read[Map[String,String]](obj("extraParams"))
)
given ReadWriter[SubscriptionManager] = readwriter[ujson.Value].bimap[SubscriptionManager]( toUJsonV1, fromUJsonV1 )
sealed trait SubscriptionManager extends Jsonable:
type D <: Destination
def extraParams : Map[String,String]
def withExtraParams( extraParams : Map[String,String] ) : SubscriptionManager
def sampleWithinTypeId : String
def sampleDestination : D // used for styling, but also to check at runtime that Destinations are of the expected class. See narrowXXX methods below
def withinTypeId( conn : Connection, subscribableName : SubscribableName, feedId : FeedId, guid : Guid, content : ItemContent, status : ItemStatus ) : Option[String]
def isComplete( conn : Connection, withinTypeId : String, currentCount : Int, feedLastAssigned : Instant ) : Boolean
def validateSubscriptionOrThrow( conn : Connection, fromExternalApi : Boolean, destination : Destination, subscribableName : SubscribableName ) : Unit =
if fromExternalApi && !supportsExternalSubscriptionApi then
throw new ExternalApiForibidden( s"[${subscribableName}]: Subscriptions must be made by an administrator, rather than via the public API." )
else
narrowDestination( destination ) match
case Right( _ ) => ()
case Left ( _ ) =>
throw new InvalidDestination( s"[${subscribableName}] Incorrect destination type. We expect a ${sampleDestination.getClass.getName()}. Destination '${destination}' is not. Rejecting." )
def composeTemplateParams( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, destination : D, subscriptionId : SubscriptionId, removeLink : String ) : TemplateParams =
TemplateParams(
defaultComposeTemplateParams(subscribableName, withinTypeId, feedUrl, destination, subscriptionId, removeLink ) ++
Customizer.TemplateParams.retrieve( subscribableName ).fold(Nil)( customizer => customizer( subscribableName, this, withinTypeId, feedUrl, destination, subscriptionId, removeLink ) )
)
def defaultComposeTemplateParams( subscribableName : SubscribableName, withinTypeId : String, feedUrl : FeedUrl, destination : D, subscriptionId : SubscriptionId, removeLink : String ) : Map[String,String]
def route( conn : Connection, assignableKey : AssignableKey, contents : Seq[ItemContent], destinations : Set[IdentifiedDestination[D]], apiLinkGenerator : ApiLinkGenerator ) : Unit
def json : SubscriptionManager.Json = SubscriptionManager.Json( write[SubscriptionManager](this) )
def jsonPretty : SubscriptionManager.Json = SubscriptionManager.Json( write[SubscriptionManager](this, indent=4) )
def materializeDestination( destinationJson : Destination.Json ) : D = Destination.materialize( destinationJson ).asInstanceOf[D]
def narrowDestinationOrThrow( destination : Destination ) : D =
if destination.getClass == sampleDestination.getClass then
destination.asInstanceOf[D]
else
throw new InvalidDestination(s"Destination '$destination' is not valid for SubscriptionManager '$this'.")
def narrowDestination( destination : Destination ) : Either[Destination,D] =
if destination.getClass == sampleDestination.getClass then
Right(destination.asInstanceOf[D])
else
Left(destination)
def narrowIdentifiedDestinationOrThrow( idestination : IdentifiedDestination[Destination] ) : IdentifiedDestination[D] =
if idestination.destination.getClass == sampleDestination.getClass then
idestination.asInstanceOf[IdentifiedDestination[D]]
else
throw new InvalidDestination(s"Identified destination '$idestination' is not valid for SubscriptionManager '$this'.")
def narrowIdentifiedDestination( idestination : IdentifiedDestination[Destination] ) : Either[IdentifiedDestination[Destination],IdentifiedDestination[D]] =
if idestination.destination.getClass == sampleDestination.getClass then
Right( idestination.asInstanceOf[IdentifiedDestination[D]] )
else
Left( idestination )
final def descShort( destination : D ) : String = destination.shortDesc
final def descFull( destination : D ) : String = destination.fullDesc
// these we override as convenient
def displayShort( destination : D ) : String = destination.shortDesc
def displayFull( destination : D ) : String = destination.fullDesc
// this we defer until D is concrete
def destinationCsvRowHeaders : Seq[String]
final def destinationCsvRow( destination : D ) : Seq[String] = destination.toCsvRow
def supportsExternalSubscriptionApi : Boolean = this.isInstanceOf[SubscriptionManager.SupportsExternalSubscriptionApi]
def bestTimeZone( conn : Connection ) : ZoneId = PgDatabase.Config.timeZone( conn ) // SubscriptionManagers that allow locally-specified time zones should override
© 2015 - 2025 Weber Informatics LLC | Privacy Policy